Compare commits

..
45 Commits
Author SHA1 Message Date
kantarciandClaude Opus 5 af5a2cef86 Fassung 1.4.6: das Update schreibt mit, und die Ergebnisseite ist nachsichtig
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Zwei Nachtraege zu 1.4.5, beide aus der Generalprobe.

- Jeder Schritt des Archiv-Updaters geht sofort nach var/updates.log,
  waehrend des Laufs und nicht am Ende. Auf Umuts Hosting war die
  Ergebnisseite tot, und damit war auch verloren, was der Updater
  berichtet hatte. Genau so wurde der naechste Fehlschlag dann doch
  lesbar: "bin liess sich nicht beiseitelegen - der alte Stand wurde
  zurueckgeschoben" (ein Windows-Artefakt der Testumgebung, und
  zugleich der Beweis, dass der Rueckweg funktioniert).
- Die Ergebnisseite versteht auch die Schreibweise bis 1.4.4. Das ist
  kein Nachgeben, sondern noetig: Beim Update schreibt der alte Code
  die Schritte in die Sitzung und der neue zeigt sie an. Eine Fassung,
  die nur die neue Schreibweise kennt, koennte das Update, das sie
  selbst einspielt, nicht berichten.

Nachgestellt mit einer echten Installation aus dem Releasearchiv, die
ueber die Oberflaeche aktualisiert wurde - nicht ueber die Konsole.

Geprueft: 434 Tests auf PHP 8.2 und 8.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:04:37 +02:00
kantarciandClaude Opus 5 d295757ce0 Fassung 1.4.5: drei Fehler, die nur auf fremden Servern auftraten
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Alle drei von Umut auf seinem Hosting gefunden, keiner davon auf
meinem Rechner sichtbar.

- Der Updateknopf endete mit 500. Das Update lief vollstaendig durch -
  alle neun Schritte, Dateien getauscht, var/ und .env unberuehrt -,
  aber die Ergebnisseite starb daran: der Updater ueber Git nennt
  einen Schritt "step", der ueber das Archiv nannte ihn "label", beide
  legen ihn unter demselben Sitzungsschluessel ab, und die Vorlage
  liest "step". Damit war auch der zweite Knopf unerreichbar. Zehn
  Tests prueften das Rueckgabe-Array, keiner die Seite, die es
  anzeigt; der neue Test rendert sie (ADR 071).
- Die Verwaltung zeigte Git-Fehler auf einer Installation ohne Git.
  "git ist installiert" und "das hier ist eine Arbeitskopie" waren
  derselbe Schalter; auf dem Hosting ist Git vorhanden, die
  Installation kam aber aus dem Archiv. Dort stand "fatal: Not a git
  repository" an der Stelle der Fassungsnummer. Ohne Arbeitskopie
  verschwinden die Git-Karten jetzt (ADR 072).
- "Sicherung anlegen" endete mit 500: VACUUM INTO gibt es erst seit
  SQLite 3.27, das Hosting hat eine aeltere Fassung - alt genug fuer
  die Installation, zu alt fuer die Sicherung. Bei aelteren wird jetzt
  unter einer Schreibsperre kopiert, und der Knopf faengt jeden Fehler
  ab statt mit einer 500-Seite zu enden (ADR 072).

Nachgestellt wurde das mit einer echten 1.4.3-Installation aus dem
Releasearchiv, eigener Datenbank, eigenem Port. Der Archiv-Updater
laeuft dort vollstaendig durch: 1.4.3 -> 1.4.4, neun Schritte, var/
und .env unberuehrt.

Geprueft: 433 Tests auf PHP 8.2 und 8.4, Editor-Browsertest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:54:13 +02:00
kantarciandClaude Opus 5 4153ba0f65 Fassung 1.4.4: Migrationen im Browser, und ein Fehlschlag sagt warum
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Zwei Luecken, die erst das Veroeffentlichen von 1.4.3 gezeigt hat.

- Es gab keinen Weg, ausstehende Migrationen im Browser auszufuehren.
  Wer die Dateien per FTP hochlaedt - und das muss genau einmal jeder
  tun, dessen Installation die Updatekarte noch nicht kennt -, hatte
  danach neuen Code und eine alte Datenbank, und dafuer nur die
  Konsole. Also genau das, was auf geteiltem Hosting fehlt. Der
  Bildschirm "System" zeigt jetzt eine Karte, sobald etwas aussteht,
  nennt die Namen und fuehrt sie auf Knopfdruck aus.
- "Die Releaseseite antwortet nicht" waren drei Ursachen in einem
  Satz. Beim Gegenpruefen des frischen Releases holte der Updater von
  hier gar nichts - nicht weil die Leitung zu waere, sondern weil
  diesem PHP der Zertifikatsspeicher fehlt. Auf einem Webhosting ist
  das der haeufigste Grund und der einzige, den man in einer Minute
  behebt; er steht jetzt in der Meldung. Bei genau diesem Fehler wird
  zusaetzlich der Weg ueber den Dateizeiger versucht - damit
  funktioniert der Abruf auf diesem Rechner. Abgeschaltet wird die
  Pruefung nicht: ein Update ohne Zertifikatspruefung koennte
  unterwegs ausgetauscht worden sein.

Dazu der manuelle Weg Schritt fuer Schritt in docs/UPDATE.md und ein
Test, der beides festhaelt: dass die Karte auffaellt und dass nur ein
angemeldeter Administrator mit gueltigem Formular migrieren kann.

Geprueft: 431 Tests auf PHP 8.2 und 8.4, Editor-, Browser- und
Linkpruefung, Editor-Modelltests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:15:37 +02:00
kantarciandClaude Opus 5 d6d476177a Fassung 1.4.3: Update ohne Git, Verwaltung auf dem Telefon, eigene Symbole
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Vier Wuensche und ein Fund beim Nachmessen.

- Farbwaehler im Designbereich. Das Textfeld bleibt massgeblich, damit
  "@akzent" ein Verweis auf die Palette bleibt und nicht beim ersten
  Klick zu einem festen Hexwert erstarrt (ADR 066).
- Die Verwaltung auf dem Telefon: das Menue klappt unter 760 px zu und
  nennt den Bereich, in dem man steht; Ziele 44 px, kein Skript.
- Eigene SVG-Symbole statt Unicode-Zeichen - ein Telefon ersetzt die
  sonst durch Emoji in fremdem Stil. Zwei Eintraege trugen dabei
  denselben Pfad; ein Test findet beides (ADR 067).
- Update aus dem Releasearchiv: der Weg fuer Installationen ohne Git.
  Zwei Schritte, weil ein Prozess, der sich selbst ausgetauscht hat,
  mit dem alten Code weiterarbeitet. var/, .env, hochgeladene
  Schriften und das erzeugte Buendel bleiben unberuehrt; public/ wird
  dazu Datei fuer Datei zusammengefuehrt. Geprueft ohne Netz gegen
  eine Releaseseite im Speicher (ADR 068).

Beim Nachmessen der echten Startseite statt einer Testseite: das
groesste Element ueber der Falz ist dort kein <img>, sondern der
Hintergrund des ersten Abschnitts - und der steht nur im Stylesheet.
Drei Vorabverweise mit denselben Geraetegrenzen wie der Compiler holen
genau die Variante, die die Regel auch nimmt. LCP 1,3 s -> 1,1 s.
Dabei gefunden: dasselbe Bild wurde je Geraeteregel neu abgefragt -
Startseite 19 -> 15 Abfragen (ADR 069).

Geprueft: 428 Tests auf PHP 8.2 und 8.4, Editor-, Browser- und
Linkpruefung, Lighthouse auf drei echten Seiten (alles ausser der
Leistung der Startseite 100).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:01:32 +02:00
kantarciandClaude Opus 5 ea3dd4ddf2 Fassung 1.4.2: die Systempruefung nennt die SQLite-Fassung
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Auf dem Hosting brach die Installation im dritten Schritt ab: near
"(": syntax error. Dort laeuft SQLite in einer Fassung vor 3.16, und
Doctrine liest das Schema mit Tabellenfunktionen wie
pragma_table_info(...), die es erst seit 3.16 gibt.

Die Pruefung nennt die gefundene Fassung jetzt vor der Wahl der
Datenbank, und der Datenbankschritt bietet SQLite gar nicht erst an,
wenn sie zu alt ist - mit dem Hinweis auf MySQL. Eine Voraussetzung,
die erst mitten in der Installation auffaellt, ist keine Pruefung wert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:56:17 +02:00
kantarciandClaude Opus 5 c517173579 Fassung 1.4.1: dem Releasearchiv fehlten migrations.php und cli-config.php
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Auf dem Hosting brach die Installation im dritten Schritt ab - "The
configuration file does not exist", genau dort, wo die Tabellen
entstehen sollten. Beide Dateien standen weder in der Mitnahme- noch in
der Ausschlussliste des Releaseskripts.

Ein Test ordnet jetzt jeden Eintrag im Projektstamm einer der beiden
Listen zu: was neu dazukommt und unbedacht bleibt, laesst ihn
fehlschlagen. Der Installationsweg wurde danach vollstaendig
durchgespielt - 3504 Dateien entpackt, 14 Migrationen ausgefuehrt, 15
Tabellen angelegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:43:56 +02:00
kantarciandClaude Opus 5 c5e85e4db0 Release 1.4.0 veroeffentlicht und der Installationsweg geprueft
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Archiv, Pruefsumme und install.php haengen am Release des Tags v1.4.0.
Einmal vollstaendig durchgespielt: install.php anonym geladen, in ein
leeres Verzeichnis gelegt, aufgerufen - laedt 4,7 MB, prueft die
Summe, entpackt 3502 Dateien mit vendor/ und loescht sich selbst. Die
frische Installation antwortet mit der Systempruefung unter /install.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:23:23 +02:00
kantarciandClaude Opus 5 43c20b14c7 Release veroeffentlichen: Ablauf dokumentiert, ADMIN_PATH in der Vorlage
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Das Archiv und install.php gehoeren als Anhang an das Git-Release des
passenden Tags - genau dort erwartet install.php sie. Ein privates
Repository liefert diese Adresse nicht ohne Anmeldung aus; das steht
jetzt in docs/UPDATE.md, samt der Alternative ueber --url=.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:56:56 +02:00
kantarciandClaude Opus 5 b218dc79ff Fassung 1.4.0
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:52:18 +02:00
kantarciandClaude Opus 5 aaea385422 Die Verwaltung kann umziehen: /admin ist frei waehlbar
php bin/console cms:admin:path <neu> legt die Verwaltung auf eine
andere Adresse. Geprueft wird, dass der Name frei ist und keine Seite
verdeckt; der Wert steht in .env.local und fuellt einen Parameter, der
als Praefix in jeder Verwaltungsroute steht, in der Firewall, in den
Schutzkopfzeilen und in der Statistik.

Die robots.txt nennt einen geaenderten Namen nicht - sie ist oeffentlich
und waere sonst ein Wegweiser. Beim Standardnamen bleibt die Zeile,
weil diese Adresse ohnehin jeder kennt.

Die Auffangroute fuer Seiten steht jetzt zuletzt: sie kann einen frei
gewaehlten Namen nicht per Regex ausschliessen, und die Reihenfolge ist
ohnehin die richtige. Der Befehl raeumt den Zwischenspeicher auf, aber
nicht den, in dem er selbst laeuft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:51:55 +02:00
kantarciandClaude Opus 5 9f1ac39251 Doku zu Schema v19: Knopf nach oben, Bildbetrachter, Rechtszeile, kaputte Stylesheet-Regeln
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:36:32 +02:00
kantarciandClaude Opus 5 de1e66350b Knopf nach oben, Bildbetrachter, Rechtszeile als Baustein (Schema v19)
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Der Knopf "nach oben" ist ein Anker, kein Skript: er springt an den
Seitenanfang, eingeblendet ueber eine Scroll-Zeitachse. Abschaltbar und
vollstaendig einstellbar - Seite, Form, Groesse, Farben, Schwelle,
Beschriftung.

Bilder lassen sich vergroessern, je Bild waehlbar (nichts / vergroessern
/ Adresse), Galerien mit einem Schalter. Grundlage ist ein gewoehnlicher
Verweis auf die grosse Fassung: ohne JavaScript oeffnet er das Bild, mit
JavaScript einen dialog-Betrachter mit Blaettern, Escape und Pfeiltasten.
Das Skript laedt nur, wo es etwas zu tun hat.

Die Rechtszeile wird ein Baustein und steht damit im Fussbereich statt
als heller Streifen darunter. Die eingebaute Leiste erscheint nur noch,
wenn die Seite den Baustein nicht enthaelt.

Dabei gefunden: zwei Stylesheet-Regeln griffen seit drei
Veroeffentlichungen nicht mehr. Beim Entfernen alter Regeln waren
Selektorreste stehen geblieben, und CSS nimmt dann die naechste Regel
mit - betroffen waren der Einwilligungsdialog und die Rechtszeile. Ein
Test prueft jetzt jede CSS-Datei auf abgebrochene Selektoren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:35:43 +02:00
kantarciandClaude Opus 5 74766f6684 Weiterlesen ist ein Link, Menues teilen sich eine Abfrage
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
In der wechselseitigen Beitragsliste stand "Weiterlesen" als reine
Beschriftung da: sichtbar wie ein Knopf, aber nicht anklickbar. Jetzt
ein Link, mit dem Beitragstitel im Linktext fuer Vorlesesoftware.

Jedes Menue holte beim Aufloesen die vollstaendige Seitenliste selbst.
Mit zwei Menuen fiel das nicht auf, mit vier - Kopf, zwei Listen im
Fuss, Rechtszeile - waren es vier gleiche Abfragen je Seitenaufruf.
Einmal je Anfrage gemerkt: 21 -> 18 Abfragen auf der Startseite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:41:28 +02:00
kantarciandClaude Opus 5 6c477a52be Vorschau zeigt nie eine Seite ohne ihre Gestaltung
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Gemessen: die Farben in der Vorschau und auf der veroeffentlichten
Seite waren immer identisch. Der Aufbau war das Problem - die
erzeugten Stile wurden im load-Ereignis des Rahmens angewandt, und das
feuert erst, wenn das letzte Bild geladen ist. Auf der nachgebauten
Startseite stand die Vorschau dadurch 1440 ms lang ohne eine einzige
Farbe da.

Jetzt wird angewandt, sobald das Dokument geparst ist; der Rahmen
bleibt bis dahin verdeckt; und gestaltet wird nur das neue Dokument -
der erste Anlauf gestaltete das alte und schaltete zu frueh sichtbar.

Die Editorkontrolle prueft dagegen das Verhalten statt der Zeit: der
Rahmen muss beim Aufbau verdeckt werden und danach wieder sichtbar.
Eine Zeitmessung haette auf der kleinen Testseite nichts gefunden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:08:12 +02:00
kantarciandClaude Opus 5 e0d06ac44c Cloudflare Turnstile optional, Formular-Fehlerseite repariert
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Turnstile laesst sich in den Einstellungen einschalten: aus, bis beide
Schluessel eingetragen sind; die Sicherheitsrichtlinie oeffnet sich nur
auf Seiten mit Formular, und nur fuer challenges.cloudflare.com.
Antwortet Cloudflare nicht, gilt die Absendung als bestanden - ein
Ausfall dort darf keine Kundenanfrage kosten. Die drei eingebauten
Pruefungen ohne Dritte bleiben davon unberuehrt.

Dabei gefunden: wer ein Pflichtfeld vergass, bekam die Seite ohne
Gestaltung zurueck. Der Formularcode baute sie selbst ein zweites Mal
auf und liess Theme, Blockstile und Schriften weg. Jetzt geht auch
dieser Weg durch den Seitencontroller, die Meldungen reisen als
Anfrageattribut mit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:39:40 +02:00
kantarciandClaude Opus 5 a2dfd0f796 Schriften ins Repository, Anleitung fuer neue Websites, Schutzkopfzeilen
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Die Lato-Dateien liegen jetzt unter public/assets/fonts/ samt OFL-Text
und einer Lizenznotiz: auf einem frischen Server fehlt nichts, und es
wird nie versehentlich von Google geladen.

docs/NEUE-WEBSITE.md beschreibt, wie eine neue Kundenwebsite entsteht -
Reihenfolge, Dokumentformat, Bilder, SEO, Pruefungen - und nennt am
Ende die fuenf Fehler, die hier immer wieder passiert sind.

Permissions-Policy ergaenzt (Kamera, Mikrofon, Standort, Sensoren aus),
X-Powered-By entfernt: der Server nannte in jeder Antwort seine genaue
PHP-Fassung. Die Browserpruefung misst die Kopfzeilen jetzt.

Behoben: der Editor kannte Schema v17, der Validator v18 - die
Beitragsnavigation liess sich in keinem aktuellen Dokument einfuegen.
Ein Test bindet die Zahl des Editors an die des Validators.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:28:31 +02:00
kantarciandClaude Opus 5 7c6b2a9ec8 Livegang vorbereitet: Checkliste, Weiterleitungen, www-Regel
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
docs/LIVEGANG.md trennt, was geprueft ist, von dem, was nur auf dem
echten Hosting geprueft werden kann - Mailversand, HTTPS, MySQL,
Cronjobs, Rechtstexte - und nennt die Reihenfolge am Umzugstag.

Fuer den Nachbau sind 14 Weiterleitungen von den alten
WordPress-Adressen auf die neuen angelegt; ohne sie haette jede bei
Google stehende Adresse nach dem Umzug mit 404 geantwortet. Die
Datenschutzerklaerung nennt jetzt die Tatsachen: Schriften vom eigenen
Server, kein Google Maps, kein reCAPTCHA.

In der .htaccess steht die www-Umleitung vorbereitet und
auskommentiert, mit dem Hinweis, dass sie zu SITE_ORIGIN passen muss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:16:28 +02:00
kantarciandClaude Opus 5 074ef0e7e5 Eigener Suchtitel, Schriften lokal, Bilder in Anzeigegroesse
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Getrennter Titel fuer Suchergebnisse je Seite und Beitrag (Migration
20260913000100): auf der Seite steht "Leistungen", im Suchergebnis darf
der lange Titel stehen. Leer heisst weiterhin: der Titel der Seite.

Schriften kommen jetzt vom eigenen Server. Ein Knopf im Designbereich
oder cms:fonts:fetch holt die gewaehlten Google-Schnitte als WOFF2 nach
public/assets/fonts. Das spart zwei fremde Verbindungen nacheinander
(gemessen 820 ms) und die IP-Weitergabe an Google. Ohne Dateien bleibt
es beim bisherigen Verweis - eine Seite ohne Schrift waere schlimmer.

Ein Stylesheet statt vier, mit Pruefsumme im Namen und ohne Kommentare,
dazu eine .htaccess mit Kompression, Cache und Schutz interner Dateien.
Der Entwicklungsserver sendet dieselben Kopfzeilen, sonst misst man
lokal etwas, das es im Betrieb nicht gibt.

Bilder in der Groesse, in der sie erscheinen: ein nur fuer Desktop
gesetztes Hintergrundbild gilt auf dem Telefon in Telefongroesse
(189 KB -> 40 KB), img nennt seine feste Breite oder sizes="auto", und
fetchpriority liegt auf dem ersten Bild des Inhalts statt auf dem Logo.

Neu: npm run test:links prueft jeden internen Verweis auf seine
Antwort. Lighthouse gegen den Nachbau: Barrierefreiheit, Best
Practices und SEO 100 auf allen vier geprueften Adressen, Performance
100 ausser auf der bilderreichen Startseite (98 mobil, 96 Desktop).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:11:46 +02:00
kantarciandClaude Opus 5 35d8270afd Vorschaubild beim Teilen, robots.txt als Bot-Steuerung
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Das Feld "Vorschaubild beim Teilen" gab es seit dem SEO-Paket,
ausgegeben wurde es nie: keine mit KantSites gebaute Seite hatte ein
og:image. Es wird jetzt aufgeloest - erst das gesetzte Vorschaubild,
sonst das Beitragsbild - als absolute Adresse bis 1600 px, dazu
og:site_name, og:locale, og:image:alt und twitter:card (klein ohne
Bild, gross mit). Dasselbe Bild steht im strukturierten Datensatz
eines Beitrags; Archive haben jetzt ebenfalls Open-Graph-Angaben.

Eigene robots.txt-Zeilen: 200 statt 30, und Leerzeilen bleiben als
Trenner erhalten - Regeln je Bot (welche KI-Crawler lesen duerfen)
brauchen Bloecke, nicht einen Klumpen.

Behoben: die Symbolerzeugung suchte feste Breiten statt der Varianten,
die das Bild wirklich hat - ein 192 px grosses Zeichen, also der
haeufigste Fall, scheiterte damit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 14:01:16 +02:00
kantarciandClaude Opus 5 f2ef2a4901 Beitraege optional, Unterstriche nur im Text, eigenes Symbol
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Eine Website ohne Beitraege redet nirgends von Beitraegen: Archiv und
Feed antworten mit 404, der Feedverweis faellt aus dem Kopf, und der
Hinweiskasten der Beitragsliste bleibt im Editor statt oeffentlich zu
erscheinen. Das gilt auch je Kategorie, sobald deren letzter Beitrag
weg ist. Dabei gefunden: die Liste fragte je Beitrag nach Bild und
Begriffen - jetzt je eine Abfrage fuer die ganze Liste (22 -> 16
Abfragen auf der Startseite des Nachbaus).

Unterstrichene Links gibt es nur noch im Fliesstext, dort mit einem
Schalter im Designbereich; Karten, Menue, Kontaktzeilen und der
Ansehen-Streifen haben keinen mehr. Die aktuelle Seite im Menue wird
ueber das Schriftgewicht kenntlich - die Akzentfarbe fiel im dunklen
Fussmenue durch die Kontrastpruefung.

Neu: das Symbol der Website laesst sich festlegen. Aus einem Bild der
Mediathek entstehen beim Speichern quadratische PNG-Dateien in 32 und
180 px; schlaegt das fehl, wird die Einstellung nicht gespeichert.

Die Browserpruefung laeuft jetzt gegen jede Installation statt nur
gegen die Demo: geprueft wird, was in der Sitemap steht, und das
Ladebudget trennt Bilder vom Beiwerk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 12:57:23 +02:00
kantarciandClaude Opus 5 f0b634f0c1 Einblendungen laufen beim Scrollen, ohne Skript
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Umut: "Effekte gibts keine, sind die aktiv?" Nachgemessen: die
Zeigereffekte liefen alle. Die Einblendungen gab es seit Schema v3 - nur
benutzt hatte sie niemand, weil eine Animation beim Laden unterhalb der
Falz wertlos ist.

Mit animation-timeline: view() laufen dieselben Keyframes, wenn der
Abschnitt in den Blick kommt - hinter der Bewegungsschranke, hinter
@supports (Firefox und Safari behalten das Einblenden beim Laden) und mit
einem Bereich, der endet, bevor der Abschnitt in der Mitte steht.

Dabei hat axe einen Fehler gefunden, den ich selbst gebaut hatte: eine
Scroll-Zeitachse ist umkehrbar, also stand alles unterhalb der Falz
dauerhaft am Anfang der Kurve - bei opacity .35 blass, bei .85 immer noch
3,86:1 statt 7,1:1. Auf dem Scrollweg wird deshalb nur Bewegung animiert,
keine Deckkraft.

Benutzt wird es jetzt auch: alle drei Beitragsvorlagen bringen die
Einblendung je Abschnitt mit.

Geprueft: 400 Tests unter PHP 8.2 und 8.4, Editortest, Browserpruefung,
axe ohne Verstoss auf Startseite, Projekteseite und Beitrag - vor und
nach dem Durchscrollen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 12:07:50 +02:00
kantarciandClaude Opus 5 0d6c1a17af Vier Darstellungen fuer die Beitragsliste
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Karten (Raster mit Bild oben), Liste (Bild links, Text rechts), Kompakt
(nur Titel und Datum) und Wechselseitig (Bild mal links, mal rechts).
Dieselben Daten, dasselbe Markup - nur eine Klasse wechselt.

Die erste Fassung von "Wechselseitig" war ein umgedrehter Flexkasten und
sah auch so aus. Jetzt sitzt das Bild in einer Karte mit Ecken, Schatten
und festem Verhaeltnis, der Text hat eine Nummer aus einem CSS-Zaehler,
eine begrenzte Zeilenlaenge und einen sichtbaren Verweis, und zwischen den
Reihen liegt eine feine Linie.

Zwei Details: die Karte ist das Listenelement, also zaehlt :nth-child auf
ihr - die erste Fassung wechselte deshalb gar nicht. Und die Umkehrung
laeuft ueber order statt row-reverse, damit auf dem Telefon sauber wieder
Bild ueber Text steht.

Geprueft: 399 Tests unter PHP 8.2 und 8.4, 18 JS-Tests, Editortest,
Browserpruefung, axe ohne Verstoss auf allen vier Darstellungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 11:34:06 +02:00
kantarciandClaude Opus 5 84e88c6636 Drei Aufbauten fuer neue Beitraege
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Umut wollte mehrere Designs zur Auswahl. Bei Seiten waere das falsch -
dort weiss vorher niemand, was daraus wird. Bei Beitraegen ist die Form
fast immer dieselbe, und wer jedes Mal von vorn anfaengt, baut jedes Mal
etwas leicht anderes.

Bildkopf (Bild ueber die volle Breite, Titel darauf), Klassisch (Titel und
Vorspann schmal, Bild darunter) und Magazin (Titel neben den Eckdaten).
Gewaehlt wird beim Anlegen; danach ist jeder Block aenderbar wie jeder
andere. Kein Aufbau verlaesst sich auf ein Bild, das es noch nicht gibt.

Dabei ist eine Regel gefallen: der erste Textblock eines Abschnitts wurde
automatisch zur Kleinbeschriftung in Akzentfarbe. Das gestaltet nach
Position statt nach Absicht - im Fussbereich wurde daraus roter
Versalientext, in der Magazin-Vorlage aus dem Einstiegsabsatz eine
Beschriftung. Wer eine Beschriftung will, stellt sie ein.

Geprueft: 398 Tests unter PHP 8.2 und 8.4, Editortest, Browserpruefung,
axe ohne Verstoss auf allen drei Aufbauten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 11:12:16 +02:00
kantarciandClaude Opus 5 1b22265a54 Beitragsnavigation als Baustein (Dokumentschema v18)
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Umut: "Beitragsseite gefaellt mir nicht, sollte wie bei kant-it.de sein
oder besser. Falls noetig Plugin bauen."

Die Originalseite hat Galerie, Vor/Zurueck zwischen Projekten und einen
Abschlussaufruf. Galerie und Aufruf gab es laengst - sie waren nur nicht
benutzt. Die Navigation gab es nicht.

Kein Plugin: ein Plugin ist hier ein Bausteintyp, dessen Eigenschaften der
Kern aufbewahrt, aber nicht rendert - es koennte also nichts ausgeben,
solange es keine Laufzeit dafuer gibt. Und die Nachbarn eines Beitrags
stehen in derselben Tabelle wie die Beitragsliste, die der Kern schon
fuellt. Also ein Baustein: postnav.

Gespeichert sind zwei Beschriftungen, mehr nicht - welcher Beitrag vorher
und nachher kommt, entscheidet der Renderer. Gesucht wird innerhalb
derselben Kategorie, und am Rand wird nicht umgebrochen.

Ausserdem: die uebernommenen Projekttexte begannen mit dem wiederholten
Titel (20 Beitraege), und schmale Abschnitte standen links statt mittig -
max-width ohne margin:auto ist eine halbe Einstellung.

Geprueft: 396 Tests unter PHP 8.2 und 8.4, 18 JS-Tests, Editortest,
Browserpruefung, axe ohne Verstoss auf der Beitragsseite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 10:56:18 +02:00
kantarciandClaude Opus 5 5a5933f8cd Slider mit Autorotation, Beitragsliste mit Blaettern
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Zwei Wuensche, zwei Bausteine.

Die Autorotation ist Sekunden, kein Haekchen: 0 heisst aus, ab 3 laeuft
sie. Sie braucht ein Skript - das zweite auf einer oeffentlichen Seite
nach den Tabs - und es kommt nur auf Seiten mit einem solchen Slider,
sonst bleibt script-src 'none'. Es laeuft nicht bei "weniger Bewegung",
hoert beim ersten Tippen, Klicken, Wischen oder Scrollen endgueltig auf
und pausiert bei Zeiger, Fokus, Hintergrundtab und ausserhalb des Bildes.
Ohne Skript bleibt der Slider vollstaendig bedienbar.

Die Beitragsliste kann blaettern statt abzuschneiden. Eine Auswahl auf der
Startseite zeigt die neuesten und hoert auf; eine Uebersichtsseite
blaettert und sagt, wie viele es sind. Das war die eigene Regel aus
Arbeitspaket M, die fuer diesen Baustein noch nicht galt.

Beim Bauen zweimal dieselbe Falle wie vorgestern: eine Variable war in der
Schliessung nicht gebunden. Einmal das Sprungziel des Sliders (-78 statt
666), einmal die Seitenzahl - die zweite fiel sofort auf, weil die Seite
mit 500 antwortete.

Geprueft: 395 Tests unter PHP 8.2 und 8.4, 18 JS-Tests, Editortest,
Browserpruefung, axe ohne Verstoss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 10:40:50 +02:00
kantarciandClaude Opus 5 b2c4f45474 Eigener Durchgang vor dem Livegang: drei Fehler, zwei Feinheiten
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Umut: "schau selbst drueber, wuerdest du so online gehen?" Nein, so nicht.

Verborgener Text stand sichtbar auf der Seite: .visually-hidden gibt es in
admin.css, aber nicht in site.css. "Bewertung: 5 von 5" neben den Sternen
und "(Pflichtfeld)" hinter jedem Formularfeld waren zu lesen - seit dem
ersten Formular. axe meldet so etwas nicht, es prueft das Ansagen, nicht
das faelschliche Sichtbarsein.

Jede Spalte war ein Kasten: Rahmen, Ecken, 30px Innenabstand von
vornherein. Eine Spalte ist ein Layoutmittel; die Karte ist jetzt eine
Einstellung. Die Demoseite wirkt dadurch ruhiger.

Der Knopf im Aufruf-Band war Tinte auf Tinte, also auf dunklem Grund fast
unsichtbar - ausgerechnet beim Handlungsaufruf. Aufruf-Band und Formular
nehmen die Akzentfarbe.

Dazu: gestapelte Karten klebten aneinander (18px), und die linke Spalte im
Kontaktbereich war halb leer.

Geprueft: 393 Tests unter PHP 8.2 und 8.4, Editortest, Browserpruefung,
axe ohne Verstoss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:45:21 +02:00
kantarciandClaude Opus 5 2715d329ef Beitragskopf mit Bild, und die Metazeile laesst sich abschalten
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Umut meinte den Kopf beim Oeffnen eines Beitrags. Im Original fuellt dort
das Projektbild den Kopf, darueber ein Verlauf mit Badge, Titel und einer
kurzen Akzentlinie - der Text mittig. Nachgebaut ohne neue Funktion, aus
Hintergrundbild, Verlauf und Flexausrichtung.

Dabei stand die Zeile mit Datum, Autor und Kategorie ueber dem Bild und
wiederholte, was der Titel darin schon sagt. Neue Einstellung
"Datum, Autor und Kategorie ueber einem Beitrag zeigen", Voreinstellung an.

Geprueft: 391 Tests unter PHP 8.2 und 8.4, Editortest, Browserpruefung,
axe ohne Verstoss auf dem Beitrag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:19:22 +02:00
kantarciandClaude Opus 5 1b164f4c86 Beitragskarten reagieren auf Zeiger und Tastatur
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Umut: "es gibt keinen Mausover-Effekt, das fehlt". Am Original
nachgemessen und uebernommen: die Karte hebt sich um 8px mit weichem
Schatten, das Bild zieht auf 1,06 auf, darueber blendet ein Hinweis ein.

Die Bewegung steht vollstaendig hinter der Bewegungsschranke - wer
weniger Bewegung eingestellt hat, bekommt keine, sieht den Hinweis aber
trotzdem. Alles haengt zusaetzlich an :focus-within, damit der Effekt
nicht nur Mausbenutzern gehoert. Der Hinweis ist fuer Vorlesesoftware
ausgeblendet, weil der Titel darunter zum selben Ziel fuehrt.

Ausserdem: der Leuchteffekt trug noch das Gruen der Vorlage statt der
Akzentfarbe der Website.

Geprueft: 391 Tests unter PHP 8.2 und 8.4, Editortest, Browserpruefung,
axe ohne Verstoss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:09:10 +02:00
kantarciandClaude Opus 5 5f23194760 Kennzahl und Kundenstimme folgen wieder ihrer Einstellung
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Beim vollstaendigen Nachbau von kant-it.de aufgefallen: die Groesse der
Kennzahl liess sich am Block einstellen und aenderte nichts, weil
.block-stat strong 44px fest setzte. Dasselbe bei der Kundenstimme mit
18px. Beide erben jetzt vom Block - dieselbe Familie wie die Theme-Skala,
die zuvor die Kartenueberschriften ueberschrieben hat.

Der Nachbau umfasst jetzt alle 29 Projektbeitraege mit den echten Bildern
und Texten sowie die vollstaendige Datenschutzerklaerung.

Dabei ein Befund fuer den echten Umzug: 11 der 29 Projektbilder haette die
Mediathek auf einem 128-MB-Hosting abgewiesen, zwei auch mit mehr Speicher
- sie ueberschreiten die Pixelgrenze. Ein Umzug muss solche Bilder vorher
verkleinern.

Geprueft: 390 Tests unter PHP 8.2 und 8.4, Editortest, Browserpruefung,
axe ohne Verstoss auf fuenf Ansichten des Nachbaus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 06:18:08 +02:00
kantarciandClaude Opus 5 e6dcc8e823 Offener Punkt notiert: Menueart je Geraet
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Beim Nachbau aufgefallen: horizontal oder Burger gilt fuer alle Groessen.
Heute loest man es mit zwei Menuebloecken und display:none je Geraet - das
funktioniert, ist aber eine Umgehung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 06:01:00 +02:00
kantarciandClaude Opus 5 e21dff91fe Slider mit Punkten, Karten als Karten, hervorgehobenes Wort
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Umut hat den Nachbau Detail fuer Detail mit kant-it.de verglichen. Vier
Stellen, an denen das CMS etwas nicht konnte oder etwas Falsches tat.

Der Slider zeigte eine Rollleiste statt Punkte. Punkte zeichnet jetzt der
Browser selbst - scroll-marker-group und ::scroll-marker, ohne Skript, mit
erkennbarem aktivem Punkt. Wo ein Browser das noch nicht kennt, bleibt das
wischbare Schiebefeld. Pfeile gibt es bewusst nicht: Chrome legt
::scroll-button an den Seitenrand statt in den Slider. Und er rotiert
nicht von selbst, weil eine oeffentliche Seite kein JavaScript laedt.

Eine Beitragskarte war keine Karte, sondern eine lose Spalte. Jetzt weiss,
18px Ecken, Bild buendig oben im Verhaeltnis 16:10, Text darunter.

Die Theme-Schriftskala ueberschrieb die Groessen der Bausteine: jede
Kartenueberschrift war so gross wie eine Abschnittsueberschrift.

Eine Ueberschrift darf ab v17 ein Wort hervorheben. Dabei waere beinahe
eine Luecke entstanden: die Dokumentfassung war in der Schliessung des
Filters nicht gebunden, der Filter lief nie, die Ausgabe war schon
unescaped. Ein Test mit <script> in einer Ueberschrift hat es gezeigt.

Geprueft: 390 PHP-Tests unter 8.2 und 8.4, 18 JS-Tests, Editortest,
Browserpruefung, axe ohne Verstoss auf dem Nachbau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 00:23:02 +02:00
kantarciandClaude Opus 5 70283db20a Dokumentschema v17: Projektraster, verlinktes Bild, klebender Kopfbereich
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Alles aus dem Nachbau von kant-it.de, den Umut durchgesehen hat.

Eine Beitragsliste kann Datum, Autor, Auszug und Kategorie ausblenden und
sieht damit aus wie ein Projektraster statt wie ein Blogarchiv. Beinahe
lautlos danebengegangen: |default(true) macht in Twig aus einem
gespeicherten false ein true - die Einstellung haette sich speichern
lassen und nichts getan. Der Test prueft beide Richtungen.

Ein Bild darf verlinken, damit ein Logo auf die Startseite zeigt; die
Adresse geht durch dieselbe Pruefung wie jeder andere Link. Und der
Kopfbereich kann beim Scrollen oben bleiben - als Einstellung der
Website, weil nur das umschliessende Element kleben kann.

Der groesste Fund steckt im Editor: die Liste bekannter Schemafassungen
endete bei 12, waehrend Bausteine v13 bis v15 verlangen. In einem
aktuellen Dokument liess sich damit kein Baustein mehr einfuegen, und
Symbol, Fortschritt, Slider und externer Inhalt in gar keinem. Jetzt eine
Zahl an einer Stelle, mit einem Test ueber jeden Typ und jede Fassung.

Geprueft: 389 PHP-Tests unter 8.2 und 8.4, 18 JS-Tests, Editortest,
Browserpruefung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 00:02:23 +02:00
kantarciandClaude Opus 5 efa285c0d0 Die Gestaltung faerbt jetzt auch die Bausteine
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Gefunden beim Nachbau von kant-it.de: eine Website mit eigener Palette
behielt das Gruen der Vorlage. Symbole, Knoepfe, Rahmen, ruhiger Text,
Fokusring und der Einwilligungsdialog kamen aus festen Token in site.css,
die das Theme nie gesetzt hat - die Farben liessen sich einstellen und
taten nichts.

Das Theme setzt --ink, --paper, --accent, --muted, --line und --mint jetzt
selbst. Als Akzent dient die Linkfarbe, nicht ein Palettenname: das Token
faerbt auch kleine Beschriftungen, und eine helle Akzentfarbe waere dort
nicht lesbar. --muted und --line werden aus Textton und Hintergrund
gemischt; eine Palettenfarbe gleichen Namens gewinnt.

Der Einwilligungsdialog nimmt die Primaerfarbe und laesst sich eigens
faerben ueber eine Palettenfarbe mit dem Schluessel "consent".

Ausserdem aus demselben Nachbau: Ueberschriften werden nicht mehr getrennt
("di-gitale Exzellenz"), und Fliesstext auf dunkel gefaerbten Abschnitten
mischt seine Farbe aus der geerbten statt aus einem Token fuer helle
Hintergruende.

Geprueft: 381 Tests unter PHP 8.2 und 8.4, axe ohne Verstoss auf vier
Seiten des Nachbaus in Desktop- und Handybreite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 23:46:30 +02:00
kantarciandClaude Opus 5 e34b4dca67 Installer: sechs Bildschirme, und eine Datei davor
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
/install fuehrt von einer leeren Installation zu einer Website:
Systempruefung, Datenbank (SQLite oder MySQL, Verbindung sofort geprueft),
Migration im Prozess ohne Konsole, Website mit Startseite, Bereichen, Menue
sowie Impressum und Datenschutzgeruest, erstes Konto, Abschlussliste.

Jeder Schritt schreibt sein Ergebnis sofort, damit ein Neuladen nichts
verliert. Gesperrt wird ueber den Zustand der Datenbank - gibt es ein
Konto, antwortet /install mit 404. Eine Datei koennte jemand loeschen.

Solange nichts installiert ist, fuehrt jede Adresse zum Installer statt zu
einem 500 aus einer fehlenden Tabelle. Und weil ohne APP_SECRET nicht
einmal die Seite bootet, die das erklaeren wuerde, erzeugt der erste Start
das Geheimnis selbst.

scripts/install.php ist die einzelne Datei davor: Adresse und SHA-256
stehen darin (vom Buildskript eingetragen), ohne passende Pruefsumme wird
nicht entpackt, jeder Archiveintrag wird gegen das Zielverzeichnis
geprueft, und danach loescht sie sich selbst.

Geprueft: 379 Tests unter PHP 8.2 und 8.4, darunter der ganze Ablauf durch
die HTTP-Schicht, neun ZIP-Slip-Varianten, axe ohne Verstoss auf allen
Installationsbildschirmen, und das entpackte Releasearchiv startet in den
Installer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 23:11:50 +02:00
kantarciandClaude Opus 5 c3bff7d936 Bilder in vier Breiten, Releasearchiv fuer Hosting ohne Composer
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Uploads werden weiterhin nach WebP umgewandelt, jetzt in vier statt drei
Breiten (480/960/1600/2400) und ohne Hochskalieren. Hintergrundbilder
waehlen ihre Variante je Geraet, weil CSS kein srcset kennt.

Dabei kam ein Fehler heraus, den erst der Browsertest zeigte: der
Dokumentpruefer erlaubte drei sources je Bild, also war mit der vierten
Variante jedes Dokument mit einem Bild ungueltig - ohne sichtbare Meldung.
Die Grenze haengt jetzt an MediaLibrary::WIDTHS.

Geteiltes Hosting hat meistens keinen Composer. scripts/build-release.php
baut deshalb auf einem Build-Rechner ein Archiv mit fertigem vendor/ ohne
Entwicklungspakete, samt SHA-256. Der Updater bricht ab, wenn ein Update
composer.lock aendert und Composer auf dem Server nicht aufrufbar ist,
statt mit einem unpassenden vendor/ weiterzulaufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 22:39:08 +02:00
kantarciandClaude Opus 5 c046681302 Fassung 1.2.0
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Sicherung und Wiederherstellung, Ladeverhalten gemessen, Seitenbereiche je
Seite, Menuearten, Designerausbau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:56:09 +02:00
kantarciandClaude Opus 5 269fb223af 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>
2026-09-12 21:56:09 +02:00
kantarciandClaude Opus 5 8c49f6a67a Sicherung und Wiederherstellung - und die Wiederherstellung ist nachgewiesen
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Arbeitspaket O. Der Teil, um den es geht, ist nicht das Sichern, sondern das
Zurueckspielen: eine Sicherungsfunktion, deren Wiederherstellung nie gelaufen
ist, ist eine Vermutung.

Die Datenbank wird nicht kopiert, sondern von SQLite ausgeschrieben (VACUUM
INTO). Ein copy() waehrend eines Schreibvorgangs ergibt eine Datei, die
vorhanden aussieht und sich am Tag der Wiederherstellung nicht oeffnen laesst.
Der Medienordner gehoert dazu - ohne die Dateien stellt die Sicherung eine
Website voller kaputter Bilder her. .env gehoert nicht dazu: darin steht
APP_SECRET, und eine Sicherung wird heruntergeladen und weitergereicht. Was
nicht drin ist, steht im Manifest.

Jede Sicherung traegt Zeitpunkt, Anlass, Fassung, Migrationsstand,
Inhaltszahlen und die SHA-256 der Datenbank. Die Uebersicht zeigt
"unversehrt" oder "Pruefsumme falsch" - eine Sicherung, von der man erst beim
Zurueckspielen erfaehrt, dass sie kaputt ist, waere schlimmer als keine.

Beim Zurueckspielen: neuere Fassung wird abgewiesen (mit --force geht es),
zuerst wird der aktuelle Stand gesichert, Wartungszustand an, Medien werden
ergaenzt statt ersetzt - eine Datei, die nach der Sicherung hochgeladen wurde,
bleibt liegen. Die Massnahme gegen Datenverlust darf nicht selbst welchen
verursachen. In der Verwaltung gibt es Liste und Anlegen-Knopf, aber kein
Zurueckspielen: ein Knopf, der die ganze Datenbank ersetzt, gehoert nicht
neben einen, der eine Seite speichert.

Nachgewiesen, zweimal: acht Tests gegen eine wegwerfbare Installation
(Sicherung, Seite und Bilddatei loeschen, zurueckspielen, beides wieder da) -
und von Hand auf der Entwicklungsinstallation mit einer eigens angelegten
Testseite: 5 Seiten, gesichert, geloescht (4), zurueckgespielt (5, Seite mit
ihrer Adresse wieder da), Testseite entfernt (4). Damit ist der aelteste
offene Punkt aus Paket I erledigt.

Ein Fall, den erst der Test zeigte: zwei Sicherungen in derselben Sekunde -
nicht akademisch, denn die Wiederherstellung legt selbst eine Sicherheitskopie
an. Der Name bekommt jetzt eine Nummer, statt dass die zweite scheitert.

Offen: MySQL (mysqldump fehlt) und ein Download ueber den Browser.

344 PHP-Tests / 1397 Assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:37:31 +02:00
kantarciandClaude Opus 5 af3589c95c Seitenbreite wirkt, vier Menueplaetze, Seiten ohne Kopfbereich
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Drei Fragen, drei unterschiedliche Antworten.

Die globale Seitenbreite war einstellbar und wirkte nur in eine Richtung:
site.css schrieb width:min(1180px,...), also schluckte die 1180 jeden
groesseren Wert aus dem Designbereich. Schmaler ging, breiter nicht, ohne
jede Meldung. Die Zahl kommt jetzt aus dem Theme. Das ist der dritte Fehler
dieser Art an einem Tag - nach der Rahmenstaerke und der Bildrundung -, und
alle drei hatten dieselbe Ursache: CSS, das nie gegen eine Erwartung geprueft
wurde.

Menueplaetze und Menueart waren vermischt. Wo ein Menue steht, bestimmt der
Block und war immer frei. Welche Eintraege es zeigt, bestimmt der Platz -
davon gab es zwei, jetzt vier (Haupt, zweite, Fusszeile, Rechtliches). Wie es
aussieht, war gar nicht einstellbar: jetzt waagerecht, untereinander oder
aufklappbar als Burger. Der Burger ist ein details-Element, denn ein Menue,
das ein Skript zum Oeffnen braucht, geht auf einer Seite ohne Skript nicht auf.

"Seiten ohne Menue" war nicht moeglich - der Kopfbereich war ein Dokument fuer
alle Seiten. Jetzt waehlt jede Seite: Standard, ein anderer Bereich, oder
keiner. Dafuer musste die Regel "es gibt genau einen Kopf- und Fussbereich"
fallen; sie war richtig, solange niemand waehlen konnte, und wurde falsch,
sobald man waehlen kann. Ein bestehender Test, der diese Regel zusicherte, ist
deshalb umgeschrieben und prueft jetzt die neue.

Gespeichert wird die Wahl, nicht das Ergebnis: leer heisst Standard, none
heisst keiner, sonst die Adresse. Ein Verweis auf einen geloeschten Bereich
faellt auf den Standard zurueck, statt die Seite stillschweigend ohne
Navigation auszuliefern.

Neu im Ausbauplan: ein einzelnes install.php, das das Releasearchiv
herunterlaedt und entpackt, damit niemand tausende Dateien per FTP hochladen
muss. Mit den Regeln, die darueber entscheiden, ob das eine Hilfe oder eine
Einladung ist: Adresse und SHA-256 stehen in der Datei und nicht im Formular,
HTTPS mit Zertifikatspruefung, Schutz gegen ZIP-Slip, Selbstloeschung danach.

336 PHP-Tests / 1354 Assertions, 17 JS-Modelltests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:15:03 +02:00
kantarciandClaude Opus 5 67b85751cf Randlose Abschnitte, volle Bildschirmhoehe, Vollbild-Hero
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Umuts Frage: "Haben wir bilder usw als volles bild startseite? 100% hoehe
usw?" Die Teile gab es, die Kombination nicht - und zwei Sachen fehlten
wirklich.

Abschnittsbreiten: neu "randlos" (ohne Begrenzung, ohne Rand, von Kante zu
Kante) und "breit" (1600 px) neben normal und schmal. Vorher war jeder Inhalt
auf 1180 px begrenzt; ein Bild von Kante zu Kante war nicht moeglich.

Hoeheneinheiten: neu dvh, svh, lvh. 100vh ist auf dem Handy hoeher als der
Bildschirm - es meint die Ansicht ohne Adressleiste, und der untere Rand
verschwindet darunter. 100dvh ist das, was jemand meint, der "volle Hoehe"
sagt.

Und eine Vorlage "Vollbild-Hero", die die ganze Kombination auf einen Klick
setzt: randlos, 100dvh (mobil 80dvh), Inhalt senkrecht zentriert,
Hintergrundbild formatfuellend, dunkle Ueberlagerung damit der Text lesbar
bleibt, Ueberschrift, Text, Button. Die Teile waren alle da, aber diese
Kombination zu finden war eine Recherche.

328 PHP-Tests / 1329 Assertions, 17 JS-Modelltests. Geprueft: vier Breiten
angenommen und eine erfundene abgewiesen, sieben Hoehenangaben mit dvh/svh/lvh
und zwei ungueltige, ein randloser Abschnitt mit formatfuellendem Bild, und
die Hero-Vorlage Eigenschaft fuer Eigenschaft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 20:52:48 +02:00
kantarciandClaude Opus 5 464f52b147 Suchfeld im Designbereich, Sicherung als Arbeitspaket O
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Neunzig Eigenschaften in acht zugeklappten Gruppen: "wo ist die
Rahmenstaerke" ist eine berechtigte Frage. Jetzt filtert ein Suchfeld die
Eigenschaften, oeffnet die passenden Gruppen und sagt, wie viele Treffer es
gibt - und wenn keine, wo eigenes CSS zu finden ist. Getestet: "rahmen" laesst
die Gruppen Rahmen und Bild stehen, 14 Treffer.

Neu im Ausbauplan, Paket O: Sicherung und Wiederherstellung als Funktion statt
als Disziplin. Heute entstehen Sicherungen nur als Nebenprodukt des Updaters
und weil ich vor jeder Migration von Hand eine Kopie lege. Geplant sind
Datenbank per VACUUM INTO statt copy (eine Kopie waehrend eines
Schreibvorgangs kann unbrauchbar sein), Medienordner, ein Manifest mit
Fassung, Migrationsstand und Pruefsummen - und ohne .env, weil darin
APP_SECRET steht und eine Sicherung den Server verlaesst. Dazu Rotation, damit
die Platte nicht vollaeuft und die naechste Sicherung nicht genau dann
scheitert, wenn man sie braucht.

Die Abnahme ist die Wiederherstellung, nicht die Sicherung: eine Seite
loeschen, zurueckspielen, pruefen dass sie wieder da ist. Eine
Sicherungsfunktion, deren Wiederherstellung nie gelaufen ist, ist eine
Vermutung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 20:43:19 +02:00
kantarciandClaude Opus 5 806df4b5df Fassung 1.1.0
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Designerausbau, Statistik, Updater, Einwilligung und Pflichtseiten seit 1.0.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 20:37:57 +02:00
kantarciandClaude Opus 5 7251b82f09 Designer: 93 Eigenschaften, neun Einblendungen, drei neue Bausteine
Umut fragte nach Zeilen, nach Rahmen und Rundungen - und ob der Designer so
aufgestellt ist wie Flatsome. Der Vergleich steht in
docs/DESIGNER-VERGLEICH.md, mit der Einschraenkung, dass Flatsome hier nicht
installierbar war: das ist Wissen, keine Messung.

Zwei echte Fehler kamen dabei heraus, beide vorher unbemerkt.

Eine Rahmenstaerke je Seite wurde zu border-width-top. Das ist kein CSS, der
Browser verwirft die Zeile stillschweigend - Rahmenstaerken je Seite hatten
nie eine Wirkung, und es gab keine Meldung, die darauf hingewiesen haette. Die
Schreibweise steht jetzt je Eigenschaft im Schema, weil padding-top dem Namen
folgt und border-top-width nicht.

Und eine Rundung auf einem Bildblock landete auf dem figure, waehrend das Bild
darin eckig blieb. Eigenschaften koennen jetzt auf das Bild im Block zielen
(target 'img' im Schema, eine zweite Regel im Compiler) - deshalb tut
object-fit ueberhaupt etwas, denn auf einem figure tut es nichts.

Von 50 auf 93 Eigenschaften: Bild (Ausschnitt, Bildmitte, Seitenverhaeltnis,
Hoehe, Ecken, Rahmen), Positionierung mit z-index, getrennte Zeilen- und
Spaltenabstaende, Flex je Element, Innen- und Textschatten, acht Bildfilter
als eine filter-Deklaration in fester Reihenfolge, Mischmodus, Zusatzrahmen,
Mauszeiger. Neun Einblendungen mit Verzoegerung, sieben Hover-Effekte, alle
innerhalb von prefers-reduced-motion.

Drei neue Bausteine (Dokumentschema v15): Symbol als eigenes Element,
Fortschritt mit role=meter und dem Wert im Markup statt nur in der
Balkenbreite, und ein Slider als Scrollbereich mit Einrasten - ohne
JavaScript, ohne Autoplay, mit Finger, Trackpad, Mausrad und Tastatur.

Zeilen waren nicht unauffindbar, sondern unbrauchbar leer: eine Zeile ohne
Spalten sieht aus wie nichts. Jetzt legt "Zeile, 2/3/4 Spalten" beides auf
einen Klick an, und im Werkzeugkasten steht, wie der Aufbau gemeint ist.

315 PHP-Tests / 1309 Assertions, 16 JS-Modelltests, axe auf dreizehn
Bildschirmen ohne Verstoesse. 16 Datensaetze rechnen jede Eigenschaftsform
gegen die erwartete Deklaration nach - die Pruefung, die die beiden Fehler
oben gefunden haette, wenn es sie vorher gegeben haette.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 20:37:49 +02:00
kantarciandClaude Opus 5 30d8d58bbf Updater am echten Repository durchgespielt
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
v1.0.0 gesetzt, die Installation darauf gestellt, v1.0.1 veröffentlicht und
das Update ausgeführt: elf Schritte, alle ok, Sicherung mit cms.db und den
Umgebungsdateien, Migration ohne Änderung, Rauchtest grün, danach VERSION
1.0.1 und die Website online. Der Wartungszustand ist einzeln geprüft - 503
mit Retry-After für die Website, 200 für die Verwaltung.

Das steht so in QUALITY, mit den Zahlen und dem Pfad der Sicherung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 20:12:51 +02:00
kantarciandClaude Opus 5 5a8d67a638 Fassung 1.0.1: die laufende Fassung steht in der Verwaltung
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Eine kleine, von außen sichtbare Änderung - sie ist der Prüfstein für das
Update: nach dem Einspielen muss in der Seitenleiste 1.0.1 stehen, und der
Weg dorthin muss die Schritte gegangen sein, die der Updater verspricht.

Die Fassung kommt über eine Twig-Funktion in die Vorlage, nicht über einen
globalen Dienst: die Vorlagen bekommen genau diese eine lesende Antwort und
nicht einen Updater mit update() darauf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 20:11:21 +02:00
146 changed files with 12258 additions and 395 deletions

No files matched your search

+3
View File
@@ -4,3 +4,6 @@ DATABASE_URL="sqlite:///%kernel.project_dir%/var/cms.db"
SITE_ORIGIN=http://127.0.0.1:8080
MEDIA_DIRECTORY="%kernel.project_dir%/var/media"
MAILER_DSN=sendmail://default
# Die Adresse der Verwaltung. Aenderbar mit `php bin/console cms:admin:path <neu>`;
# der Befehl schreibt .env.local und prueft, dass die Adresse frei ist.
ADMIN_PATH=admin
+14
View File
@@ -0,0 +1,14 @@
# Vorlage für eine Installation. Der Installer schreibt daraus die echte .env und erzeugt das
# Geheimnis selbst; von Hand: diese Datei nach .env kopieren, Werte eintragen und
# `php bin/setup.php` ausführen, das APP_SECRET nach .env.local schreibt.
APP_ENV=prod
APP_DEBUG=0
DATABASE_URL="sqlite:///%kernel.project_dir%/var/cms.db"
# Für MySQL stattdessen, mit den Zugangsdaten des Hostings:
# DATABASE_URL="mysql://benutzer:passwort@127.0.0.1:3306/datenbank?charset=utf8mb4"
SITE_ORIGIN=https://example.de
MEDIA_DIRECTORY="%kernel.project_dir%/var/media"
MAILER_DSN=sendmail://default
# Die Adresse der Verwaltung. Ohne Eintrag: /admin. Aenderbar mit
# `php bin/console cms:admin:path <neu>` - der Befehl schreibt sie nach .env.local.
#ADMIN_PATH=admin
+6
View File
@@ -9,3 +9,9 @@
/.php-cs-fixer.cache
*.log
/config/reference.php
/var/releases/
# Die Schriften der Pilotwebsite liegen im Repository (siehe public/assets/fonts/LIZENZ.md),
# damit auf einem frischen Server nichts fehlt und nichts von Google geladen wird. Andere
# Installationen holen ihre eigenen mit `cms:fonts:fetch` - deren Dateien gehoeren nicht hierher.
/public/assets/build/
+14 -5
View File
@@ -18,7 +18,9 @@ php -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0
Danach `http://127.0.0.1:8080/` öffnen. Der Routerskript-Parameter ist nicht optional: der eingebaute PHP-Server liefert vorhandene Dateien selbst aus und antwortet für jeden fehlenden Pfad mit Dateiendung mit 404, sodass `/sitemap.xml`, `/robots.txt`, `/llms.txt` und `/feed.xml` den Frontcontroller nie erreichen. Auf echtem Hosting erledigt das `public/.htaccess`; `bin/dev-router.php` bildet dasselbe lokal nach. Unter Windows hier vorhanden: `C:\xampp\php\php.exe` (8.2.12) und `C:\php84\php.exe` (8.4.22). Die Befehle verwenden die PHP-Version im PATH; alternativ den vollständigen Pfad einsetzen. Diese vorhandenen Patchstände sind kein empfohlenes Produktionsimage.
`bin/setup.php` erzeugt ein zufälliges lokales Secret und `var/`; bestehende `.env.local` bleibt erhalten. Die Datenbank liegt in `var/cms.db`. Wiederholtes Seeding erhält existierende Seiten einschließlich ihrer Bearbeitungen. Es gibt keinen öffentlichen Installer und keine Standardzugangsdaten. Editor-Endpunkte benötigen Anmeldung, Berechtigung und bei POST einen CSRF-Token.
`bin/setup.php` erzeugt ein zufälliges lokales Secret und `var/`; bestehende `.env.local` bleibt erhalten. Die Datenbank liegt in `var/cms.db`. Wiederholtes Seeding erhält existierende Seiten einschließlich ihrer Bearbeitungen. Standardzugangsdaten gibt es nicht.
**Installation ohne Konsole** läuft über `/install`: Systemprüfung, Datenbank, Migration, Website, erstes Konto. Der Installer antwortet nur, solange keine Installation existiert – sobald ein Konto angelegt ist, liefert jede Adresse darunter 404, und die Sperre hängt am Zustand der Datenbank, nicht an einer Datei. Solange nichts installiert ist, führt jede Adresse dorthin. Davor genügt eine einzelne hochgeladene Datei: `install.php` aus einem Release lädt das Archiv von einer fest eingetragenen Adresse, vergleicht die SHA-256, entpackt es und löscht sich selbst. Ohne passende Prüfsumme wird nichts entpackt. Editor-Endpunkte benötigen Anmeldung, Berechtigung und bei POST einen CSRF-Token.
## Anmelden und bearbeiten
@@ -55,7 +57,7 @@ Unter Linux dieselben PHP-Argumente mit vorangestelltem `APP_ENV=prod APP_DEBUG=
## Medien, Galerien und Effekte
Links im Werkzeugkasten **Medienverwaltung öffnen** oder auf der Seitenübersicht **Medienverwaltung** wählen. JPEG/PNG/WebP hochladen, Titel und Alternativtext bearbeiten und speichern. Bilder lassen sich suchen, archivieren und wiederherstellen. Die Dateien werden als optimierte WebP-Varianten gespeichert; Originaldateien werden nicht aufgehoben. Uploadgrenzen: 8 MB, 12 Megapixel und 8000 px Kantenlänge; Hostinglimits können niedriger sein. Bild-URLs sind öffentlich erreichbar, auch wenn sie noch in keinem veröffentlichten Inhalt verwendet werden.
Links im Werkzeugkasten **Medienverwaltung öffnen** oder auf der Seitenübersicht **Medienverwaltung** wählen. JPEG/PNG/WebP hochladen, Titel und Alternativtext bearbeiten und speichern. Bilder lassen sich suchen, archivieren und wiederherstellen. Die Dateien werden als optimierte WebP-Varianten in vier Breiten gespeichert (480, 960, 1600, 2400 px; ein schmaleres Bild bekommt weniger Stufen und wird nie hochskaliert). Originaldateien werden nicht aufgehoben. Bildbausteine liefern `srcset`, Hintergrundbilder wählen die Variante je Gerät. Uploadgrenzen: 8 MB, 12 Megapixel und 8000 px Kantenlänge; Hostinglimits können niedriger sein. Bild-URLs sind öffentlich erreichbar, auch wenn sie noch in keinem veröffentlichten Inhalt verwendet werden.
Für ein einzelnes Bild **Bild aus Mediathek wählen** anklicken. Für eine Galerie den Block **Galerie** einfügen und **Galeriebilder auswählen** öffnen. Mehrere Bilder markieren, Alt-Texte speichern oder die dekorative Verwendung wählen und **Auswahl übernehmen**. Im Strukturbaum einzelne Bilder auswählen, beschriften und sortieren; Reihenfolge und Einfügen lassen sich rückgängig machen. Unter **Design** Spaltenzahl und Abstände pro Gerät einstellen.
@@ -77,6 +79,7 @@ composer build
npm.cmd ci
npx.cmd playwright install chromium
npm.cmd run test:browser
npm.cmd run test:links
npm.cmd test
npm.cmd run test:editor
npm.cmd run test:editor -- --audit
@@ -85,9 +88,15 @@ npm.cmd run audit
Unter Linux/macOS `npm` und `npx` ohne `.cmd`; in CI `npx playwright install --with-deps chromium`. `test:browser` und `audit` benötigen den laufenden lokalen Produktionsmodus auf Port 8080. Ein anderes Ziel lässt sich über `CMS_URL` konfigurieren. `test:editor` startet einen eigenen PHP-Prozess auf einem freien Port mit isolierter Datenbank und zufälligem Testkonto; es ändert keine echten Seiten oder Benutzer. `PHP_BINARY` kann dafür einen alternativen PHP-Pfad angeben. Testdaten bleiben unter `var/browser-tests`, Berichte/Screenshots unter `reports/`. Das Skript erneuert zuvor den Produktionscache.
**Datenbank:** SQLite ab Fassung 3.16 (Doctrine liest das Schema mit Tabellenfunktionen, die es erst seit dieser Fassung gibt) oder MySQL/MariaDB. Auf älteren Systemen – manche Shared Hoster liefern noch SQLite 3.7 – bleibt MySQL; die Systemprüfung des Installers nennt die gefundene Fassung und bietet SQLite gar nicht erst an, wenn sie zu alt ist.
Die Verwaltung liegt unter `/admin`. `php bin/console cms:admin:path meine-verwaltung` legt sie auf eine andere Adresse: der Wert landet in `.env.local`, gilt für Routen, Firewall und Schutzkopfzeilen, und wird in der `robots.txt` bewusst nicht genannt. Ohne Argument zeigt der Befehl die aktuelle Adresse.
`composer format` formatiert PHP. `composer build` wärmt den Symfony-Produktionscache; öffentliche CSS-Dateien sind direkt auslieferbar, es ist kein Frontend-Bundler nötig. Bei Änderungen an `public/assets/design-tokens.json` einmal `npm.cmd run build:design` ausführen und die erzeugte CSS-Datei mitliefern; Node ist auf dem Hosting nicht erforderlich. Nach Änderungen an Produktionskonfiguration/Templates `php bin/console cache:clear --env=prod --no-debug` ausführen. Falls lokale Tests alte Templates verwenden, vor PHPUnit auch `php bin/console cache:clear --env=test --no-debug` ausführen.
Das Lighthouse-Skript misst die Startseite dreimal je Profil und prüft die Mediane gegen die Projektziele. `test:editor -- --audit` prüft die im isolierten Editortest gestaltete v3-Seite mit Abschnittsvorlage, Galerie, tatsächlichen Bildvarianten und Effekten; Berichte stehen unter `reports/lighthouse-editor`. Uploads und Datenbank des Tests liegen getrennt unter `var/browser-tests`. Diese kleinen Prototypseiten ersetzen nicht die spätere Referenzsuite mit Kundeninhalten, Fotos, Beitrag und Formular. Messgrenzen und Ergebnisse: `docs/QUALITY.md`.
`test:links` folgt der Sitemap, sammelt jeden internen Verweis samt Bildern, Stylesheets und Hintergrundbildern und meldet jede Antwort ab 400 – gebaut, nachdem im Nachbau von kant-it.de vier Verweise ins Leere zeigten, die kein Test finden konnte.
Das Lighthouse-Skript misst dreimal je Profil und prüft die Mediane gegen die Projektziele. Welche Adressen geprüft werden, sagt `CMS_PATHS` (mit Komma getrennt, Standard `/`); in der Git-Bash davor `MSYS_NO_PATHCONV=1` setzen, sonst wird aus `/` ein Windows-Pfad. `test:editor -- --audit` prüft die im isolierten Editortest gestaltete v3-Seite mit Abschnittsvorlage, Galerie, tatsächlichen Bildvarianten und Effekten; Berichte stehen unter `reports/lighthouse-editor`. Uploads und Datenbank des Tests liegen getrennt unter `var/browser-tests`. Diese kleinen Prototypseiten ersetzen nicht die spätere Referenzsuite mit Kundeninhalten, Fotos, Beitrag und Formular. Messgrenzen und Ergebnisse: `docs/QUALITY.md`.
## Inhalte und Entwicklung
@@ -107,6 +116,6 @@ Anforderungen vor dem Pilotbetrieb beim Anbieter bestätigen:
Serverkonfiguration über echte Umgebungsvariablen oder eine nicht öffentliche `.env.local`: `APP_ENV=prod`, `APP_DEBUG=0`, eigenes zufälliges `APP_SECRET`, `SITE_ORIGIN=https://eigene-domain.example` und `DATABASE_URL=mysql://USER:URL_ENCODED_PASSWORD@HOST:3306/DATABASE?charset=utf8mb4`. Das ist eine Vorlage, keine funktionierende Zugangskonfiguration.
Deployment später mit `composer install --no-dev --optimize-autoloader`, Backup, Migration und Cacheaufbau. Composer darf auf einem geeigneten Build-Rechner laufen, sodass das Hosting weder Node noch Composer benötigt; CLI-Zugriff oder ein verlässlich dokumentierter Deploymentweg für Datenbankmigrationen bleibt zu klären. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
**Composer wird auf dem Hosting nicht vorausgesetzt.** Ein Release wird auf einem Rechner gebaut, der Composer hat: `php scripts/build-release.php` legt `var/releases/kantsites-<Fassung>.zip` samt `.sha256` an. Das Archiv enthält die Anwendung mit fertigem `vendor/` ohne Entwicklungspakete, aber keine Tests, keine Skripte, kein `var/` und kein `.env`; als Vorlage für die eigene Konfiguration liegt `.env.example` bei. Auf dem Hosting wird es entpackt, `.env.local` angelegt, migriert und der Cache gebaut. Node wird dort ebenfalls nicht gebraucht. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` gehört danach in einen täglichen Cronjob. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt. Wie ein Update später über einen Git-Tag laufen soll, steht als Plan in `docs/UPDATE.md` – gebaut ist davon noch nichts.
Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001300` ergänzt zwei Spalten an `page` für Kopf- und Fußbereich je Seite (zwei ADD COLUMN). Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` und `php bin/console cms:backup` gehören danach in einen täglichen Cronjob – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore <name>`. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Ändert ein Update die Abhängigkeiten (`composer.lock`) und ist Composer auf dem Server nicht aufrufbar, bricht der Updater ab und verweist auf das Releasearchiv; Updates ohne Abhängigkeitsänderung laufen auch ohne Composer durch. Ablauf und Grenzen stehen in `docs/UPDATE.md`. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt.
+1 -1
View File
@@ -1 +1 @@
1.0.0
1.4.6
+32
View File
@@ -11,6 +11,38 @@
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$file = __DIR__.'/../public'.$path;
if ('/' !== $path && !str_contains($path, '..') && is_file($file)) {
// Dieselben Antwortkoepfe wie public/.htaccess sie auf einem Webserver setzt: Stylesheets,
// Skripte, Schriften und Bilder tragen ihre Fassung oder ihre Kennung in der Adresse und
// duerfen deshalb lange im Browser bleiben. Ohne das misst jede Messung hier etwas anderes
// als das, was spaeter ausgeliefert wird - und man optimiert an der falschen Stelle.
if (1 === preg_match('/\.(css|js|woff2|webp|png|jpe?g|gif|svg)$/', $path, $treffer) && !str_starts_with($path, '/assets/seite/')) {
header('Content-Type: '.match ($treffer[1]) {
'css' => 'text/css; charset=UTF-8',
'js' => 'text/javascript; charset=UTF-8',
'woff2' => 'font/woff2',
'svg' => 'image/svg+xml; charset=UTF-8',
'jpg', 'jpeg' => 'image/jpeg',
default => 'image/'.$treffer[1],
});
header('Cache-Control: public, max-age=31536000, immutable');
// Und ebenso die Komprimierung, die mod_deflate auf einem Webserver uebernimmt: ohne sie
// misst man hier eine Stylesheetgroesse, die es im Betrieb nie gibt.
$inhalt = (string) file_get_contents($file);
if (in_array($treffer[1], ['css', 'js', 'svg'], true) && str_contains($_SERVER['HTTP_ACCEPT_ENCODING'] ?? '', 'gzip')) {
$inhalt = (string) gzencode($inhalt, 6);
header('Content-Encoding: gzip');
header('Vary: Accept-Encoding');
}
header('Content-Length: '.strlen($inhalt));
echo $inhalt;
exit;
}
return false; // Let the built-in server deliver the static file.
}
// Auch die Seiten selbst gehen komprimiert raus, wie auf einem Webserver mit mod_deflate.
ini_set('zlib.output_compression', '1');
ini_set('zlib.output_compression_level', '6');
require __DIR__.'/../public/index.php';
+16
View File
@@ -11,3 +11,19 @@ foreach (['APP_ENV', 'APP_DEBUG', 'APP_SECRET', 'DATABASE_URL', 'SITE_ORIGIN', '
}
}
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
// A freshly unpacked installation has no secret, and without one nothing boots - not even the
// installer that would explain why. So the first request writes one into .env.local and never
// touches it again. If the project directory is not writable, the request still works, but only
// this one: the installer's system check says so in a sentence instead of leaving a white page.
if (!isset($_SERVER['APP_SECRET']) || '' === $_SERVER['APP_SECRET']) {
$secret = bin2hex(random_bytes(32));
$file = dirname(__DIR__).'/.env.local';
$existing = is_file($file) ? (string) file_get_contents($file) : '';
if (false === @file_put_contents($file, $existing.(str_ends_with($existing, "\n") || '' === $existing ? '' : "\n").'APP_SECRET="'.$secret.'"'."\n")) {
$secret .= '-nicht-gespeichert';
} else {
@chmod($file, 0o600);
}
$_SERVER['APP_SECRET'] = $_ENV['APP_SECRET'] = $secret;
}
+3 -3
View File
@@ -9,7 +9,7 @@ security:
ROLE_PUBLISHER: ROLE_EDITOR
firewalls:
admin:
pattern: ^/admin(?:/|$)
pattern: ^/%kant.admin_path%(?:/|$)
lazy: true
provider: cms_users
form_login:
@@ -25,5 +25,5 @@ security:
target: admin_login
enable_csrf: true
access_control:
- { path: ^/admin/login$, roles: PUBLIC_ACCESS }
- { path: ^/admin, roles: ROLE_EDITOR }
- { path: ^/%kant.admin_path%/login$, roles: PUBLIC_ACCESS }
- { path: ^/%kant.admin_path%, roles: ROLE_EDITOR }
+3
View File
@@ -5,3 +5,6 @@ twig:
# Reading the decision needs the current request, so the templates ask the gate directly
# instead of every controller passing the same three values along.
consentGate: '@App\Content\Consent'
# Dasselbe für die Einstellungen der Website: der Kopfbereich fragt, ob er beim
# Scrollen kleben soll, und das steht in keinem Dokument.
siteSettings: '@App\Content\SiteSettings'
+9 -1
View File
@@ -1,3 +1,11 @@
controllers:
# Erst alle benannten Routen, dann die Auffangroute fuer oeffentliche Seiten. Ohne diese
# Reihenfolge wuerde `/verwaltung/partials` als Seitenadresse gelesen, sobald die Verwaltung nicht
# mehr `/admin` heisst - die Auffangroute schliesst nur den Standardnamen aus.
verwaltung:
resource: ../src/Controller/
type: attribute
exclude: '../src/Controller/PageController.php'
seiten:
resource: ../src/Controller/PageController.php
type: attribute
+59 -1
View File
@@ -1,3 +1,16 @@
parameters:
# Die Adresse der Verwaltung. Standard bleibt /admin; wer sie aendert, nimmt Scannern und
# Anmeldeversuchen das Ziel - erraten laesst sie sich nicht mehr. Sie steht in der Umgebung und
# nicht in der Datenbank, weil die Routen beim Start feststehen muessen. Geaendert wird sie
# ueber `php bin/console cms:admin:path <neu>`; der Befehl prueft und raeumt den Cache auf.
kant.admin_path: '%env(string:default:kant.admin_path_default:ADMIN_PATH)%'
kant.admin_path_default: 'admin'
# Wo Schriften und Stylesheets liegen. Im Normalfall im oeffentlichen Ordner; ueber
# FONT_DIRECTORY und ASSET_DIRECTORY umstellbar - die Tests nutzen das, damit sie nicht in
# das Projekt schreiben, und eine Installation mit schreibgeschuetztem public/ ebenfalls.
kant.fonts_dir: '%kernel.project_dir%/public/assets/fonts'
kant.asset_dir: '%kernel.project_dir%/public/assets'
services:
_defaults:
autowire: true
@@ -7,7 +20,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%'
@@ -15,6 +28,21 @@ services:
App\Content\MediaLibrary:
arguments:
$directory: '%env(resolve:MEDIA_DIRECTORY)%'
# Die Schriften liegen im oeffentlichen Ordner: sie sollen vom Webserver direkt ausgeliefert
# werden, ohne PHP dazwischen - das ist der Punkt der Uebung.
# Die Stellen, die wissen muessen, wie die Verwaltung heisst.
App\EventSubscriber\AdminHeadersSubscriber:
arguments:
$adminPath: '%kant.admin_path%'
App\Content\Statistics:
arguments:
$adminPath: '%kant.admin_path%'
App\Content\FontLibrary:
arguments:
$directory: '%env(default:kant.fonts_dir:FONT_DIRECTORY)%'
App\Content\StyleBundle:
arguments:
$assetDirectory: '%env(default:kant.asset_dir:ASSET_DIRECTORY)%'
App\Controller\FormController:
arguments:
$secret: '%kernel.secret%'
@@ -22,13 +50,43 @@ services:
App\Controller\SeoController:
arguments:
$siteOrigin: '%env(SITE_ORIGIN)%'
$adminPath: '%kant.admin_path%'
App\Command\HealthCommand:
arguments:
$projectDir: '%kernel.project_dir%'
App\Controller\SystemController:
arguments:
$adminPath: '%kant.admin_path%'
App\Command\AdminPathCommand:
arguments:
$projectDir: '%kernel.project_dir%'
$adminPath: '%kant.admin_path%'
$environment: '%kernel.environment%'
App\Ops\ArchiveUpdate:
arguments:
$root: '%kernel.project_dir%'
App\Ops\Updater:
arguments:
$root: '%kernel.project_dir%'
$databaseUrl: '%env(resolve:DATABASE_URL)%'
App\EventSubscriber\InstallSubscriber:
arguments:
$projectDir: '%kernel.project_dir%'
App\EventSubscriber\MaintenanceSubscriber:
arguments:
$projectDir: '%kernel.project_dir%'
$adminPath: '%kant.admin_path%'
App\Ops\Installation:
arguments:
$root: '%kernel.project_dir%'
$mediaDirectory: '%env(resolve:MEDIA_DIRECTORY)%'
$secret: '%kernel.secret%'
App\Ops\Backup:
arguments:
$root: '%kernel.project_dir%'
$databaseUrl: '%env(resolve:DATABASE_URL)%'
$mediaDirectory: '%env(resolve:MEDIA_DIRECTORY)%'
App\Infrastructure\QueryCounter: ~
App\EventSubscriber\QueryHeaderSubscriber:
arguments:
$environment: '%kernel.environment%'
+519 -1
View File
@@ -95,7 +95,7 @@ Migration `20260912000300` ergänzt ausschließlich die Tabelle `media`: stabile
Uploads und Metadatenänderungen verlangen `ROLE_EDITOR` und einen eigenen CSRF-Token. Metadaten nutzen erwartete Versionen; konkurrierende Änderungen liefern 409. Bildtitel/Alt-Text im Medienkatalog sind Vorlagen für künftiges Einfügen. Bereits gespeicherte Seiten behalten ihre kontextbezogenen Texte, Beschriftungen und Bildvarianten. Archivieren blendet Bilder aus der normalen Auswahl aus und ist umkehrbar; bestehende URLs, Seiten und Undo-Stände bleiben funktionsfähig. Es gibt noch keine physische Löschung, Nutzungsübersicht, Ordner oder Katalog-Paginierung.
Nur tatsächlich decodierbare JPEG-, PNG- und WebP-Dateien werden angenommen. Dateinamen und vom Browser gemeldete MIME-Typen bestimmen weder Speicherpfade noch Dateiformat. Maximal 8 MiB, 12 Millionen Pixel und 8000 px pro Kante; zusätzlich konservative Prüfung gegen das PHP-Speicherlimit. GD decodiert und erzeugt WebP-Varianten bis 480/960/1600 px Breite ohne Hochskalieren. Das hochgeladene Original wird nicht übernommen; Metadaten und angehängte Nutzdaten werden nicht in die Varianten kopiert. EXIF-Orientierung wird bei JPEG berücksichtigt, sofern die EXIF-Erweiterung vorhanden ist. Transparenz wird erhalten; animierte Bilder/Dateianhänge/SVG gehören nicht zum unterstützten Uploadvertrag. Grundlage: [PHP-Bilddecodierung](https://www.php.net/manual/en/function.imagecreatefromstring.php).
Nur tatsächlich decodierbare JPEG-, PNG- und WebP-Dateien werden angenommen. Dateinamen und vom Browser gemeldete MIME-Typen bestimmen weder Speicherpfade noch Dateiformat. Maximal 8 MiB, 12 Millionen Pixel und 8000 px pro Kante; zusätzlich konservative Prüfung gegen das PHP-Speicherlimit. GD decodiert und erzeugt WebP-Varianten (Qualität 82) bis 480/960/1600/2400 px Breite ohne Hochskalieren; ein schmaleres Original bekommt entsprechend weniger Stufen, die letzte davon in seiner eigenen Breite. Das hochgeladene Original wird nicht übernommen; Metadaten und angehängte Nutzdaten werden nicht in die Varianten kopiert. EXIF-Orientierung wird bei JPEG berücksichtigt, sofern die EXIF-Erweiterung vorhanden ist. Transparenz wird erhalten; animierte Bilder/Dateianhänge/SVG gehören nicht zum unterstützten Uploadvertrag. Grundlage: [PHP-Bilddecodierung](https://www.php.net/manual/en/function.imagecreatefromstring.php).
Dateien liegen unter `MEDIA_DIRECTORY`, standardmäßig `var/media` außerhalb des Webroots. Nur zufällige IDs und bekannte Variantengrößen gelangen über `/media/{id}/{width}` mit festem `image/webp`, `nosniff` und unveränderlichem Langzeitcache nach außen. Erweiterungslose URLs funktionieren auch über die Frontcontroller-Weiterleitung des lokalen PHP-Servers; Pfade mit nicht vorhandenen `.webp`-Dateien wurden dort als statische 404 behandelt. Bild-URLs sind öffentlich, auch vor Veröffentlichung einer Seite und nach Archivieren; der Uploaddialog benennt dies. Die Katalog-API bleibt geschützt. Dies ist keine private Dokumentablage.
@@ -440,6 +440,524 @@ 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 072 – Drei Fehler einer Familie: dieses Hosting ist nicht mein Rechner (13.09.2026)
Umut, während er 1.4.4 in Betrieb nahm: „Das buggt doch immer noch" und „Bei Sicherung anlegen kommt auch Fehler 500 – wieso ist das alles verbuggt?" Drei Befunde an einem Abend, und sie gehören zusammen: **Jeder einzelne war auf meinem Rechner unsichtbar.**
**1. `git` installiert ist nicht dasselbe wie „Arbeitskopie".** `Updater::state()` gab unter dem Schlüssel `git` zurück, ob der *Befehl* `git` aufrufbar ist. Auf Umuts Hosting ist er das (Version 1.8.3.1), nur ist das Verzeichnis eine Installation aus dem Releasearchiv. Also lief jeder Git-Aufruf ins Leere, und in der Verwaltung stand dort, wo eine Fassungsnummer hingehört: `fatal: Not a git repository`. Dazu eine Vorprüfung voller roter Ausrufezeichen für Bedingungen, die für diese Installation gar nicht gelten. Die Software war in Ordnung und sah kaputt aus – für den Betreiber ist das dasselbe. Der Zustand heißt jetzt `worktree` und wird getrennt von `git` erhoben; ohne Arbeitskopie verschwinden die Git-Karten vollständig, und an ihrer Stelle steht ein Satz: „Eingerichtet über das Releasearchiv."
**2. `VACUUM INTO` gibt es erst seit SQLite 3.27.** Die Sicherung benutzt es, weil eine Dateikopie während eines Schreibvorgangs unbrauchbar sein kann (ADR 038). Auf Umuts Hosting läuft eine ältere Fassung: alt genug für die Installation (die braucht 3.16, ADR aus 1.4.2), zu alt für die Sicherung. Ergebnis: 500 beim Knopf „Sicherung jetzt anlegen". Jetzt wird bei älteren Fassungen unter einer Schreibsperre kopiert – `PRAGMA wal_checkpoint`, `BEGIN IMMEDIATE`, kopieren, `COMMIT` –, und der Test setzt die Schwelle künstlich hoch, damit dieser Weg wirklich läuft und nicht nur auf fremden Servern.
**3. Ein unerwarteter Fehler darf keine 500-Seite sein.** Der Sicherungsknopf fing `InvalidPage` ab, also die *erwarteten* Fälle. Alles andere – alte Datenbankfassung, fehlendes Schreibrecht, voller Datenträger – fiel durch. Genau das sind aber die Fälle, die nur auf fremden Servern auftreten. Der Knopf fängt jetzt jeden Fehler und sagt, woran es lag.
**Was ich daraus mitnehme:** Jeder dieser Fehler war eine *Umgebungsdifferenz*, und keiner davon war durch mehr Tests auf meinem Rechner zu finden. Was hilft, ist zweierlei – eine nachgestellte Installation aus dem echten Releasearchiv (so wurde ADR 071 gefunden) und die Regel, dass ein Knopf in der Verwaltung **niemals** mit einer 500-Seite enden darf, sondern mit einem Satz. Das Zweite ist billiger und fängt auch die Fälle, an die ich nicht gedacht habe.
## ADR 071 – Ein Buchstabe, der die ganze Funktion unbrauchbar machte (13.09.2026)
Umut hat 1.4.3 installiert, den Updateknopf gedrückt und eine **500-Seite** bekommen. Sein Satz dazu: „Das gefällt mir alles ehrlich gesagt nicht. Ich möchte es so einfach wie WordPress." Beides berechtigt.
Nachgestellt wurde es mit einer echten 1.4.3-Installation aus dem Releasearchiv, eigener Datenbank, eigenem Port. Der Fehler stand dann im Protokoll:
```
Twig\Error\RuntimeError: Key "step" for sequence/mapping with keys
"label, ok, detail" does not exist in "admin/system.html.twig" at line 137
```
Der Updater über Git nennt einen Schritt `step`, der neue über das Archiv nannte ihn `label`, und beide legen ihr Ergebnis unter demselben Sitzungsschlüssel ab. Die Vorlage liest `step`. **Das Update lief vollständig durch** – alle neun Schritte in Ordnung, Dateien getauscht, `var/` und `.env` unberührt – und dann starb die Seite, die das berichten sollte. Damit war auch der zweite Knopf unerreichbar, der die Migrationen ausführt, und die Website blieb im Wartungszustand.
**Warum kein Test das gefunden hat.** Zehn Tests prüfen den Archiv-Updater, und alle prüfen sein Rückgabe-Array. Keiner hat die Seite gerendert, die dieses Array anzeigt. Ein Ergebnis, das stimmt, und eine Anzeige, die es nicht lesen kann – das ist dieselbe Familie wie das `og:image`, das gespeichert wurde und nie ankam (ADR 056), und wie das Stylesheet, das nichts mehr tat (ADR 064). **Die Naht zwischen zwei richtigen Hälften ist die Stelle, an der es bricht.** Der Test dazu rendert jetzt `/admin/system` mit den Schritten eines echten Laufs in der Sitzung; gegengeprüft, indem der Fehler noch einmal eingebaut wurde – der Test wird rot.
Der Schlüssel heißt jetzt in beiden Updatern `step`. Nicht die Vorlage wurde nachsichtig gemacht: Zwei Dinge derselben Art beschreiben sich gleich, sonst wiederholt sich das beim nächsten Anzeigeort.
**Und der zweite Teil von Umuts Satz.** Nach dem Tausch stand die Verwaltung mit zwei Karten da, die beide dasselbe wollten – „Migrationen ausführen und freigeben" und „Migrationen stehen aus". Solange das Archiv-Update auf seinen zweiten Schritt wartet, gehört der Knopf an eine Stelle. Zwei Knöpfe, die dasselbe tun, sind eine Frage zu viel.
## ADR 070 – Die zwei Lücken, die erst das Veröffentlichen zeigte (13.09.2026)
Umut, kurz nach dem Release: „Wie spiele ich das Update manuell ein?" Die Frage war berechtigt, und die Antwort hatte zwei Lücken.
**Erstens: Es gab keinen Weg, Migrationen im Browser auszuführen.** Wer die Dateien über FTP hochlädt – und das muss genau einmal jeder tun, dessen Installation die Updatekarte noch nicht kennt –, hat danach neuen Code und eine alte Datenbank. Dafür gab es nur die Konsole, also genau das, was auf geteiltem Hosting fehlt. Der Bildschirm „System" zeigt jetzt eine Karte, **sobald etwas aussteht**, nennt die Namen der Migrationen und führt sie auf Knopfdruck aus. Das Lesen der Liste ist gekapselt: Eine Datenbank, die die Migrationstabelle noch nicht hat, darf nicht der Grund sein, dass dieser Bildschirm gar nicht mehr erscheint.
**Zweitens: „Die Releaseseite antwortet nicht" war drei Fehler in einem Satz.** Beim Gegenprüfen des frischen Releases holte der Updater von diesem Rechner gar nichts – nicht weil die Leitung zu wäre, sondern weil diesem PHP ein **Zertifikatsspeicher** fehlt (`curl.cainfo` leer). Auf einem Webhosting ist das der häufigste der drei Gründe und der einzige, den man in einer Minute behebt; er muss also dastehen. `Download` merkt sich seither, woran es lag, und der Schritt in der Verwaltung trägt den Grund mit.
**Und daraus folgte gleich noch etwas:** Bei genau diesem Fehler lohnt der zweite Weg. Der Dateizeiger benutzt die Einstellungen von OpenSSL, und die sind manchmal gesetzt, wo curl nichts findet – auf diesem Rechner **funktioniert der Abruf dadurch jetzt**. Was nicht passiert, ist das Naheliegende: Die Prüfung wird nicht abgeschaltet. Ein Update, das ohne Zertifikatsprüfung geladen wird, kann unterwegs ausgetauscht worden sein, und dann installiert der Knopf fremden Code.
## ADR 069 – Das größte Bild über der Falz ist gar kein Bild (13.09.2026)
Nachgemessen vor dem Livegang, auf der Startseite des Piloten statt auf einer Testseite: **Leistung 96 (Desktop) und 98 (Mobil)**, alles andere 100. Der Grund stand im Bericht: `LCP request discovery` – das größte Element über der Falz ist dort kein `<img>`, sondern der **Hintergrund des ersten Abschnitts**.
Das ist ein struktureller Unterschied, kein Zufall: Ein `<img>` steht im HTML, der Browser sieht es beim ersten Durchlesen und beginnt zu laden. Ein Hintergrundbild steht im Stylesheet – der Browser erfährt erst von ihm, wenn er das CSS geholt und geparst hat. Die Regel aus ADR 039 („das erste Bild einer Seite lädt sofort") griff genau deshalb nicht: Sie sucht nach einem Bildbaustein, und es gab keinen.
**Drei Vorabverweise mit Medienabfragen, nicht ein `imagesrcset`.** Das war die einzige Entscheidung, die wirklich eine war. Ein `srcset` wählt nach Breite, die Regeln des Compilers wählen nach Gerät – bei einem 1200 px breiten Fenster nähme das eine die 1600er Datei und das andere die 1920er, und der Browser lüde **zwei** Bilder statt einem. Dann kostet das Vorabholen mehr, als es bringt – genau der Fehler, der in diesem Projekt schon einmal fünf Punkte gekostet hat. Die drei Verweise tragen deshalb dieselben Grenzen wie der Compiler (`StyleCompiler::PRELOAD_DEVICES`), und die Variantenwahl ist eine einzige öffentliche Methode, die beide Seiten benutzen. Der Test vergleicht nicht nur, dass Verweise da sind, sondern dass **jeder Verweis dieselbe Datei meint wie die Regel**.
**Gemessen danach: Desktop 97, Mobil 99**, LCP 1,3 s → 1,1 s. Kein Sprung auf 100 – was bleibt, ist das blockierende Stylesheet (300 ms FCP), und das ist zum großen Teil der Entwicklungsserver ohne Kompression, siehe `docs/QUALITY.md`.
**Und ein Nebenfund, der mehr brachte als der Hauptpunkt.** Der Kopf fragte die Mediathek nach demselben Bild, das der Compiler ohnehin schon dreimal gesucht hatte – je Gerät eine Regel, je Regel eine Abfrage. `MediaLibrary::find()` merkt sich jetzt, wonach es in dieser Anfrage schon gefragt wurde: **Startseite 19 → 15 Abfragen**, und zwar einschließlich der Abfrage, die der Vorabverweis neu gebracht hat. Der Merkzettel lebt nur so lange wie die Anfrage, also kann er nicht veralten; beim Hochladen und Ändern wird er trotzdem geleert, weil derselbe Aufruf danach noch liest.
## ADR 068 – Update aus dem Releasearchiv, in zwei Schritten (13.09.2026)
Umut: „Dann Updater für Hoster optimieren?!" Der Updater aus ADR 035 setzt eine Git-Arbeitskopie voraus. Auf geteiltem Hosting gibt es die nicht – dort wird über das Releasearchiv installiert, und die Verwaltung sagte danach „Dieses Verzeichnis ist keine Git-Arbeitskopie". Also kein Update, außer von Hand über FTP. Das ist genau der Fall, für den dieses CMS gedacht ist.
`ArchiveUpdate` geht denselben Weg wie die Erstinstallation: Archiv laden, Prüfsumme vergleichen, entpacken, Dateien ersetzen, migrieren. Drei Unterschiede trennen ein Update von einer Installation, und jeder davon ist eine Entscheidung:
**Was dem Betreiber gehört, wird nicht angefasst.** `var/`, `.env`, `.env.local`, die hochgeladenen Schriften und das erzeugte Stylesheet-Bündel bleiben stehen. Deshalb wird `public/` als einziges Verzeichnis **Datei für Datei zusammengeführt** statt ersetzt: dort liegen die Dateien der Anwendung und die des Betreibers nebeneinander. Ein `rename()` des ganzen Ordners wäre eine Zeile weniger Code und hätte die Schriften gelöscht.
**Der alte Stand wird beiseitegelegt, nicht gelöscht.** Er liegt unter `var/update/<Zeitstempel>/alt/`, bis jemand ihn entfernt. Scheitert das Ersetzen mittendrin, schiebt der Updater zurück, was er schon bewegt hat, und nimmt den Wartungszustand wieder heraus.
**Es sind zwei Knöpfe, und das ist kein Umweg.** Der erste ersetzt die Dateien, der zweite führt die Migrationen aus. Dazwischen liegt eine neue Anfrage – denn ein PHP-Prozess, der sich selbst ausgetauscht hat, arbeitet mit den Klassen weiter, die er beim Start geladen hat. Migrationen im selben Durchgang liefen mit dem Code der alten Fassung. Der Wartungszustand bleibt zwischen beiden Schritten an, und die Oberfläche sagt, dass er es tut.
**Geprüft wird das ohne Netz.** Das Herunterladen ist in `Download` gewandert – eine Klasse, die genau eine Sache tut und die HTTPS-Pflicht an einer einzigen Stelle durchsetzt. Der Test stellt eine Releaseseite in den Speicher und lässt den Rest echt laufen: echte Zip-Archive, echte Prüfsummen, echte Umbenennungen. Die Frage, um die es dabei geht, ist nicht „läuft der Download", sondern: Steht die hochgeladene Schrift danach noch da? Ein Test, der wirklich `git.kant-it.de` abfragt, wäre rot, wenn der Server umzieht, und grün, wenn im Archiv Unsinn steht.
Dazu kommt ein Test, der die Ersetzungsliste des Updaters gegen die Mitnahmeliste von `scripts/build-release.php` hält. Denselben Fehler gab es schon einmal andersherum: `migrations.php` fehlte im Archiv, und es fiel erst auf Umuts Hosting auf.
## ADR 067 – Die Verwaltung auf dem Telefon, und eigene Symbole (13.09.2026)
Umut: „Bitte Backend mobiloptimieren, das Menü ist schlecht." Und kurz darauf: „Machst du auch gleich eigene Icons statt die Web-Dinger zu nutzen – auf dem Smartphone kommen sonst Emojis."
Zwei Beobachtungen, eine Ursache: Die Verwaltung war für einen breiten Bildschirm gebaut. Zwölf Navigationspunkte nebeneinander ergeben auf 390 px eine Zeile, die seitlich hinausläuft.
**Das Menü ist jetzt ein `<details>`**, das ab 760 px Breite aufgeklappt und darunter zugeklappt startet; die Zusammenfassung nennt den Bereich, in dem man gerade steht. Kein Skript, kein Zustand, der verloren gehen kann – und mit Tastatur bedienbar, weil der Browser das mitbringt. Die Ziele sind 44 px hoch, was keine Gestaltungsfrage ist, sondern die Größe einer Fingerkuppe.
**Die Symbole waren Unicode-Zeichen** (▤ ◫ ☰ ▩ ⚙). Auf dem Schreibtisch sehen sie aus wie Symbole; ein Telefon ersetzt sie durch **Emoji** – buntes Zahnrad, bunter Briefumschlag, jedes in einem anderen Stil, und nichts davon in der Schrift der Oberfläche. Jetzt sind es zwölf eigene SVG-Pfade in einer Vorlage: eine Linienstärke, `currentColor`, kein zusätzlicher Request, keine Symbolschrift. Ein Test sucht in der Navigationsvorlage nach Zeichen aus den Emoji-Bereichen und hält jeden verwendeten Namen gegen die Sammlung – ein fehlender Name würde sonst still das Ersatzsymbol zeichnen, und zwei Einträge sähen gleich aus. Genau das war der Fall: „Formulare" und „Benutzer" trugen denselben Pfad.
## ADR 066 – Ein Farbfeld, das beides kann (13.09.2026)
Umut: „Wieso kein Colorpicker?" Berechtigte Frage. Die Farbfelder im Designbereich waren Textfelder – wer Grün wollte, musste `#2e7d5b` wissen.
Der Grund dafür war echt: Ein `<input type="color">` kann **nur** eine Farbe. Die Gestaltung dieses CMS kennt aber auch `@akzent` – einen Verweis auf die Palette, der sich ändert, wenn die Palette sich ändert. Ein Farbwähler hätte daraus beim ersten Klick einen festen Wert gemacht und die Verbindung stillschweigend gekappt.
**Das Textfeld bleibt deshalb maßgeblich**, und daneben stehen ein Farbfeld und eine Auswahl der Palettenfarben. Wer im Farbfeld zieht, schreibt einen Hexwert in das Textfeld; wer `@akzent` tippt, behält den Verweis, und das Farbfeld zeigt daneben nur an, wie diese Farbe gerade aussieht. Aufgebaut wird das von einem kleinen Skript über das vorhandene Feld: Ohne JavaScript bleibt das Textfeld übrig und die Seite vollständig bedienbar.
## ADR 056 – Das Vorschaubild beim Teilen, und die robots.txt als Bot-Steuerung (13.09.2026)
Umut: „Hast du auch schon SEO und Siteindex usw. übernommen?“ Der Abgleich mit dem Original war die Antwort – und er förderte einen Fehler der eigenen Software zutage.
**Das Feld „Vorschaubild beim Teilen“ gab es seit dem SEO-Paket, ausgegeben wurde es nie.** Im Kopf stand `{% if socialImage %}`, gesetzt hat `socialImage` kein Controller. Damit hatte **keine** mit KantSites gebaute Seite ein `og:image` – beim Teilen in WhatsApp, LinkedIn oder Slack erschien eine Karte ohne Bild. Jetzt wird es aufgelöst: erst das eigens gesetzte Vorschaubild, sonst bei einem Beitrag sein Beitragsbild, als absolute Adresse und in der Variante bis 1600 px. Dasselbe Bild steht im strukturierten Datensatz des Beitrags. Ohne Bild gibt es `twitter:card: summary` statt der großen Karte, die sonst eine leere Fläche wäre. Dazu `og:site_name` und `og:locale`, die schlicht fehlten.
**Die robots.txt war zu eng gefasst.** Das Original pflegt eine Liste, welche KI-Crawler die Website lesen dürfen (GPTBot, ClaudeBot, PerplexityBot …) und welche nicht (Bytespider, Diffbot, omgili, ImagesiftBot) – rund siebzig Zeilen in Blöcken je Bot. Das Feld für eigene Zeilen nahm dreißig und warf Leerzeilen weg, machte also aus Blöcken einen Klumpen. Jetzt 200 Zeilen, Leerzeilen bleiben als Trenner erhalten. Die erlaubten Anweisungen bleiben dieselben: Kommentar, `User-agent`, `Allow`, `Disallow`, `Crawl-delay`.
**Kein Sitemap-Index.** Das Original liefert einen (drei Teilkarten von Yoast), KantSites eine einzelne Datei mit allen Adressen. Ein Index ist erst ab 50.000 Adressen oder 50 MB vorgeschrieben; bei 35 Adressen wäre er eine Datei mehr ohne jeden Unterschied für eine Suchmaschine. Aufgeteilt wird, wenn es eine Website gibt, die das braucht – und dann automatisch, nicht als Einstellung.
## ADR 059 – Cloudflare Turnstile: optional, und der Preis steht dabei (13.09.2026)
Umut: „bei Formularen hätte ich gerne optional Cloudflare." Eingebaut sind drei Prüfungen ohne Dritte – ein verstecktes Feld, ein signierter Zeitstempel, fünf Absendungen je Absender und Stunde. Sie halten ab, was ein Skript ausfüllt; gegen jemanden, der einen echten Browser fernsteuert, helfen sie nicht.
**Turnstile und nicht reCAPTCHA:** keine Cookies, keine Google-Anmeldung, keine Verhaltensauswertung über Websites hinweg. Ein Dritter bleibt es trotzdem – die IP-Adresse des Absenders geht an Cloudflare, und das gehört in die Datenschutzerklärung.
Drei Entscheidungen, die nicht offensichtlich sind:
**Aus, bis jemand sie einschaltet.** Eine Website ohne Spamwelle soll keinen Dritten einbinden. Eingeschaltet ohne Schlüssel wird abgelehnt: das wäre ein Formular, das jede Anfrage verschluckt.
**Antwortet Cloudflare nicht, gilt die Absendung als bestanden.** Ein Ausfall bei einem Dritten darf keine Kundenanfrage kosten. Spam ist ärgerlich, eine verlorene Anfrage ist teurer.
**Die Sicherheitsrichtlinie öffnet sich nur dort, wo ein Formular steht** – Skript und Rahmen von `challenges.cloudflare.com`, und das auch nur auf den betroffenen Seiten. Jede andere Seite behält `script-src 'none'`.
Der Preis steht in der Verwaltung: das Widget braucht JavaScript. Ohne Skript ist das Formular dann nicht absendbar – genau die Art von Verschlechterung, die dieses Projekt sonst vermeidet. Deshalb steht im Formular ein `<noscript>`-Hinweis mit dem Weg über E-Mail.
## ADR 065 – Die Verwaltung muss nicht /admin heißen (13.09.2026)
Umut: „Erstelle mal Möglichkeit, Backend anzupassen. Also URL, Admin auf irgendwas."
`/admin` ist die erste Adresse, die ein Scanner probiert, und jeder Anmeldeversuch, der dort ankommt, kostet Rechenzeit und Logzeilen. Ein anderer Name nimmt der Maschinerie das Ziel. **Er ersetzt kein gutes Passwort** – die Anmeldesperre nach fünf Versuchen bleibt die eigentliche Verteidigung –, aber er macht die Website für automatisierte Angriffe uninteressant.
**Der Name steht in der Umgebung, nicht in der Datenbank.** Routen stehen fest, bevor die erste Datenbankabfrage läuft; eine Adresse, die erst beim Rendern bekannt wird, käme zu spät. `ADMIN_PATH` füllt einen Parameter, der als Präfix in jeder Verwaltungsroute steht (`#[Route('/%kant.admin_path%/media')]`), in der Firewall, in den Schutzkopfzeilen und in der Statistik.
Drei Dinge, die dabei nicht offensichtlich sind:
**Die robots.txt nennt den Namen nicht mehr.** Sie ist öffentlich und das erste, was ein Angreifer liest – ein `Disallow: /stiller-eingang` wäre ein Wegweiser. Beim Standardnamen bleibt die Zeile, weil diese Adresse ohnehin jeder kennt; bei jedem anderen entfällt sie. Vom Indexieren hält die Verwaltung ihr eigener Kopf `X-Robots-Tag: noindex` ab.
**Die Auffangroute für Seiten steht jetzt zuletzt.** Sie schließt per Regex `/admin` aus – einen frei wählbaren Namen kann sie nicht kennen. Statt die Regex dynamisch zu machen, lädt `routes.yaml` erst alle benannten Routen und dann die Auffangroute. Das ist ohnehin die richtige Reihenfolge.
**Der Befehl räumt den Zwischenspeicher auf, aber nicht den, in dem er selbst läuft.** Die Routen tragen den Namen einbetoniert; ohne Aufräumen gälte der alte weiter. Wer sich dabei den Boden unter den Füßen wegzieht, endet mit einem Fehler, obwohl alles geklappt hat – also bleibt die laufende Umgebung stehen, und der Befehl sagt, wie man sie leert.
`php bin/console cms:admin:path` zeigt die aktuelle Adresse, mit Argument ändert er sie – nach einer Prüfung, dass der Name frei ist und keine Seite verdeckt.
## ADR 064 – Ein Stylesheet, das nichts mehr tat (13.09.2026)
Umut: „ganz unten Einwilligung ändern, Datenschutz usw. ist links, komplett bitte besser lösen." Das war kein Gestaltungswunsch, sondern ein Fehlerbericht: Der **Einwilligungsdialog stand ungestaltet unter dem Fuß**, linksbündig, ohne Rand, ohne Hintergrund.
Die Ursache lag drei Veröffentlichungen zurück. Beim Entfernen alter Regeln war zweimal der Selektorrest stehen geblieben:
```css
.site-header
.site-footer
/* Kommentar */
.legal-bar{padding:16px 20px;text-align:center;…}
```
CSS liest darüber hinweg: Der offene Selektor nimmt den nächsten mit, und aus zwei Regeln wird eine, die nirgends greift. Dasselbe war der `.consent-bar`-Regel passiert. **Ein Stylesheet beschwert sich nicht – es wirkt nur nicht mehr**, und beides blieb unbemerkt, weil kein Test das Aussehen dieser beiden Leisten prüft.
Repariert, und ein Test liest jetzt jede CSS-Datei zeilenweise: Was wie ein Selektor beginnt, muss auch wie einer enden. Er findet den Fehler in beiden Fassungen, geprüft gegen den alten Stand.
## ADR 063 – Der Bildbetrachter: ein Verweis, den ein Skript veredelt (13.09.2026)
Umut: „Bilder vergrößern Funktion, ein Popup-Viewer – gerne auch auswählbar wie bei WordPress, ob man bei Klick Galerie anschauen möchte."
Auswählbar heißt hier: Jedes Bild hat unter „Beim Klick" drei Möglichkeiten – nichts tun, vergrößern, zu einer Adresse führen –, und eine Galerie hat einen Schalter „Bilder vergrößerbar".
**Die Grundlage ist ein gewöhnlicher Verweis auf die große Bildfassung.** Ohne JavaScript öffnet ein Klick sie, zurück führt die Zurück-Taste; nichts ist kaputt. Das Skript – geladen nur auf Seiten, die tatsächlich ein vergrößerbares Bild enthalten – fängt den Klick ab und zeigt das Bild über der Seite, mit Blättern innerhalb einer Galerie.
Benutzt wird `<dialog>`: Fokusfang, Schließen mit Escape und die Auszeichnung als Dialog bringt der Browser mit. Eine nachgebaute Fassung aus `<div>` und Tastaturabfragen wäre mehr Code und weniger zugänglich. Mit gedrückter Zusatztaste bleibt es ein normaler Verweis – wer ein Bild in einem neuen Reiter öffnen will, soll das können.
## ADR 062 – Zwei Dinge, die am Rand kleben: Knopf nach oben und Rechtszeile (13.09.2026)
**Der Knopf „nach oben" ist ein Anker, kein Skript.** `<a href="#seitenanfang">` springt an den Seitenanfang – das kann ein Browser seit jeher allein. Eingeblendet wird er über eine Scroll-Zeitachse (`animation-timeline: scroll(root)`), dieselbe Technik wie bei den Einblendungen; wo es sie nicht gibt, steht er von Anfang an da. Sichtbar und nutzbar ist besser als unsichtbar. Alles daran ist einstellbar – Seite, Form, Größe, Farben, Schwelle, Beschriftung –, weil er auf jeder Seite in der Ecke klebt und deshalb zur Gestaltung passen muss. Solange der Einwilligungsdialog offen steht, tritt er zurück.
**Die Rechtszeile wird ein Baustein.** Bisher hängte das System sie als eigene Leiste unter den Fuß: auf einer Website mit dunklem Fuß ein heller Streifen, den niemand dort haben wollte. Als Baustein steht sie im Fußbereich, erbt dessen Farben und lässt sich wie jeder andere Baustein gestalten. Die eingebaute Leiste erscheint weiterhin – aber nur, wenn die Seite den Baustein nicht enthält. Die Pflicht, Impressum und Datenschutz von jeder Seite aus erreichbar zu halten, bleibt damit erfüllt, ohne dass zweimal dasselbe dasteht.
## ADR 061 – Die Vorschau zeigt nie eine Seite ohne ihre Gestaltung (13.09.2026)
Umut, mit Blick auf einen Screenshot des Editors: „im Screenshot sind die Buttons nicht gleiche Farbe, es sollte exakt nachladen, die Vorschau soll wie das echte aussehen."
**Nachgemessen: die Farben sind identisch.** Knopf, Hero-Hintergrund, Hintergrundbild und Textfarbe geben in der Vorschau und auf der veröffentlichten Seite exakt dieselben berechneten Werte. Was auf dem Bild zu sehen war, war ein Zwischenzustand – links oben stand „Vorschau wird aktualisiert …".
Der Zwischenzustand war trotzdem ein Fehler, und ein langer: **1440 Millisekunden** lang war die Vorschau sichtbar und ungestaltet, gemessen an der nachgebauten Startseite. Der Grund liegt in einer Zeile: die erzeugten Stile wurden im `load`-Ereignis des Rahmens angewandt – und das feuert erst, wenn **das letzte Bild** geladen ist. Bei sechzehn Bildern sieht man bis dahin die Seite ohne eine einzige Farbe.
Drei Änderungen, jede aus einem eigenen Fehlversuch:
1. **Angewandt wird, sobald das Dokument geparst ist**, nicht wenn es fertig geladen ist. Für `srcdoc` gibt es dafür kein verlässliches Ereignis, also wird Bild für Bild nachgesehen, ob der Kopf schon da ist.
2. **Der Rahmen bleibt verdeckt, bis die Gestaltung sitzt.** Eine leere Fläche für einen Moment ist besser als eine falsche Seite.
3. **Nur das neue Dokument wird gestaltet.** Der erste Anlauf sah beim Warten das *alte* Dokument – es trägt dieselben Kennzeichen –, gestaltete es, schaltete sichtbar, und `srcdoc` ersetzte es gleich darauf durch den ungestalteten neuen Inhalt. Der Fehler war damit derselbe wie vorher, nur schneller.
Gegen einen Rückfall prüft die Editorkontrolle nicht die Zeit, sondern das Verhalten: der Rahmen **muss** beim Aufbau verdeckt werden und danach wieder sichtbar. Eine Zeitmessung hätte auf der kleinen Testseite nichts gefunden – das Fenster ist dort zu kurz.
## ADR 060 – Die Fehlerseite eines Formulars ist dieselbe Seite (13.09.2026)
Beim Einbau von Turnstile gefunden: wer ein Pflichtfeld vergaß, bekam die Seite **ohne Gestaltung** zurück. Der Formularcode baute sie selbst ein zweites Mal auf und ließ dabei alles weg, was er nicht kannte – Theme, Blockstile, Schriften, das Stylesheet-Bündel. Zwei Renderwege für dieselbe Seite, und der zweite hinkte hinterher, seit es den ersten gibt.
Jetzt geht auch dieser Weg durch den Seitencontroller. Die Meldungen und die eingetippten Werte reisen als Anfrageattribut mit; die Seite entsteht genau einmal, mit allem, was dazugehört. Eine Sonderregel blieb nötig: die Startseite leitet von ihrem eigenen Pfad auf `/` um, und diese Umleitung hätte die Meldungen verworfen – sie gilt für diesen Weg nicht.
## ADR 058 – Bilder in der Größe, in der sie erscheinen (13.09.2026)
Gemessen auf der nachgebauten Startseite: ein Telefon lud das Hintergrundbild des Hero mit **1920 Pixeln und 189 KB**. Zu sehen war davon nie mehr als ein Viertel. Die Ursache ist bauartbedingt: wer im Editor ein Hintergrundbild wählt, sitzt am Schreibtisch und setzt es unter „Desktop“; kleine Geräte erben diese Regel, und CSS kennt kein `srcset`.
Die Regeln stehen aber ohnehin je Gerät da. Der Kompilierer ergänzt deshalb für Tablet und Telefon dieselbe Bildwahl, die entstünde, wenn jemand sie einzeln gesetzt hätte – samt Verlauf und Abdunkelung, denn ohne sie stünde heller Text plötzlich auf einem ungedämpften Bild. Ein ausdrücklich gesetztes Bild wird nie ersetzt. **189 KB → 40 KB auf dem Telefon.**
Dasselbe Thema bei `<img>`: jedes Bild behauptete, 800 Pixel breit zu erscheinen – auch ein Logo, das 190 Pixel breit ist. Jetzt gilt: eine im Block gesetzte feste Breite ist die Antwort; sonst steht bei verzögert geladenen Bildern `sizes="auto"`, womit der Browser selbst nachmisst (Rückfallangaben dahinter für Safari). Das vorgezogene Bild behält die Annahme, weil seine Größe zum Zeitpunkt der Anfrage noch nicht feststeht.
Und die Eile lag auf der falschen Stelle: `fetchpriority="high"` bekam das erste Bild überhaupt – das Logo im Kopfbereich. Es bekommt es jetzt das erste Bild im **ersten Abschnitt des Inhalts**; Bilder im Kopfbereich laden weiterhin sofort, aber ohne Vorrang.
**Was nicht blieb:** ein Vorabladen des Hero-Hintergrunds (`<link rel="preload" as="image">`). Der Gedanke stimmt – ein Bild im Stylesheet wird spät entdeckt –, die Messung nicht: mobil **95 → 90**. Das Bild nimmt genau die Bandbreite weg, die der Text für den größten Aufbauschritt braucht. Wieder entfernt.
## ADR 057 – Schriften vom eigenen Server (13.09.2026)
Eine Schrift von `fonts.googleapis.com` kostet zweierlei. Technisch: der Browser holt erst das Stylesheet, findet darin die Adresse der Schriftdatei und holt dann diese – zwei fremde Verbindungen nacheinander, gemessen 820 ms, in denen keine Zeile in der richtigen Schrift steht. Rechtlich: ein Dritter erfährt die IP-Adresse jedes Besuchers, und dafür gibt es in Deutschland Abmahnungen (LG München I, 3 O 17493/20).
Deshalb: **einmal holen, selbst ausliefern.** Ein Knopf im Designbereich (und `cms:fonts:fetch` auf der Konsole) lädt die gewählten Schnitte als WOFF2 nach `public/assets/fonts`, schreibt die `@font-face`-Regeln daneben und meldet, was geladen wurde. Die Lizenz erlaubt das ausdrücklich – Google Fonts stehen unter OFL oder Apache 2.0.
Drei Entscheidungen dabei: **Kein neues Paket** – geholt wird mit curl, ersatzweise über `allow_url_fopen`, genau wie der Installer es tut; eine zusätzliche Abhängigkeit würde jedes Update auf einem Hosting ohne Composer scheitern lassen. **Nur Latein** – Kyrillisch und Vietnamesisch wären zwei Drittel der Bytes für nichts. **Nicht beim Seitenaufruf** – ein Besucher wartet nie auf einen fremden Server, auch nicht mittelbar. Liegen die Dateien nicht, bleibt es beim Verweis auf Google statt einer Seite ohne Schrift.
## ADR 056 – Vier Stylesheets werden eines, und der Webserver bekommt Anweisungen (13.09.2026)
Vor dem Livegang gemessen, nicht geschätzt. Jede der vier CSS-Dateien hält das Zeichnen der Seite auf, zusammen 46 KB. Sie sind getrennt, weil sie beim Schreiben Verschiedenes tun; beim Ausliefern ist das kein Argument. Ein Bündel trägt die Prüfsumme seines Inhalts im Namen, wird beim ersten Aufruf nach einer Änderung erzeugt und ohne Kommentare geschrieben. Minifiziert wird bewusst nur das: Kommentare und Einrückung. Ein Minifier, der Selektoren umbaut, spart Zeichen und kostet einen Abend Fehlersuche – in `calc(100% - 20px)` und `.a :hover` trägt ein Leerzeichen Bedeutung. Fehlt das Schreibrecht, werden die Quellen einzeln geladen: langsamer, aber heil.
Dazu eine `public/.htaccess`, die diesen Namen verdient: Kompression (mod_deflate/mod_brotli), ein Jahr Cache für alles, was seine Fassung oder Kennung in der Adresse trägt – Stylesheets und Skripte bekommen dafür `?v=` mit der Programmfassung –, und ein Riegel vor Dateien, die niemanden angehen. HTTPS-Umleitung und HSTS stehen auskommentiert mit dem Grund dabei: eine Umleitung ohne Zertifikat ist eine Endlosschleife, und HSTS lässt sich ein halbes Jahr lang nicht zurücknehmen.
**Der Entwicklungsserver sendet jetzt dieselben Kopfzeilen und komprimiert ebenso.** Ohne das misst man hier Größen, die es im Betrieb nie gibt: dieselbe Startseite ging von 90 auf 96 Punkte, ohne dass sich an ihr etwas geändert hätte.
## ADR 055 – Das Symbol der Website entsteht aus einem Bild der Mediathek (13.09.2026)
Umut: „dann brauchen wir Favicon-Möglichkeit zum Festlegen“. Ein Favicon ist der einzige Teil der Gestaltung, den ein Besucher sieht, bevor die Seite geladen ist – und bisher war es das mitgelieferte Zeichen, für jede mit KantSites gebaute Website dasselbe.
Naheliegend wäre gewesen, die WebP-Varianten der Mediathek zu verwenden. Dagegen sprechen zwei Dinge: die kleinste Variante ist 480 px breit (für ein 32-px-Symbol zwei Größenordnungen zu viel), und ein Favicon ist **quadratisch** – ein breites Logo würde gestaucht oder zur Briefmarke.
**Deshalb entstehen die Symboldateien beim Speichern der Einstellung, nicht beim Aufruf**: ein mittiger quadratischer Ausschnitt in 32 px (Reiter) und 180 px (Startbildschirm), als PNG, weil das jeder Browser liest. Schlägt das fehl, wird die Einstellung gar nicht erst gespeichert – ein Verweis auf ein Symbol, das es nicht gibt, wäre schlechter als das alte Symbol. Ausgeliefert werden sie unter `/favicon.png` und `/apple-touch-icon.png`, mit der Bildkennung als Version am Verweis, damit ein Wechsel im Browser ankommt.
Nebenwirkung, die die Tests fanden: `SiteSettings` fragt die Datenbank jetzt auch aus der Nicht-gefunden-Seite heraus – und **vor** der Installation gibt es die Tabelle noch nicht. Eine gezielt gefangene `TableNotFoundException` liefert dort die Standardwerte; jeder andere Datenbankfehler bleibt ein Fehler.
## ADR 054 – Unterstrichene Links: nur im Fließtext, und dort abschaltbar (13.09.2026)
Umut: „und links haben alle underlined“. Stimmte: `a` hatte keine eigene Textdekoration, also entschied der Browser – und der unterstreicht alles. Sichtbar wurde das an Stellen, wo ein Unterstrich nur wie ein Fehler aussieht: im „Ansehen“-Streifen über einem Kartenbild, im Menü bei der aktuellen Seite, in der Rechtszeile des Fußes, neben dem Telefonsymbol im Kontaktblock.
Die Trennlinie ist nicht Geschmack, sondern Funktion: **ein Link mitten im Fließtext ist ohne Unterstrich nur an seiner Farbe zu erkennen** – für Menschen mit Farbsehschwäche also gar nicht (WCAG 1.4.1). Ein Link, der als Karte, Knopf, Menüeintrag oder Kontaktzeile mit Symbol auftritt, ist an Form und Ort erkennbar und braucht keinen.
Also: strukturelle Links ohne Unterstrich (beim Überfahren erscheint er), Fließtext mit – und für den Fließtext **ein Schalter im Designbereich** statt einer stillen Entscheidung, mit dem Hinweis, was man sich damit einhandelt. Die aktuelle Seite im Menü wird über das Schriftgewicht kenntlich und nicht mehr über die Akzentfarbe: die reichte im dunklen Fußmenü nicht für den Kontrast – gefunden von der Browserprüfung, nicht von mir.
## ADR 053 – Eine Website ohne Beiträge redet nicht von Beiträgen (13.09.2026)
Umut: „Können wir Beiträge optional machen? Wenn's keine gibt, überall ausblenden – ist das schon so?“ Teilweise: die Sitemap ließ das Archiv weg, `llms.txt` auch. Der Rest nicht – `/beitraege` antwortete mit einer leeren Seite, `/feed.xml` mit einem Feed ohne Einträge, jede Seite verwies im Kopf auf diesen Feed, und ein Beitragslisten-Baustein zeigte öffentlich den Kasten „Hier erscheinen die Beiträge, sobald welche veröffentlicht sind“.
**Eine Adresse, die nichts zeigt, ist kein Inhalt, sondern eine Baustelle mit eigener Adresse.** Archiv und Feed antworten deshalb mit 404, solange sie nichts zu zeigen haben – das gilt auch für eine einzelne Kategorie, deren letzter Beitrag gelöscht wurde: der Begriff bleibt als wiederverwendbare Beschriftung bestehen, sein Archiv verschwindet. Der Verweis auf den Feed steht nur im Kopf, wenn es ihn gibt. Der Hinweiskasten bleibt – aber **nur im Editor**, denn dort erklärt er die leere Stelle, während er öffentlich nur ein Versprechen wäre.
Beim Nachmessen fiel eine zweite Sache auf, die nichts mit Beiträgen zu tun hat: die Beitragsliste fragte **je Beitrag** nach seinem Bild und seinen Begriffen. Zwölf Beiträge kosteten 24 Abfragen, und `neighbours()` (Vor/Zurück) lädt bis zu 500 Beiträge. Bilder und Begriffe kommen jetzt in je einer Abfrage für die ganze Liste: die Startseite des Nachbaus fiel von 22 auf 16 Abfragen.
## ADR 052 – Einblenden beim Scrollen, ohne eine Zeile JavaScript (13.09.2026)
Umut: „Effekte gibt es keine, sind die aktiv aktuell?“ Nachgemessen statt geraten: die Zeigereffekte liefen alle (Karte −8 px, Bild 1,06×, Symbolkarte −4 px, Knopf −2 px, Titel in Akzentfarbe). Die **Einblendungen beim Laden** gab es seit Dokumentschema v3 – und **benutzt hatte sie niemand**. Ein Werkzeug, das im Kasten bleibt, ist kein Werkzeug.
Der Grund, warum es im Kasten blieb, war ein echter: eine Animation, die beim *Laden* läuft, ist unterhalb der Falz wertlos – sie ist vorbei, bevor jemand hinsieht. Öffentliche Seiten laufen ohne JavaScript, also gab es bisher nichts, was den Scrollstand kennt.
**Inzwischen kennt CSS ihn.** `animation-timeline: view()` bindet den Ablauf einer Animation an die Sichtbarkeit des Elements: dieselben Keyframes, aber sie laufen, wenn der Abschnitt in den Blick kommt. Dreifach abgesichert: hinter der Bewegungsschranke, hinter `@supports` (Firefox und Safari behalten heute die Fassung von vorher – Einblenden beim Laden, nichts fehlt), und mit einem Bereich, der endet, bevor der Abschnitt in der Bildschirmmitte steht.
**Und dann hat die Prüfung etwas gefunden, das ich selbst gebaut hatte.** Eine Scroll-Zeitachse ist **umkehrbar**: ein Abschnitt außerhalb des Blicks steht wieder am Anfang der Kurve. Mit der ursprünglichen Kurve (`opacity: .35`) hieß das: alles unterhalb der Falz ist dauerhaft blass – und blasser Text ist kein Effekt, sondern schlechterer Kontrast. axe meldete prompt fünfzehn Verstöße. Der Versuch mit 0,85 half nicht: aus `#3d4d5c` wird bei 85 Prozent `#78838d`, also 3,86:1 statt 7,1:1.
**Auf dem Scrollweg wird deshalb keine Deckkraft animiert, nur Bewegung.** 20 px für „rise“, 10 px für „fade“, Deckkraft bleibt 1. Der Effekt trägt trotzdem, und der Text ist an jeder Stelle lesbar – auch wenn er gerade nicht zu sehen ist. Beim Laden (ohne Scroll-Zeitachse) bleiben die alten Kurven mit Deckkraft, weil sie dort einmalig laufen und nicht zurückspulen.
**Benutzt wird es jetzt auch:** alle drei Beitragsvorlagen bringen die Einblendung je Abschnitt mit, und der Nachbau von kant-it.de hat sie auf jeder Seite – im Hero gestaffelt, darunter je Abschnitt.
## ADR 051 – Vier Darstellungen für dieselbe Beitragsliste (13.09.2026)
Umut wollte auch für die Auflistung mehrere Formen. Dieselben Daten, dasselbe Markup – nur eine Klasse wechselt. Eine Darstellung kostet dadurch ein paar Regeln CSS und keine Zeile PHP.
- **Karten** – Raster mit Bild oben. Für eine Übersicht.
- **Liste** – Bild links, Titel und Auszug rechts. Für ein Archiv.
- **Kompakt** – nur Titel und Datum, durch Linien getrennt, ohne Bilder. Für eine Randspalte oder ein langes Verzeichnis.
- **Wechselseitig** – großes Bild, Text daneben, und bei jedem zweiten Beitrag die Seiten getauscht.
**Die erste Fassung von „Wechselseitig" war ein umgedrehter Flexkasten und sah auch so aus.** Umut hat es zu Recht hässlich genannt. Was gefehlt hat, war nicht die Umkehrung, sondern alles andere: das Bild sitzt jetzt in einer Karte mit Ecken und weichem Schatten in festem Verhältnis 16:10, damit die Reihen gleich hoch sind; der Text bekommt eine Nummer aus einem CSS-Zähler, eine begrenzte Zeilenlänge und einen sichtbaren Verweis; zwischen den Reihen liegt eine feine Linie. Beim Zeigen zieht das Bild auf und der Titel nimmt die Akzentfarbe – die Reihe selbst bleibt stehen, denn sie ist keine Karte.
**Zwei Details, die der Bau gelehrt hat:** die Karte *ist* das Listenelement, also zählt `:nth-child` auf ihr und nicht eine Ebene tiefer – die erste Fassung wechselte deshalb gar nicht. Und die Umkehrung läuft über `order` der beiden Spalten statt über `row-reverse`, weil sich damit auf schmalen Bildschirmen sauber wieder Bild-über-Text ergibt.
**Die Nummer kommt aus einem Zähler, nicht aus dem Inhalt.** Auf Seite zwei beginnt sie wieder bei 01. Das ist ehrlich: der Beitrag weiß nicht, der wievielte er ist, und eine Nummer, die das behauptet, wäre erfunden.
## ADR 050 – Drei Aufbauten für einen Beitrag (13.09.2026)
Umut: „ich hätte gerne mehrere Designs zur Auswahl, 2–3 Stück für Beiträge.“
**Warum das bei Beiträgen sinnvoll ist und bei Seiten nicht:** bei einer Seite weiß vorher niemand, was daraus wird – ein leeres Dokument ist dort die ehrliche Voreinstellung. Ein Beitrag hat dagegen fast immer dieselbe Form: Kopf, Text, Bilder, ein Weg weiter. Wer jedes Mal von vorn anfängt, baut jedes Mal etwas leicht anderes, und nach zwanzig Beiträgen sieht ein Archiv aus wie zwanzig Websites.
**Die drei:** *Bildkopf* (Bild über die volle Breite, Titel darauf – für Projekte), *Klassisch* (Titel und Vorspann schmal, Bild darunter – für lange Texte), *Magazin* (Titel neben den Eckdaten – wenn Kunde, Leistung und Jahr zählen). Gewählt wird beim Anlegen, im selben Formular wie Titel und Adresse.
**Es sind Startpunkte, keine Schablonen.** Jeder Block lässt sich danach verschieben, ändern und löschen wie jeder andere. Festgelegt wird nur die erste Entscheidung – und die ist die, die am schwersten nachzuholen ist.
**Kein Aufbau verlässt sich auf ein Bild, das es noch nicht gibt.** Wo ein Beitragsbild hingehört, steht ein Platzhalter; der dunkle Kopf ohne Bild sieht schlicht aus statt kaputt. Ein Aufbau, der erst nach dem ersten Upload funktioniert, wäre beim Anlegen wertlos.
**Und die Regel, die dabei gefallen ist:** der erste Textblock eines Abschnitts wurde automatisch zur Kleinbeschriftung in Akzentfarbe. Das gestaltet nach **Position statt nach Absicht** – im Fußbereich wurde daraus roter Versalientext für den Firmennamen, in der Magazin-Vorlage aus dem Einstiegsabsatz eine Kleinbeschriftung. Beides sah aus wie ein Fehler, weil es einer war. Wer eine Beschriftung will, stellt sie ein; dafür gibt es den Designbereich. Die Demoseite verliert dadurch ihre Versalienzeilen und wirkt ruhiger – dasselbe Ergebnis wie beim Rahmen der Spalten.
## ADR 049 – Die Beitragsseite, und warum das kein Plugin wurde (Dokumentschema v18, 13.09.2026)
Umut: „Beitragsseite gefällt mir nicht, sollte wie bei kant-it.de sein oder besser. Falls nötig Plugin bauen.“
Nachgesehen: die Originalseite besteht aus Kopfbild, Text, **Bildergalerie**, **Vor/Zurück zwischen Projekten** und einem **Abschlussaufruf**. Galerie und Aufruf gab es als Bausteine längst – sie waren nur nicht benutzt. Die Navigation zwischen Beiträgen gab es nicht.
**Warum das kein Plugin geworden ist.** Ein Plugin ist in diesem CMS ein Bausteintyp mit Namensraum, dessen Eigenschaften der Kern *aufbewahrt, aber nicht rendert* – genau damit eine abgeschaltete Erweiterung keine Seite unlesbar macht. Ein Plugin kann also nichts ausgeben, solange es keine Laufzeit dafür gibt (Arbeitspaket H). Und die Nachbarn eines Beitrags sind keine Fremdfunktion: sie stehen in derselben Tabelle wie die Beitragsliste, die der Kern schon füllt. Ein Plugin dafür wäre eine Hülle um eine Kernabfrage gewesen. Also ein Baustein: `postnav`, Dokumentschema v18.
**Die Regel der Beitragsliste gilt auch hier: der gespeicherte Inhalt kennt keinen Beitrag.** Gespeichert sind zwei Beschriftungen, mehr nicht. Welcher Beitrag vorher und nachher kommt, entscheidet der Renderer beim Ausliefern – sonst könnte ein altes Dokument auf einen Beitrag zeigen, den es nicht mehr gibt oder der nie veröffentlicht wurde.
**Gesucht wird innerhalb derselben Kategorie:** von einem Projekt führt man zum nächsten Projekt, nicht zur nächsten Pressemitteilung. **Am Rand wird nicht umgebrochen** – der neueste Beitrag hat keinen Vorgänger. Ein Ring wäre bequem zu programmieren und eine Falle für den Leser, der glaubt, er sei am Anfang.
**Dabei zwei Dinge gelernt:** `present()` gibt für eine Liste bewusst keine Kennung heraus, nur Titel und Adresse – die Nachbarsuche vergleicht deshalb über die Adresse. Und die Abfrage kostet etwas, also läuft sie nur, wenn die Seite den Baustein wirklich enthält.
**Was ich zusätzlich geändert habe, ohne gefragt zu werden:** die Absätze der übernommenen Projekttexte begannen mit dem wiederholten Titel und der Zwischenüberschrift, weil beides im selben Absatz stand – 20 Beiträge betroffen. Und schmale Abschnitte standen am linken Rand statt in der Mitte: `max-width` ohne `margin:auto` ist eine halbe Einstellung.
## ADR 047 – Ein Slider, der von selbst weiterblättert (13.09.2026)
Umut wollte die Autorotation. Die Regel „öffentliche Seiten laufen ohne JavaScript“ steht seit dem ersten Tag, und sie bleibt – **mit einer Ausnahme, die es schon gab**: die Tabs laden ein kleines Skript, aber nur auf Seiten, die Tabs enthalten. Der Slider folgt genau diesem Muster.
**Die Einstellung sind Sekunden, kein Häkchen.** 0 heißt aus, unter drei Sekunden wäre es keine Hilfe mehr, sondern Hektik. Die Zahl steht im Baustein, nicht im Skript.
**Was das Skript nicht tut, ist der interessantere Teil:**
- Es läuft **gar nicht**, wenn das System weniger Bewegung verlangt.
- Es hört **endgültig** auf, sobald jemand tippt, klickt, wischt oder von Hand scrollt. Nicht „pausiert kurz“ – aus. Wer selbst blättert, hat entschieden.
- Es pausiert, solange der Zeiger darin ist, solange etwas darin den Fokus hat, solange der Tab im Hintergrund liegt und solange der Slider nicht zu sehen ist.
**Ohne das Skript bleibt alles benutzbar**: wischen, scrollen, Tastatur, Punkte. Es kommt nur eine Bequemlichkeit dazu, es hängt nichts davon ab.
**Und ein Fehler beim Bauen, der zeigt, warum man so etwas nachmisst:** die erste Fassung rechnete das Sprungziel aus `offsetLeft` und zog zusätzlich den Offset des Sliders ab – bei einem Slider, der selbst der Bezugsrahmen ist, kam dabei −78 heraus, also gar keine Bewegung. Gemessen wird jetzt über die Bildschirmrechtecke. Der Test dazu ist kein Schnappschuss, sondern ein Verlauf: 0 → 666 → 1332 → 1998 in drei Schritten, und nach einem Mausrad-Ereignis bleibt der Wert stehen.
## ADR 048 – Eine Beitragsliste darf blättern (13.09.2026)
Umut: „fehlt die Pagination bei Projekte? Es werden nur 12 angezeigt.“ Richtig, und es widersprach der eigenen Regel aus Arbeitspaket M: *eine feste Obergrenze ist kein Blättern, sie verschweigt, dass etwas fehlt.* Für die Verwaltung war das behoben, für die Beitragsliste einer Seite nicht.
Der Unterschied ist absichtlich und heißt jetzt `paginate`: eine **Auswahl** auf der Startseite zeigt die neuesten vier und hört auf – dort will niemand blättern, dort will man weiter zur Übersicht. Eine **Übersichtsseite** blättert und sagt dabei, wie viele es insgesamt sind.
Zwei Kleinigkeiten, die dabei wichtig waren: eine blätternde Liste nimmt die Seitenzahl aus der Anfrage, eine Auswahl immer die erste – sonst würden zwei Listen auf derselben Seite einander umblättern. Und eine Seitenzahl jenseits des Endes landet auf der letzten Seite statt im Leeren.
**Beim Bauen zum zweiten Mal dieselbe Falle:** die Seitenzahl war in der Schließung, die die Blöcke durchläuft, nicht gebunden – genau wie vorgestern beim Filter für Überschriften. Diesmal fiel es sofort auf, weil die Seite mit 500 antwortete statt still das Falsche zu tun.
## ADR 046 – Der eigene Blick: was ich vor einem Livegang ändern würde (13.09.2026)
Umut: „schau selbst drüber, ohne zu vergleichen – würdest du so online gehen?“ Antwort: **so nicht.** Fünf Dinge sind mir aufgefallen, und drei davon waren Fehler im CMS, nicht im Inhalt.
**1. Verborgener Text stand sichtbar auf der Seite.** `.visually-hidden` gibt es in `admin.css`, aber **nicht in `site.css`** – auf jeder öffentlichen Seite waren damit die Zusätze zu lesen, die nur für Vorlesesoftware gedacht sind: „Bewertung: 5 von 5“ neben den Sternen, „(Pflichtfeld)“ hinter jedem Formularfeld. Das ist der peinlichste Fund der Reihe, weil er seit dem ersten Formular dasteht und in keinem Test auffiel: axe prüft, ob etwas **angesagt** wird, nicht ob es fälschlich **sichtbar** ist.
**2. Jede Spalte war ein Kasten.** `.block-column` brachte Rahmen, Ecken und 30 px Innenabstand mit. Eine Spalte ist aber ein Layoutmittel – wer damit zwei Textblöcke nebeneinander stellt, bekam zwei umrandete Kästchen und fragt sich, warum seine Seite aussieht wie ein Formular. Die Karte ist jetzt das, was sie sein sollte: eine Einstellung, kein Standard. Die Demoseite sieht dadurch ruhiger aus, nicht ärmer.
**3. Der Knopf im Aufruf-Band war unsichtbar.** Er nahm die Tintenfarbe – auf einem dunklen Band also Tinte auf Tinte, ausgerechnet beim Handlungsaufruf. Aufruf-Band und Formular nehmen jetzt die Akzentfarbe.
**4. Gestapelte Karten klebten aneinander.** In einer Zeile sorgt das Raster für Abstand, untereinander gab es keinen. Aufeinanderfolgende Karten bekommen 18 px.
**5. Und mein eigener Inhalt:** die linke Spalte im Kontaktbereich war zur Hälfte leer, weil sie sich auf die Höhe des Formulars zog. Sie steht jetzt oben und trägt drei Karten, die die Frage beantworten, die man an dieser Stelle hat: was passiert, wenn ich abschicke?
**Was ich bewusst gelassen habe:** die Einwilligung liegt weiterhin als Kasten mitten auf der Seite. Das ist richtig so – sie ist eine Entscheidung, keine Verzierung –, aber sie verdeckt auf jedem Bildschirmfoto den Inhalt dahinter. Wer die Seite abnimmt, sollte sie einmal wegklicken, bevor er urteilt.
## ADR 045 – Der Zeigereffekt, und warum Feinschliff kein Zusatz ist (13.09.2026)
Umut: „es gibt keinen Mausover-Effekt wenn man auf einen Beitrag geht, das fehlt … grob siehts ähnlich aus, aber im Detail fehlen viele Kleinigkeiten.“ Das ist die richtige Beobachtung: eine Karte, die sich beim Zeigen nicht rührt, wirkt kaputt, und zwar bevor jemand sagen kann, warum.
**Die Beitragskarte reagiert jetzt**, und zwar wie im Original nachgemessen: die Karte hebt sich um 8 px, bekommt einen weichen Schatten, das Bild zieht auf 1,06 auf, und über dem Bild blendet ein Hinweis ein. Dauer und Kurven sind aus dem Original übernommen (0,28 s für die Karte, 0,5 s für das Bild, 0,3 s für den Hinweis).
**Drei Entscheidungen dabei:**
- **Die Bewegung steht vollständig hinter der Bewegungsschranke.** Wer im Betriebssystem weniger Bewegung eingestellt hat, bekommt keine sanftere Fahrt, sondern keine – aber den Hinweis über dem Bild trotzdem, denn der ist Information und keine Verzierung. Ein Test prüft genau diese Trennung: kein `translateY` außerhalb der Schranke.
- **Auch mit der Tastatur.** Alles hängt zusätzlich an `:focus-within`, sonst wäre der Effekt eine Belohnung für Mausbenutzer.
- **Der Hinweis ist für Vorlesesoftware ausgeblendet.** Er wiederholt nur, wohin der Titel darunter ohnehin führt; zweimal dasselbe Ziel anzusagen hilft niemandem.
**Der Hero war das falsche Bild.** Im Original ist er kein Foto, sondern Marineblau mit einer Binärtextur bei 10 Prozent und einem roten Schein oben rechts. Nachgebaut mit den Mitteln, die der Designer hat: dasselbe Bild als Textur, die Überlagerung auf 93 Prozent, und ein Verlauf schräg darüber für den Schein. Die Augenbraue ist im Original eine **Pille** mit Rahmen, kein nackter Text – auch das ließ sich ohne neue Funktion bauen.
**Und die Effekte, die es längst gibt, waren schlicht nicht benutzt.** `layout.effects` kennt Anheben, Zoom, Schatten und Einblenden seit Dokumentschema v3; im Nachbau standen sie auf „nichts“. Jetzt heben sich Leistungskarten und Lösungskarten beim Zeigen. Das ist keine Änderung am CMS, sondern die Antwort auf die Frage, warum es „im Detail“ fehlte: ein Werkzeug, das man nicht in die Hand nimmt, ist so gut wie nicht vorhanden.
**Nebenbei entfernt:** der Leuchteffekt (`fx-hover-glow`) trug noch das Grün der Vorlage statt der Akzentfarbe der Website.
**Nachgetragen am 13.09.2026:** Umut meinte den Kopf **beim Öffnen eines Beitrags**, nicht den der Startseite. Im Original füllt dort das Projektbild den ganzen Kopf (75 vh), darüber liegt ein Verlauf von fast durchsichtig oben nach fast deckend unten, und darin stehen ein Badge in der Akzentfarbe, der Titel und eine 60 × 4 Pixel kurze Linie. Nachgebaut mit den vorhandenen Mitteln – Hintergrundbild, Verlauf, Flexausrichtung –, der Text mittig gesetzt. Dabei fiel auf, dass die Zeile mit Datum, Autor und Kategorie oberhalb davon stand: bei einem Beitrag, der seinen Titel im Bild trägt, ist sie doppelt. Sie ist jetzt abschaltbar (`postMetaHeader`), Voreinstellung bleibt an.
## ADR 044 – Genauer hinsehen: Punkte statt Rollleiste, Karten statt Zeilen (13.09.2026)
Umut hat den Nachbau Detail für Detail mit dem Original verglichen, und er hatte in jedem Punkt recht. Was dabei herauskam, sind nicht Geschmacksfragen, sondern vier Stellen, an denen das CMS etwas nicht konnte oder etwas Falsches tat.
**Ein Slider ist kein Schiebefeld.** Der Slider war ein Scrollbereich mit sichtbarer Rollleiste – im Hero sah das aus wie ein Versehen. Das Original hat Punkte. Punkte gibt es jetzt, und zwar **ohne Skript**: `scroll-marker-group` und `::scroll-marker` lassen den Browser je Folie einen Punkt zeichnen, der aktive ist über `:target-current` erkennbar, und ein Klick scrollt. Wo ein Browser das noch nicht kennt (heute Firefox und Safari), bleibt ein wisch- und tastaturbedienbares Schiebefeld – also kein Ausfall, nur weniger Komfort. Die Rollleiste ist dort ausgeblendet, wo Punkte die Orientierung übernehmen.
**Pfeile gibt es bewusst nicht.** `::scroll-button()` existiert, aber Chrome legt die Knöpfe weder absolut noch klebend in den Slider, sondern an den Rand der Seite. Lieber keine Pfeile als ein Knopf, der am Seitenrand schwebt.
**Was der Slider nicht kann, steht hier:** er rotiert nicht von selbst. Eine öffentliche Seite lädt kein JavaScript, und eine CSS-Animation, die sich nicht anhalten lässt, wäre ein Barrierefreiheitsproblem statt einer Lösung.
**Eine Beitragskarte war keine Karte.** Sie war eine Spalte aus Bild, Überschrift und Text ohne Rahmen. Im Original ist sie eine Karte: weiß, 18 px Ecken, Bild bündig oben im Verhältnis 16:10, Text in einem eigenen Feld darunter. Genau so sieht sie jetzt aus – und zwar für jede Website, weil es die Darstellung des Bausteins ist und keine Einstellung dieser einen Seite.
**Die Schriftgrößen der Bausteine verloren gegen die Theme-Skala.** `.kant h3` aus dem Theme und `.block-iconcard h3` aus `site.css` hatten dieselbe Spezifität, und der Themeblock steht später im Dokument – also gewann er. Jede Kartenüberschrift war so groß wie eine Abschnittsüberschrift, auf jeder Website mit eigener Schriftskala. Die Bausteinregeln tragen jetzt `.kant` davor und gewinnen wieder.
**Eine Überschrift darf ein Wort hervorheben.** Im Original ist die zweite Zeile der Hauptüberschrift rot. Dafür dürfen Überschriften ab v17 Auszeichnungen tragen – durch denselben Filter wie jeder Text. **Dabei wäre beinahe eine Lücke entstanden:** die Fassung des Dokuments war in der Schliessung, die den Filter läuft, gar nicht vorhanden; der Filter lief nie, und der Inhalt wird unescaped ausgegeben. Der Test, der ein `<script>` in eine Überschrift schreibt, hat es sofort gezeigt – und er bleibt genau dafür stehen. Zusätzlich entscheidet die Ausgabe jetzt an einer eigenen Fahne (`richHeadings`), damit eine ältere Seite ihre Überschrift weiterhin wörtlich zeigt.
## ADR 043 – Was beim Nachbauen noch fehlte (Dokumentschema v17, 13.09.2026)
Umut hat den Nachbau angesehen und vier Dinge benannt: der Abstand zwischen den Lösungskarten und der Projektliste fehlt, die Karten sind zu hoch, die Beiträge sehen nicht aus wie im Original, und später: im Hero fehlen die letzten Projekte als Slider, der Kopfbereich klebt nicht beim Scrollen, das Logo verlinkt nicht auf die Startseite. Zwei davon waren mein Aufbau, vier waren fehlende Fähigkeiten.
**Eine Beitragsliste kann jetzt ein Projektraster sein.** Im Original zeigt eine Projektkarte Bild und Titel. Die Beitragsliste zeigte immer zusätzlich Datum, Autor, Auszug und Kategorie – richtig für ein Blogarchiv, falsch für eine Projektseite, und nicht abstellbar. Drei Schalter (`showMeta`, `showExcerpt`, `showTerms`) beantworten das, mit den bisherigen Werten als Voreinstellung, damit bestehende Seiten sich nicht ändern.
**Dabei wäre es beinahe schiefgegangen, und zwar lautlos:** `{{ block.props.showMeta|default(true) }}` gibt in Twig auch dann `true` zurück, wenn der Wert `false` ist – der `default`-Filter greift auf jeden leeren Wert, nicht nur auf einen fehlenden. Die Einstellung hätte sich speichern lassen und nichts getan. Mit `??` statt `|default()` bleibt `false` ein `false`. Der Test prüft beide Richtungen, weil nur die Gegenprobe diesen Fehler findet.
**Ein Bild darf verlinken.** Klingt selbstverständlich und fehlte: ein Logo im Kopfbereich ohne Verweis auf die Startseite ist der erste Klick, den jeder sucht. Die Adresse geht durch dieselbe Prüfung wie jeder andere Link, also kein `javascript:`, kein `data:`, kein schemaloses `//fremd.test`.
**Der Kopfbereich kann beim Scrollen oben bleiben.** Das ist bewusst eine Einstellung der Website und keine Eigenschaft eines Blocks: kleben kann nur das Element, das den Bereich umschließt (`.site-header`), und ein Abschnitt *innerhalb* davon würde nur solange kleben, wie sein Elternelement sichtbar ist – also gar nicht. Wer es im Designer an einem Abschnitt versucht hätte, hätte eine Einstellung gefunden, die nichts tut.
**Der Slider im Hero** brauchte nichts Neues: vier Folien mit Bild und Titel im vorhandenen Slider-Baustein. Ehrlich dazugesagt: **er rotiert nicht von selbst.** Öffentliche Seiten laufen ohne JavaScript, und daran ändert eine Animation nichts – gewischt, gescrollt und mit der Tastatur bedient wird er, und er rastet je Folie ein. Eine automatische Rotation wäre als reine CSS-Animation möglich, aber sie wäre nicht anhaltbar, und ein Karussell, das sich nicht anhalten lässt, ist ein Barrierefreiheitsproblem.
**Und ein Fehler, der größer ist als alles andere in diesem Paket:** die Liste bekannter Schemafassungen im Editor endete bei **12**, während Bausteine längst v13 bis v15 verlangten. Folge: **in einem aktuellen Dokument ließ sich im Editor gar kein Baustein mehr einfügen** – jeder Versuch endete mit „Unbekanntes Dokumentschema“ – und Symbol, Fortschritt, Slider und eingebetteter Inhalt nirgends. Der Playwright-Editortest hat es nicht gefunden, weil er auf einem alten Dokument arbeitet und die betroffenen Typen nicht einfügt. Jetzt steht die höchste Fassung als **eine Zahl an einer Stelle** (`SCHEMA_VERSION`), und ein Test fügt jeden Bausteintyp in Dokumente von v1 bis v17 ein.
## ADR 042 – Der Nachbau von kant-it.de, und was er gefunden hat (12.09.2026)
Umut: „Baue mal lokal kant-it.de komplett nach. Ich möchte sehen ob das funktioniert.“ Gebaut ist die Startseite mit allen neun Abschnitten, die Projekteseite, vier Projektbeiträge, Impressum und Datenschutzgerüst, Kopf- und Fußbereich, zwei Menüs, das Kontaktformular, zwölf Bilder und die Palette der echten Website – in einer eigenen Datenbank, damit die Entwicklungsinstallation unberührt bleibt.
**Das Ergebnis der Übung sind nicht die Bilder, sondern fünf Fehler, die sonst ein Kunde gefunden hätte.** Eine echte Website zu bauen ist eine andere Prüfung als ein Test: sie benutzt alles gleichzeitig.
**1. Die Gestaltung färbte die Bausteine nicht.** Symbole, Knöpfe, Rahmen, ruhiger Text und der Einwilligungsdialog kamen aus festen Token in `site.css` – eine Website mit roter Palette behielt das Grün der Vorlage. Die Farben ließen sich einstellen und taten nichts, dieselbe Familie wie Rahmenstärke und Bildrundung (ADR 036). Das Theme setzt jetzt `--ink`, `--paper`, `--accent`, `--muted`, `--line` und `--mint` selbst. Als Akzent dient die **Linkfarbe**, nicht ein Palettenname: dieses Token färbt auch kleine Beschriftungen, und eine helle Akzentfarbe wäre dort nicht mehr lesbar. `--muted` und `--line` werden aus Textton und Hintergrund gemischt; eine Palettenfarbe gleichen Namens gewinnt.
**2. Der Einwilligungsdialog war grün**, unabhängig von der Website. Er nimmt jetzt die Primärfarbe – und lässt sich eigens färben, indem im Design eine Farbe mit dem Schlüssel `consent` angelegt wird. Kein zusätzlicher Schalter, nur eine Regel, die eine Palettenfarbe bevorzugt.
**3. „Di-gitale Exzellenz“.** `hyphens:auto` galt für h1 bis h3. Im Fließtext ist Trennung nützlich, in einer 64-Pixel-Überschrift sieht sie nach einem Defekt aus – und genau dort fällt sie auf. Überschriften trennen nicht mehr.
**4. Ruhiger Text auf dunklem Grund.** `.block-text` hatte fest `color:var(--muted)`, eine Farbe für helle Hintergründe. In einem dunkel gefärbten Abschnitt stand damit Grau auf Marineblau. Jetzt wird die Farbe aus der geerbten gemischt (`currentColor` zu 82 Prozent), womit dieselbe Regel auf hellem und dunklem Grund funktioniert. Dasselbe gilt für die automatische Kleinbeschriftung des ersten Textblocks: sie mischt den Akzent in die geerbte Farbe, statt ihn zu setzen.
**5. Ein Kontrastfehler, den ich selbst eingebaut hatte.** Die erste Ableitung von `--muted` mischte zu 72 Prozent und ergab für eine Palette mit hellem Textton 4,09:1 statt 4,5:1. axe hat ihn im selben Durchlauf gefunden, in dem die Farben zum ersten Mal wirkten. Jetzt 80 Prozent.
**Was sich nicht als Fehler herausstellte, aber auffällt:** Ecken und Seiten müssen einzeln angegeben werden (`borderRadius` als Kurzschreibweise wird abgewiesen) – der Designer schreibt das ohnehin so, von Hand ist es überraschend. Und die Beschriftung eines Einwilligungsfeldes ist auf 120 Zeichen begrenzt; der Einwilligungssatz der echten Website hat 148. Der Rest musste in den Hilfetext.
**Was der Nachbau belegt:** die Seite läuft mit **null axe-Verstößen** auf vier Seiten in Desktop- und Handybreite, **ohne JavaScript**, mit Bildern als WebP in vier Breiten und einer Startseite, die dem Original so nahe kommt, dass der Unterschied in der Typografie liegt und nicht in der Technik. Was fehlt, ist ehrlich zu benennen: die vollständige Datenschutzerklärung (das Gerüst ist da, der Text der laufenden Website ist länger), die übrigen Projektbeiträge (vier von fünf Seiten Archiv) und die Unterseiten von HausKlar und KantTresor.
## ADR 041 – Der Installer: sechs Bildschirme, und danach ist er weg (12.09.2026)
Umut: „Einen Installer brauche ich noch wie wordpress schritt für schritt alles einrichten migration ausführen usw.“ und, später, „einen installer das eine php ist und alle dateien herunterladet“. Beides ist gebaut, und beides hängt an derselben Frage: was darf eine Datei dürfen, die auf einem leeren Webspace liegt und von jedem aufrufbar ist?
**Der Ablauf schreibt jeden Schritt sofort**, statt Antworten in einer Sitzung zu sammeln. Nach dem Datenbankschritt gibt es `.env.local`, nach dem Migrationsschritt Tabellen, nach dem Websiteschritt Inhalte. Ein Neuladen, ein Zeitüberlauf, ein geschlossener Tab verliert damit nichts – der Installer sieht, was schon da ist, und macht an der nächsten fehlenden Stelle weiter. Wer eine spätere Adresse rät, wird zurückgeschickt, und zwar vom Server, nicht von einem Link.
**Das Ende ist das erste Konto, und es ist ein Zustand der Datenbank, keine Datei.** Eine Datei, die den Installer sperrt, ist eine Datei, die jemand löschen kann – und dann steht auf einer laufenden Website wieder ein Formular, das ein Administratorkonto anlegt. Gibt es ein Konto, antwortet jede Adresse unter `/install` mit 404.
**Kein Sitzungstoken, sondern eine Unterschrift.** Das Sitzungscookie dieser Anwendung gilt nur für `/admin` – eine bewusste Entscheidung, die öffentliche Seiten cachebar hält –, und für sechs Bildschirme wird sie nicht aufgeweicht. Jedes Formular trägt Schritt und Zeitpunkt, mit `APP_SECRET` unterschrieben, zwei Stunden gültig. Ehrlich dazugesagt: das ist kein CSRF-Schutz, denn wer den Installer erreicht, bekommt auch eine gültige Unterschrift. Der Schutz ist, dass es den Installer nach dem ersten Konto nicht mehr gibt – deshalb steht auf dem letzten Bildschirm, dass sofort nach dem Hochladen installiert werden soll.
**Migrationen laufen im Prozess, nicht über die Konsole.** Auf geteiltem Hosting gibt es oft keine Shell, und `proc_open` ist ein häufiges Opfer einer gehärteten `php.ini`. Der Installer benutzt dieselbe `migrations.php` und dieselbe `DependencyFactory` wie das Konsolenkommando. Bricht der Browser ab, ist nichts kaputt: angewandte Migrationen sind vermerkt, ein erneuter Aufruf macht weiter.
**Ein Huhn-Ei-Problem und seine Lösung:** ohne `APP_SECRET` bootet Symfony nicht – also nicht einmal die Seite, die erklären würde, warum. Deshalb erzeugt der erste Start das Geheimnis selbst und schreibt es nach `.env.local`. Geht das nicht, läuft die Anfrage trotzdem, und die Systemprüfung sagt in einem Satz, dass im Projektstamm das Schreibrecht fehlt. Eine weiße Seite wäre die Alternative gewesen.
**Und eine Weiterleitung statt eines Fehlers:** solange keine Installation existiert, führt jede Adresse zu `/install`. Der erste Besuch auf einem frisch entpackten Webspace ist sonst ein 500 aus einer fehlenden Tabelle – genau der Moment, in dem jemand einen Satz braucht und keinen Stacktrace. Sobald ein Konto existiert, wird eine Marke unter `var/installed` geschrieben und keine Anfrage fragt die Datenbank noch einmal; die Marke schweigt nur diesen Subscriber, der Installer selbst fragt immer die Datenbank.
**Die einzelne Datei davor** (`install.php`) lädt das Releasearchiv, prüft dessen SHA-256, entpackt es und löscht sich danach selbst. Adresse und Prüfsumme stehen **in der Datei**, eingetragen beim Bauen des Releases: ein Installer, der eine URL aus einem Formular entgegennimmt, ist eine Einladung, fremden Code auf den Server zu legen. Stimmt die Prüfsumme nicht, wird nicht entpackt – es gibt kein Häkchen daran vorbei. Jeder Archiveintrag wird gegen das Zielverzeichnis geprüft, bevor er geschrieben wird; geprüft wird nicht durch Suchen nach „..“, sondern indem der zusammengesetzte Zielpfad unterhalb des Ziels liegen muss. In ein Verzeichnis, in dem schon eine Installation liegt, arbeitet sie gar nicht erst.
**Was der Installer anlegt, ist nicht die Demoseite.** Eine Startseite mit dem Namen der Website, Kopf- und Fußbereich mit Menü – und Impressum und Datenschutzerklärung als Gerüst mit markierten Lücken. Das Letzte ist kein Entgegenkommen: eine deutsche Unternehmenswebsite ohne beides ist ab dem ersten Tag ein Problem, und die Installation ist der einzige Moment, in dem mit Sicherheit jemand hinsieht.
## ADR 040 – Bildgrößen, und ein Release für Hosting ohne Composer (12.09.2026)
Zwei Fragen von Umut in einem Satz: „werden Bilder in verschiedenen Größen angelegt und in WebP umgewandelt?“ und „auf einem Hostingpaket ist kein Composer, meistens, oder?“ – die erste war zur Hälfte schon beantwortet, die zweite ist berechtigt und hatte bis heute keine Antwort.
**Die Bilder: ja, und jetzt eine Stufe weiter.** Ein Upload wird nie im Original gespeichert. GD decodiert ihn, dreht ihn nach EXIF gerade, behandelt Transparenz und schreibt WebP mit Qualität 82 – bisher in drei Breiten, jetzt in **vier: 480, 960, 1600, 2400**. Die vierte Stufe ist keine Kosmetik: ein vollflächiges Startseitenbild auf einem zweifach skalierten Bildschirm wurde vorher aus 1600 px hochgezogen und sah entsprechend aus. Hochskaliert wird dabei nichts – ist die Quelle 1800 px breit, ist die letzte Stufe 1800 px, nicht 2400.
Der Bildbaustein liefert `srcset` und `sizes`, der Browser wählt. **Ein Hintergrundbild kann das nicht**, denn CSS hat kein `srcset` – und genau da lag bisher die Lücke: ein Sektionshintergrund bekam immer die größte Variante, auch auf dem Telefon. Da die Gestaltung ohnehin je Gerät geschrieben wird, entscheidet nun das Gerät: Mobil 960, Tablet 1600, Desktop die größte vorhandene. Das ist die richtige Stelle dafür, weil die Regel ohnehin in einem `@media`-Block steht – es kostet kein Byte zusätzlich.
Der Preis, ehrlich benannt: **vier Stufen kosten Rechenzeit und Platz beim Upload.** Deshalb bleibt die konservative Prüfung gegen das Speicherlimit vor dem Decodieren stehen; sie ist es, die ein Hosting mit 128 MB vor einem Abbruch mitten im Schreiben bewahrt. Ein Test hat das sofort gezeigt, als er im Rahmen der gesamten Suite lief und der Speicher schon halb belegt war – das war kein Testproblem, sondern die Schutzfunktion bei der Arbeit.
**Composer: Umut hat recht, und das ändert den Auslieferweg.** Geteiltes Hosting hat in aller Regel keinen Composer, oft nicht einmal eine Shell. Die Frage stand seit `docs/UPDATE.md` offen („Composer auf dem Server oder `vendor/` im Tag?“). Sie ist jetzt entschieden: **es gibt ein Releasearchiv.** `scripts/build-release.php` läuft auf einem Rechner, der Composer hat, baut `vendor/` ohne Entwicklungspakete in eine Kopie des Projekts und schreibt `var/releases/kantsites-<Fassung>.zip` samt SHA-256. Im Archiv liegt die Anwendung und sonst nichts: keine Tests, keine Skripte, kein `var/`, kein `.env`. Das Git-Repository bleibt dabei frei von `vendor/` – wer entwickelt, bekommt keine 3000 fremden Dateien in den Diff.
Daraus folgt eine Regel für den Updater, und sie ist wichtiger als das Archiv selbst: **ein Update, das `composer.lock` ändert, bricht ab, wenn Composer auf dem Server nicht aufrufbar ist.** Vorher hätte er den Code gewechselt und wäre mit einem `vendor/` weitergelaufen, das nicht mehr zum Code passt – der Fehler wäre erst beim ersten Seitenaufruf aufgetaucht, nach dem Rauchtest, als weiße Seite. Jetzt vergleicht der Updater die Prüfsumme der Datei vor und nach dem Checkout und sagt in einem Satz, was zu tun ist: das Releasearchiv einspielen, das `vendor/` mitbringt. Danach läuft der normale Rückweg.
**Und ein Fehler, den erst der Browsertest zeigte:** der Dokumentprüfer erlaubte drei `sources` je Bild. Die vierte Variante machte damit **jedes Dokument mit einem Bild ungültig** – ohne sichtbare Meldung, denn eine abgewiesene Speicherung sieht aus wie nichts. Das ist dieselbe Familie wie Rahmenstärke und Bildrundung: eine Zahl an einer Stelle, die niemand mit der Stelle verbindet, an der sie herkommt. Die Grenze liest jetzt `MediaLibrary::WIDTHS`, damit sie sich mit der Pipeline bewegt.
**Nebenbei gefunden:** `CommandRunner` konnte unter Windows `composer` nicht starten, weil das dort eine `.bat` ist und `CreateProcess` keine Stapeldatei ausführt. Ein Kandidat über `%COMSPEC% /c` behebt es – die Argumentliste bleibt fest, es kommt nichts aus einer Anfrage hinein.
## 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.
**Die Datenbank wird nicht kopiert, sondern von SQLite ausgeschrieben.** Ein `copy()` der Datei während eines Schreibvorgangs ergibt eine Datei, die vorhanden aussieht und sich am Tag der Wiederherstellung nicht öffnen lässt. `VACUUM INTO` liefert einen vollständigen, konsistenten Stand – ohne Wartungszustand, weil SQLite das selbst kann.
**Der Medienordner gehört dazu, `.env` nicht.** Ohne die Bilddateien stellt die Sicherung eine Website voller kaputter Bilder wieder her; die Dokumente verweisen nur auf IDs. `.env` dagegen enthält `APP_SECRET`, und eine Sicherung ist etwas, das heruntergeladen und weitergereicht wird. Sie wird zum Wiederherstellen von Inhalten auch nicht gebraucht. Im Manifest steht ausdrücklich, was **nicht** drin ist – damit niemand die Sicherung für vollständiger hält, als sie ist.
**Jede Sicherung trägt ein Manifest** mit Zeitpunkt, Anlass, Fassung, Migrationsstand, Inhaltszahlen und der SHA-256 der Datenbank. Die Übersicht zeigt „unversehrt“ oder „Prüfsumme falsch“, und eine Sicherung mit falscher Prüfsumme wird nicht eingespielt. Eine Sicherung, von der man erst beim Zurückspielen erfährt, dass sie kaputt ist, ist schlimmer als keine.
**Beim Zurückspielen:**
- Eine Sicherung aus einer **neueren** Fassung wird abgewiesen – ihr Schema kennt der laufende Code nicht. Mit `--force` geht es trotzdem, weil es Fälle gibt, in denen genau das gewollt ist.
- **Zuerst wird der aktuelle Stand gesichert.** Auch eine Wiederherstellung kann die falsche sein.
- **Medien werden ergänzt, nicht ersetzt.** Eine Datei, die nach der Sicherung hochgeladen wurde, bleibt liegen: die Maßnahme gegen Datenverlust darf nicht selbst welchen verursachen.
- Der Wartungszustand ist an, solange die Datenbank ersetzt wird.
**Zurückspielen gibt es nur auf der Konsole.** In der Verwaltung steht die Liste mit Zustand und ein Knopf zum Anlegen – ein Knopf, der die ganze Datenbank ersetzt, gehört nicht neben einen, der eine Seite speichert.
Ein kleiner Fall, der beim Testen auffiel: zwei Sicherungen in derselben Sekunde. Das ist nicht akademisch – eine Wiederherstellung legt eine Sicherheitskopie an, und die landet in derselben Sekunde wie die Sicherung, die gleich eingespielt wird. Der Name bekommt deshalb bei Bedarf eine Nummer, statt dass die zweite Sicherung fehlschlägt.
## ADR 037 – Seitenbreite, Menüarten und Seiten ohne Kopfbereich (Dokumentschema v16, 12.09.2026)
Drei Fragen von Umut, drei unterschiedliche Antworten.
**Die globale Seitenbreite war einstellbar und wirkte nur in eine Richtung.** `base.containerWidth` gab es im Designbereich, aber `site.css` schrieb `width:min(1180px,…)` – jeder größere Wert wurde von der 1180 geschluckt. Schmaler ging, breiter nicht, und niemand hätte gesehen warum. Die Zahl kommt jetzt aus dem Theme (`min(var(--k-container,1180px),…)`). Das ist der dritte Fehler dieser Art an einem Tag: eine Einstellung, die sich speichern ließ und nichts tat. Alle drei hatten dieselbe Ursache – CSS, das nie gegen eine Erwartung geprüft wurde. Dagegen helfen die Datensatztests, die jetzt jede Deklaration nachrechnen.
**Menüplätze und Menüarten sind zwei verschiedene Dinge**, und das war vermischt. *Wo* ein Menü steht, bestimmt der Menüblock – er kann in jedem Bereich und jeder Sektion stehen, das war immer so. *Welche Einträge* es zeigt, bestimmt der Platz, und davon gab es nur zwei; jetzt sind es vier (Hauptnavigation, zweite Navigation, Fußzeile, Rechtliches). *Wie* es aussieht, war überhaupt nicht einstellbar; jetzt gibt es drei Arten: waagerecht, untereinander und **aufklappbar als Burger**. Der Burger ist ein `details`-Element – ein Menü, das ein Skript zum Öffnen braucht, wäre auf einer Seite ohne Skript ein Menü, das nicht aufgeht.
**„Seiten ohne Menü“ war nicht möglich**, und das ist der interessanteste Teil. Der Kopfbereich war ein einziges Dokument für alle Seiten. Jetzt wählt jede Seite: Standard, ein anderer Bereich, oder **keiner**. Dafür durfte die Regel „es gibt genau einen Kopf- und einen Fußbereich“ fallen – sie war richtig, solange niemand wählen konnte, und wurde falsch, sobald man wählen kann. Der erste Bereich behält die Adresse `default` und ist, was eine Seite ohne eigene Wahl bekommt.
Zwei Entscheidungen dabei:
- **Gespeichert wird die Wahl, nicht das Ergebnis.** `header_area` ist leer für „Standard“, `none` für „keiner“, sonst die Adresse. Ein Verweis auf einen gelöschten Bereich fällt auf den Standard zurück, statt die Seite stillschweigend ohne Kopf auszuliefern – eine fehlende Navigation ist schlimmer als eine unerwartete.
- **Die Migration ist nicht umkehrbar.** Ein `down()` würde den Standardkopf auf Landingpages zurückbringen, die absichtlich keinen haben.
## ADR 036 – Der Designer im Vergleich, und zwei Fehler, die er verdeckte (Dokumentschema v15, 12.09.2026)
Umut fragte nach Zeilen, nach Rahmen und Rundungen – und ob der Designer so aufgestellt ist wie Flatsome. Der Vergleich steht in `docs/DESIGNER-VERGLEICH.md`, samt der Einschränkung, dass ich Flatsome nicht selbst öffnen konnte. Was die Frage an Code auslöste:
**Zwei Fehler, die vorher niemand gesehen hat.** Eine Rahmenstärke je Seite wurde zu `border-width-top` – das ist kein CSS, der Browser verwirft die Zeile, und niemand bekommt eine Meldung. Rahmenstärken je Seite hatten damit **nie** eine Wirkung. Die Schreibweise steht jetzt als `sideCss` je Eigenschaft im Schema, weil `padding-top` dem Namen folgt und `border-top-width` nicht. Und eine Rundung auf einem Bildblock landete auf dem `figure`, während das Bild darin eckig blieb.
**Eigenschaften können jetzt auf das Bild im Block zielen.** Ein `target: 'img'` im Schema, und der Compiler schreibt eine zweite Regel `#id img{…}`. Das ist der Grund, warum `object-fit` überhaupt etwas tun kann: auf einem `figure` tut es nichts. Damit kommen Bildausschnitt, Bildmitte, Seitenverhältnis, Bildhöhe, Bildecken und Bildrahmen.
**Von 50 auf 93 Eigenschaften.** Dazu Positionierung mit z-index (freigestellte Elemente, wie in Flatsome-Bannern), getrennte Zeilen- und Spaltenabstände, Flex-Eigenschaften je Element, Innenschatten, Textschatten, acht Bildfilter als eine zusammengesetzte `filter`-Deklaration in fester Reihenfolge, Mischmodus und Mauszeiger. Neun Einblendungen mit Verzögerung, sieben Hover-Effekte – alle innerhalb von `prefers-reduced-motion`.
**Drei neue Bausteine (v15).** Symbol als eigenes Element; Fortschritt mit `role="meter"` und dem Wert im Markup statt nur in der Balkenbreite; und ein **Slider ohne JavaScript** – ein Scrollbereich mit Einrasten, sichtbare Folien je Gerät, ohne Autoplay. Kein Autoplay ist eine Entscheidung: ein sich selbst bewegendes Karussell ist etwas, das Menschen anhalten wollen.
Weil Inline-Stile verboten sind, sind Symbolgröße und Fortschrittswert **Klassen**. Beim Symbol heißt das: nur die Größen aus einer festen Liste sind erlaubt – ein freier Wert hätte keine Klasse, und ein SVG ohne Maße ist unsichtbar. Der Validator kennt die Liste, der Editor bietet genau sie an.
**Zeilen waren nicht unauffindbar, sondern unbrauchbar leer.** „Zeile“ stand immer in der Elementliste, aber eine Zeile ohne Spalten sieht aus wie nichts. Jetzt gibt es „Zeile, 2/3/4 Spalten“ auf einen Klick und im Werkzeugkasten den Satz, wie der Aufbau gemeint ist: Sektion → Zeile → Spalte → Inhalt.
## ADR 035 – Der Updater: jeder Schritt prüft, der Rückweg ist Teil des Wegs (12.09.2026)
Umgesetzt wie in `docs/UPDATE.md` geplant, mit drei Dingen, die in der Umsetzung wichtiger wurden als im Plan.
+55 -7
View File
@@ -114,6 +114,12 @@ Seiten umbenennen und verschieben, mit Aktualisierung aller Kindpfade in einer T
Abnahme: Eine Seite mit Unterseiten wird verschoben; alle alten Adressen leiten weiter, Menüs bleiben intakt.
### G3 – Designer auf Flatsome-Niveau (Nutzerauftrag vom 12.09.2026) — **weitgehend erledigt am 12.09.2026** (ADR 036)
Umuts Auftrag: „Prüfe es und baue alles wirklich alles genauso ein styles, effekte, und mehr.“ Der Vergleich steht in `docs/DESIGNER-VERGLEICH.md`. Gebaut: 93 statt 50 Eigenschaften, neun Einblendungen mit Verzögerung, sieben Hover-Effekte, Bild-Eigenschaften mit eigenem Ziel, Positionierung, Filter, Symbol, Fortschritt, Slider, Zeilenvorlagen.
Offen und dort begründet: Sichtbarkeitsschalter je Gerät, Parallax, Video-Hintergrund, selbst gehostetes Video, Countdown, Popup, Einblendung beim Scrollen, soziale Symbole, Preistabelle, Teammitglied, Suche, Vorlagenbibliothek. Die vier mit Skriptbedarf (Countdown, Popup, Scroll-Einblendung, Autoplay) sind eine Entscheidung, keine Lücke – sie weichen `script-src 'none'` auf und werden nur auf Zuruf gebaut.
### G – Editor für die tägliche Arbeit
*Braucht: C.*
@@ -145,7 +151,7 @@ Abnahme: Templatewechsel erhält die Inhalte. Das Referenzplugin lässt sich akt
*Braucht: nichts; teils vorziehbar.*
~~Benutzerverwaltung und Passwortänderung in der Oberfläche.~~ — **erledigt am 12.09.2026** (ADR 030). Offen bleibt der Rest des Pakets: Inhaltscache mit gezielter Erneuerung beim Veröffentlichen. Bildformate und Ressourcenpakete prüfen. Sicherung und Wiederherstellung erproben. Updateverfahren mit Datenbankmigration und Rückfallstrategie dokumentieren — **geplant am 12.09.2026 in `docs/UPDATE.md`**: neue Version über einen Git-Tag ziehen, mit Vorprüfung, Sicherung, Wartungszustand, Migration, Rauchtest und Rückweg. Umsetzung offen; zwei Entscheidungen hängen am Hosting (Composer auf dem Server oder `vendor/` im Tag, und ob es Shell-Zugriff gibt).
~~Benutzerverwaltung und Passwortänderung in der Oberfläche.~~ — **erledigt am 12.09.2026** (ADR 030). Offen bleibt der Rest des Pakets: Inhaltscache mit gezielter Erneuerung beim Veröffentlichen. ~~Bildformate prüfen.~~ — **erledigt am 12.09.2026** (ADR 040): WebP in vier Breiten, Hintergrundbilder je Gerät. Ressourcenpakete bleiben offen. ~~Sicherung und Wiederherstellung erproben~~ — **erledigt** in Arbeitspaket O: die Wiederherstellung ist im Test und von Hand nachgewiesen. Updateverfahren mit Datenbankmigration und Rückfallstrategie dokumentieren — **geplant am 12.09.2026 in `docs/UPDATE.md`**: neue Version über einen Git-Tag ziehen, mit Vorprüfung, Sicherung, Wartungszustand, Migration, Rauchtest und Rückweg. Umgesetzt am 12.09.2026 (ADR 035). Die Frage „Composer auf dem Server oder `vendor/` im Tag“ ist entschieden (ADR 040): ein Releasearchiv vom Build-Rechner bringt `vendor/` mit, und ein Update, das die Abhängigkeiten ändert, bricht ohne Composer ab statt halb durchzulaufen. Offen bleibt, ob das Hosting Shell-Zugriff hat.
Abnahme: Veröffentlichen macht die Änderung sofort sichtbar und erneuert nur die betroffenen Einträge. Eine Wiederherstellung aus dem Backup ist nachgewiesen.
@@ -172,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.*
@@ -183,24 +189,66 @@ 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)
### N – Installer (Nutzerauftrag vom 12.09.2026) — **erledigt am 12.09.2026** (ADR 041), außer dem Download aus einer veröffentlichten Releaseadresse
**Teilweise erledigt am 12.09.2026:** der *Updater* ist gebaut (ADR 035), der *Installer* nicht. Beide teilen die Systemprüfung, die jetzt existiert und dort wiederverwendet wird.
**Gebaut:** der Browserinstaller unter `/install` mit sechs Bildschirmen und die einzelne `install.php`, die ein Buildskript mit Adresse und Prüfsumme versieht. Der Schritt „Datenbank“ fragt SQLite oder MySQL ab und prüft die Verbindung sofort; damit brauchte er die Hostingentscheidung aus Arbeitspaket J nicht mehr abzuwarten.
*Braucht: die Hostingentscheidung aus Arbeitspaket J für den Schritt „Datenbank“.*
**Offen bleibt:** ein echter Download (dazu muss ein Release unter seiner Adresse liegen), MySQL gegen einen echten Server, und eine Installation auf echtem Hosting.
Umut: „Einen Installer brauche ich noch wie wordpress schritt für schritt alles einrichten migration ausführen usw.“ Heute braucht eine Installation `bin/setup.php` und Konsolenzugriff – auf geteiltem Hosting ist das genau die Hürde, an der es scheitert.
**Ablauf, wie bei WordPress, aber ohne dessen Nachlässigkeiten:** eine Seite unter `/install`, die nur antwortet, solange keine Installation existiert, und danach dauerhaft 404 liefert. Schritte: (1) Systemprüfung – PHP-Version, benötigte Erweiterungen, Schreibrechte auf `var/` und den Medienordner, ob `public/` wirklich der Dokumentenstamm ist; jede Prüfung mit dem Satz, was zu tun ist, wenn sie fehlschlägt. (2) Datenbank – SQLite-Datei oder MySQL-Zugangsdaten, Verbindung sofort geprüft statt erst beim Speichern. (3) Migration ausführen, mit Ausgabe der angewandten Versionen. (4) Erstes Administratorkonto – Name und Passwort, dieselben Regeln wie in der Benutzerverwaltung. (5) Website – Name, Adresse, Sprache; dazu Kopf- und Fußbereich, Startseite sowie Datenschutz- und Impressumsgerüst anlegen. (6) Fertig: Anmeldung, und eine Liste dessen, was noch auszufüllen ist.
**Davor steht ein einzelner Dateidownload** (Nutzerauftrag vom 12.09.2026: „einen installer das eine php ist und alle dateien herunterladet dann muss man nicht alles hochladen. Am besten zip herunterladen dann entpacken“). Eine Datei `install.php` per FTP hochladen, im Browser aufrufen, fertig – statt tausende Dateien einzeln zu übertragen. Das ist der Teil, der über Sicherheit entscheidet, deshalb die Regeln vorweg:
- **Die Adresse steht in der Datei, nicht im Formular.** Ein Installer, der eine URL abfragt und den Inhalt entpackt, ist eine Einladung, fremden Code auf den Server zu legen. Geladen wird von einer festen Adresse des Releasearchivs.
- **Die Prüfsumme steht ebenfalls in der Datei.** SHA-256 des Archivs, in `install.php` eingebacken und vor dem Entpacken verglichen. Stimmt sie nicht, wird abgebrochen – ohne Prüfsumme kein Entpacken, auch nicht mit Häkchen.
- **HTTPS mit Zertifikatsprüfung**, kein `verify_peer => false`, kein Rückfall auf HTTP.
- **Schutz gegen ZIP-Slip:** jeder Eintrag wird gegen das Zielverzeichnis geprüft, bevor er geschrieben wird. Ein Archiveintrag `../../.env` darf nichts außerhalb des Ziels anfassen.
- **Nach dem Entpacken löscht sich die Datei selbst** und verweigert jeden weiteren Aufruf, sobald eine Installation existiert.
- **Vorprüfung zuerst:** Schreibrechte, `ZipArchive` oder `PharData`, genug Platz, PHP-Version, und ob der Download überhaupt möglich ist (`allow_url_fopen` oder curl). Fehlt etwas, sagt die Seite welche Zeile in welcher Datei zu ändern ist – statt auf halbem Weg zu scheitern.
- **`vendor/` ist im Archiv.** Damit ist die offene Frage aus Arbeitspaket J für diesen Weg entschieden: das Releasearchiv wird auf einem Build-Rechner erzeugt und bringt die Abhängigkeiten mit, weil geteiltes Hosting kein Composer hat.
Danach übernimmt der eigentliche Installer unten: Systemprüfung, Datenbank, Migration, erstes Konto, Website.
**Die drei Punkte, an denen WordPress-Installer regelmäßig zum Problem werden, und was hier anders läuft:**
- **Der Installer schreibt `.env`,** braucht dafür Schreibrechte im Projektstamm und sagt es, statt die Datei stillschweigend nicht zu schreiben. `APP_SECRET` wird erzeugt, nicht abgefragt.
- **Er ist nach dem ersten Durchlauf tot.** Gesperrt wird über den Zustand der Datenbank (existiert ein Konto?), nicht über eine Datei, die jemand löschen kann.
- **Er legt keine Zugangsdaten in ein Protokoll und zeigt kein Passwort erneut an.**
Abnahme: eine leere Installation ist im Browser in sechs Schritten betriebsbereit, ohne Konsole. Ein zweiter Aufruf von `/install` liefert 404. Die Systemprüfung schlägt bei fehlenden Schreibrechten fehl und sagt, welcher Pfad fehlt. Getestet gegen ein leeres Verzeichnis und eine leere Datenbank, nicht gegen die Entwicklungsinstallation.
Abnahme: **eine einzelne hochgeladene PHP-Datei genügt**, um von einem leeren Webspace zu einer betriebsbereiten Installation zu kommen; eine leere Installation ist im Browser in sechs Schritten betriebsbereit, ohne Konsole. Ein manipuliertes Archiv (falsche Prüfsumme) wird abgelehnt, und ein Archiveintrag mit `..` im Pfad landet nirgends. Ein zweiter Aufruf von `/install` liefert 404. Die Systemprüfung schlägt bei fehlenden Schreibrechten fehl und sagt, welcher Pfad fehlt. Getestet gegen ein leeres Verzeichnis und eine leere Datenbank, nicht gegen die Entwicklungsinstallation.
### O – Sicherung und Wiederherstellung als Funktion (Nutzerauftrag vom 12.09.2026) — **erledigt am 12.09.2026** (ADR 038), außer MySQL und dem Download über den Browser
*Braucht: nichts. Für MySQL zusätzlich die Hostingentscheidung aus Arbeitspaket J.*
Umut: „Todo aufnehmen. Standard backup funktion.“ Heute gibt es Sicherungen nur als Nebenprodukt: der Updater kopiert die Datenbank vor einem Update, und ich habe vor jeder Migration von Hand eine Kopie in `var/backups/` gelegt. Das ist keine Sicherungsfunktion, das ist Disziplin – und Disziplin fällt aus, sobald jemand anderes arbeitet.
**Was in eine Sicherung gehört**, und was nicht:
- **Die Datenbank.** Bei SQLite die Datei, und zwar konsistent: eine Kopie während eines Schreibvorgangs kann unbrauchbar sein, deshalb `VACUUM INTO` statt `copy()`. Bei MySQL `mysqldump`.
- **Der Medienordner.** Ohne ihn ist die Sicherung wertlos: die Dokumente verweisen auf Bild-IDs, die Dateien liegen im Medienordner. Das ist auch der Teil, der groß wird.
- **Ein Manifest**: Zeitpunkt, Fassung aus `VERSION`, höchste angewandte Migration, Anzahl Seiten, Fassungen und Medien, und eine Prüfsumme je Teil. Ohne das weiß man beim Zurückspielen nicht, ob die Sicherung zum Code passt.
- **Nicht `.env`.** Darin steht `APP_SECRET`; eine Sicherung, die man herunterlädt und weitergibt, darf das nicht enthalten. Die Datei wird beim serverseitigen Aufbewahren mitkopiert (der Updater tut das schon), aber nicht in ein Archiv gelegt, das den Server verlässt. Beim Zurückspielen wird sie deshalb auch nicht überschrieben.
**Ablauf einer Sicherung:** Wartungszustand ist *nicht* nötig – `VACUUM INTO` liefert einen konsistenten Stand, und Medien werden nur hinzugefügt, nie verändert. Geschrieben wird nach `var/backups/sicherung-<zeitstempel>/` mit Manifest; auf Wunsch als ein Archiv. `var/` liegt außerhalb des Dokumentenstamms (`public/`), also ist keine Sicherung über das Netz erreichbar – das muss die Systemprüfung nachweisen, nicht annehmen.
**Aufbewahrung mit Rotation:** die letzten sieben täglichen, vier wöchentlichen, drei monatlichen. Ohne Rotation läuft die Platte voll und dann scheitert die nächste Sicherung genau dann, wenn man sie braucht.
**Herunterladen:** in der Verwaltung, als Archiv, gestreamt. Mit zwei ehrlichen Hinweisen: ohne `.env`, und bei einem großen Medienordner bricht ein Browserdownload womöglich ab – dann ist die Konsole oder ein Dateizugriff der Weg. Kein Archiv im Speicher aufbauen.
**Wiederherstellung** ist der Teil, der zählt, und der Teil, den niemand übt:
1. `cms:restore <verzeichnis-oder-archiv>`, und in der Verwaltung nur die Anzeige, welche Sicherungen es gibt. Ein Knopf, der die Datenbank überschreibt, gehört nicht neben einen Knopf, der eine Seite speichert.
2. Manifest gegen die laufende Installation prüfen: eine Sicherung aus einer **neueren** Fassung wird abgewiesen – deren Migrationen kennt der alte Code nicht. Eine ältere ist erlaubt, dann läuft danach die Migration.
3. Wartungszustand an, **Sicherung des aktuellen Stands** anlegen (auch eine Wiederherstellung kann die falsche sein), Datenbank ersetzen, Medien ergänzen statt löschen, Migration, Cache, Rauchtest, Wartungszustand aus.
4. Protokoll nach `var/backups.log`.
**Was gebaut wird:** `src/Ops/Backup.php`, `cms:backup` (mit `--rotate`, `--archive`), `cms:restore` (mit `--force`), eine Übersicht im Bildschirm „System“ mit Größe, Zeitpunkt, Fassung und Prüfsummenstatus je Sicherung, und ein Eintrag im Cronjob-Abschnitt des README.
**Abnahme – und hier liegt der offene Punkt aus Arbeitspaket I:** eine Wiederherstellung ist *nachgewiesen*. Nicht „die Funktion existiert“, sondern: eine Sicherung wird angelegt, die Datenbank wird nachweislich verändert (eine Seite gelöscht, ein Bild entfernt), die Sicherung wird zurückgespielt, und danach ist beides wieder da – geprüft im Test gegen eine wegwerfbare Installation und einmal von Hand auf der Entwicklungsinstallation. Eine Sicherungsfunktion, deren Wiederherstellung nie gelaufen ist, ist eine Vermutung.
### K – Migration von kant-it.de
+102
View File
@@ -0,0 +1,102 @@
# Der Designer im Vergleich mit Flatsome (UX Builder)
Stand: 12.09.2026.
## Woran dieser Vergleich hängt
Umut hat gefragt: „Ist der Designer vom Funktionsumfang so aufgestellt wie Flatsome?“ Zur Ehrlichkeit gehört, wie ich das geprüft habe – und wie nicht.
**Nicht geprüft:** Flatsome selbst. Die Erweiterung ist hier nicht installiert, das WordPress-Backend des Piloten ist mir nicht zugänglich, und in der öffentlich ausgelieferten Startseite sieht man die Bausteine, nicht den Baukasten. Ich habe also **keinen Klick in UX Builder gemacht**.
**Geprüft:** der eigene Designer, Eigenschaft für Eigenschaft, gegen den mir bekannten Funktionsumfang von UX Builder (Elementliste, Optionsgruppen je Element, Animations- und Hover-Sammlung). Das ist Wissen, keine Messung – wo es falsch ist, korrigiere mich, dann ziehe ich nach. Für die konkrete Pilotseite ist ohnehin der Abgleich aus Arbeitspaket K maßgeblich, und der braucht Backendzugang.
## Gestaltungsoptionen
Vor heute hatte der Designer 50 Eigenschaften, jetzt 93 – jede je Gerät (Desktop, Tablet, Mobil) und ein Teil zusätzlich für den Hover-Zustand.
| Bereich | Flatsome (UX Builder) | KantSites |
| --- | --- | --- |
| Typografie | Schriftart, Größe, Gewicht, Zeilenhöhe, Laufweite, Ausrichtung, Schreibweise, Farbe | **11 Eigenschaften**, dazu Schriftlage, Textlinie, Wortabstand; Schriftart aus der Website-Palette (Systemschrift oder Google Fonts) |
| Hintergrund | Farbe, Bild, Position, Größe, Wiederholung, Parallax, Überlagerung, Verlauf, Video | **11**: Farbe, Bild aus der Mediathek, Größe, Ausschnitt, Wiederholung, Scrollverhalten, Überlagerung mit Deckkraft, Verlauf mit Winkel. **Fehlt: Parallax und Video-Hintergrund** |
| Abstand | Innen/Außen je Seite | **je Seite**, je Gerät |
| Größe | Breite, Höhe, Mindesthöhe | **7**: Breite, Mindest-, Maximalbreite, Höhe, Mindest-, Maximalhöhe, Überlauf |
| Rahmen | Stärke je Seite, Farbe, Art, Rundung je Ecke | **8**: alles davon, dazu ein zweiter Rahmen (outline) mit Abstand. **Heute behoben:** die Stärke je Seite erzeugte `border-width-top` statt `border-top-width` – ungültiges CSS, das der Browser stillschweigend verwarf. Rahmenstärken je Seite hatten damit nie eine Wirkung |
| Bild | Rundung, Zuschnitt, Seitenverhältnis, Filter | **neu, 8 Eigenschaften**, die auf das Bild *im* Block wirken statt auf seinen Rahmen: Ausschnitt (object-fit), Bildmitte, Seitenverhältnis, Höhe, Ecken je Ecke, Rahmen. Vorher landete eine Rundung auf dem `figure` und das Bild blieb eckig – genau das, was Umut aufgefallen ist |
| Layout | Spalten, Abstand, Verteilung, Ausrichtung | **20**: Anzeige, Flexrichtung, Umbruch, Verteilung, Ausrichtung, Spaltenzahl, Abstände (auch getrennt für Zeilen und Spalten), Reihenfolge, eigene Ausrichtung, Wachsen, Schrumpfen, Grundbreite, **Positionierung (auch freigestellt und klebend), Abstände zu allen vier Rändern, Ebene (z-index)** |
| Wirkung | Schatten, Deckkraft, Animationen, Hover | **26**: Deckkraft, Schatten (auch **innen**), Textschatten, Verschieben, Skalieren, Drehen, Übergangsdauer, **Weichzeichnen, Helligkeit, Kontrast, Sättigung, Graustufen, Sepia, Farbdrehung, Umkehren, Mischmodus, Mauszeiger** |
| Eigener Code | CSS je Element | **CSS je Element für alle, Skript je Element nur für Administratoren** (ADR 028) |
| Sichtbarkeit je Gerät | „Hide on mobile/tablet/desktop“ | über `Anzeige: keine` je Gerät möglich, aber **nicht als eigener Schalter** – siehe „Was noch fehlt“ |
| Abschnittsbreite | Normal, breit, randlos | **Normal (1180 px), schmal (800 px), breit (1600 px), randlos** – randlos heißt ohne Begrenzung und ohne Rand, für Bild oder Farbfläche von Kante zu Kante |
| Globale Inhaltsbreite | im Theme einstellbar | **im Designbereich einstellbar** – und wirkt seit heute auch nach oben; vorher deckelte das Stylesheet bei 1180 px |
| Volle Bildschirmhöhe | „Full screen“ je Abschnitt | über `Mindesthöhe: 100dvh` plus `Anzeige: flex` und `Ausrichtung quer: Mitte`; die Vorlage **„Vollbild-Hero“** setzt genau das auf einen Klick. Neu sind dafür die Einheiten **dvh, svh, lvh** – `100vh` ist auf dem Handy höher als der Bildschirm |
### Bewegung
| | Flatsome | KantSites |
| --- | --- | --- |
| Einblendungen | rund fünfzehn, beim Scrollen ausgelöst | **neun** (sanft, von unten, von oben, von links, von rechts, Heranzoomen, Kippen, aus der Unschärfe) mit **Verzögerung** in drei Stufen. **Sie laufen beim Laden der Seite, nicht beim Scrollen** – dafür bräuchte es JavaScript, und eine öffentliche Seite führt keines aus. Das steht so im Editor |
| Hover | rund zehn | **sieben** (anheben, absenken, Bild vergrößern, Leuchten, Kippen, Rahmen einfahren) – dazu **frei**: jede Farbe, jeder Schatten, jede Verschiebung, jede Skalierung hat einen eigenen Hover-Wert |
| „Bewegung reduzieren“ | teilweise berücksichtigt | **jede** Animation liegt in der Abfrage `prefers-reduced-motion`; wer weniger Bewegung wünscht, bekommt den Endzustand |
## Bausteine
| Flatsome-Element | KantSites |
| --- | --- |
| Section, Row, Column | Sektion, Zeile, Spalte – **neu: „Zeile, 2/3/4 Spalten“ auf einen Klick**, weil eine leere Zeile aussieht wie nichts und deshalb niemand sie gefunden hat |
| Text, Headline | Text mit Werkzeugleiste und HTML-Ansicht, Überschrift |
| Button | Button |
| Image, Gallery | Bild, Galerie |
| Icon, Icon Box | **neu: Symbol** als eigenes Element (vorher nur in der Symbolkarte), Symbolkarte |
| Slider, Banner | **neu: Slider** als Scrollbereich mit Einrasten – ohne JavaScript, ohne Autoplay, mit Finger, Trackpad, Mausrad und Tastatur bedienbar. Sichtbare Folien je Gerät einstellbar |
| Accordion, Tabs | Akkordeon, Tabs |
| Progress bar | **neu: Fortschritt** – mit `role="meter"` und dem Wert im Markup, nicht nur in der Balkenbreite |
| Testimonial | Kundenstimme mit Sternen |
| Counter, Countdown | **fehlt** – braucht JavaScript |
| Team member, Price table | **fehlt als eigenes Element**; aus Zeile, Spalte, Bild, Überschrift und Liste zusammensetzbar |
| Video | **teils**: ein eingebettetes Video geht über „Externer Inhalt“ und lädt erst nach Einwilligung. Eine selbst gehostete Videodatei fehlt |
| Google Map | über „Externer Inhalt“ – **und zwar richtig**: ohne Einwilligung wird keine Verbindung zu Google aufgebaut |
| Social icons, Share | **fehlt als Element**; aus Symbolen mit Linkziel zusammensetzbar |
| Contact form | Formular, serverseitig validiert, mit Einwilligung und Spamschutz |
| Blog posts | Beitragsliste, nach Kategorie oder Schlagwort filterbar |
| Shop-Elemente | **bewusst nicht** – kein Shop im Projekt |
| Popup, Lightbox | **fehlt** – braucht JavaScript |
| HTML, Custom CSS | eigenes CSS je Element für alle, eigenes Skript nur für Administratoren |
| Divider, Gap | Trennlinie, Abstand |
| Search | **fehlt** – es gibt keine Suche auf der Website (Arbeitspaket M) |
| Breadcrumbs, Navigation | Brotkrumen, Navigation aus der Menüverwaltung – **vier Plätze** (Haupt, zweite, Fußzeile, Rechtliches) und **drei Arten**: waagerecht, untereinander, aufklappbar als Burger (ohne JavaScript) |
| Kopfbereich je Seite („Hide header“) | **je Seite wählbar**: Standard, ein anderer Kopf- oder Fußbereich, oder keiner |
| Verknüpfte Abschnitte | Verknüpfte Sektion – eine Änderung wirkt auf allen Seiten, die sie einbinden |
## Was absichtlich anders ist
Das ist kein Funktionsmangel, sondern eine Entscheidung:
1. **Eine öffentliche Seite führt kein JavaScript aus** (`script-src 'none'`), außer sie enthält Tabs oder ein eigenes Skript. Deshalb gibt es kein Autoplay-Karussell, keinen Countdown und kein Popup. Der Preis: diese drei fehlen. Der Gegenwert: die Seite ist ohne Skript vollständig lesbar und schnell, und eine ganze Klasse von Fehlern und Angriffen entfällt.
2. **Keine Inline-Stile.** Jede Gestaltung wird zu einem Stylesheet mit Hash zusammengefasst. Deshalb sind Symbolgrößen und Fortschrittswerte Klassen und keine `style`-Attribute.
3. **Eingebettete Inhalte laden erst nach Einwilligung** – vorher steht nicht einmal die Adresse des Anbieters im HTML.
4. **Jeder Wert wird serverseitig geprüft.** Was der Editor anbietet, ist genau das, was der Server annimmt; ein Muster oder eine feste Liste je Eigenschaft, keine freie CSS-Eingabe außer im ausdrücklich dafür gedachten Feld.
5. **Kein Shop und keine Produktelemente.**
## Was noch fehlt, nach Nutzen geordnet
1. **Sichtbarkeit je Gerät als eigener Schalter.** Technisch über `Anzeige: keine` je Gerät schon möglich, aber niemand sucht es dort. Ein Häkchen „Auf diesem Gerät ausblenden“ im Designbereich – klein, hoher Nutzen.
2. **Parallax und Video-Hintergrund.** Parallax geht in modernen Browsern rein mit CSS (`background-attachment: fixed` gibt es schon; echtes Parallax bräuchte `animation-timeline: scroll()`, das Safari und Firefox noch nicht können). Video-Hintergrund braucht ein `<video>`-Element und eine Entscheidung über Datenmenge und Einwilligung.
3. **Selbst gehostetes Video** als Element, mit Vorschaubild und ohne Autoplay mit Ton.
4. **Countdown und Popup.** Beide brauchen ein Skript. Machbar wie die Tabs: eine Seite, die eines enthält, bekommt `script-src 'self'` und lädt eine kleine Datei. Das ist eine bewusste Aufweichung – ich würde sie nur bauen, wenn du sie wirklich brauchst.
5. **Einblendung beim Scrollen** statt beim Laden. Ohne Skript nur mit `animation-timeline: view()` möglich, das noch nicht überall funktioniert; mit Skript sofort.
6. **Soziale Symbole, Preistabelle, Teammitglied** als eigene Elemente – heute zusammensetzbar, aber jedes Mal Handarbeit.
7. **Suche auf der Website.**
8. **Vorlagenbibliothek.** Flatsome bringt fertige Seitenvorlagen mit. Hier gibt es drei Abschnittsvorlagen und die Zeilenvorlagen; eine echte Sammlung gehört zu Arbeitspaket H (Templates).
## Was heute dabei herauskam
Neben dem Ausbau zwei echte Fehler, beide vorher unbemerkt:
- **Rahmenstärken je Seite hatten nie eine Wirkung.** `border-width-top` ist kein CSS; der Browser verwirft die Zeile stillschweigend. Die Schreibweise steht jetzt je Eigenschaft im Schema, weil sie sich nicht erraten lässt.
- **Eine Rundung auf einem Bildblock rundete den Rahmen, nicht das Bild.** Jetzt erbt das Bild die Rundung seines Rahmens, und die neuen Bild-Eigenschaften schreiben ohnehin direkt auf das Bild.
## Offen: Menüart je Gerät (13.09.2026)
Beim Nachbau von kant-it.de aufgefallen: die **Menüart** (`horizontal`, `vertikal`, `Burger`) gilt für alle Bildschirmgrößen. Gebraucht wird fast immer „auf dem Desktop nebeneinander, auf dem Telefon ein Burger". Heute geht das nur über **zwei Menüblöcke**, von denen je Gerät einer über `display: none` ausgeblendet wird – das funktioniert und ist barrierefrei (der ausgeblendete Block steht nicht im Zugänglichkeitsbaum), aber es ist eine Umgehung und keine Einstellung.
Sauber wäre die Menüart als Wert je Gerät, wie es die Gestaltungseigenschaften ohnehin sind. Dafür müsste die Umschaltung ohne doppeltes Markup auskommen – `<details>` lässt sich per CSS nicht zuverlässig in allen Browsern aufklappen, also läuft es entweder auf ein Aufklappmuster ohne `details` hinaus oder auf zwei gerenderte Varianten, von denen eine verborgen ist. Die Entscheidung steht aus.
+55
View File
@@ -0,0 +1,55 @@
# Livegang von kant-it.de
Stand: 13.09.2026. Diese Liste trennt, was **geprüft ist**, von dem, was **nur auf dem echten Hosting geprüft werden kann** – und in welcher Reihenfolge umgeschaltet wird.
## Geprüft (lokal, gegen den Nachbau)
| Prüfung | Ergebnis |
| --- | --- |
| PHPUnit, PHP 8.2 und 8.4 | 409 Tests, 1791 Zusicherungen, grün |
| JavaScript-Modelltests | grün |
| Editor durchgespielt (Playwright + axe) | grün, isolierte Datenbank |
| Öffentliche Seiten (Playwright + axe, 1440 px und 390 px) | keine Beanstandung |
| Interne Verweise (`npm run test:links`) | 35 Seiten, 408 Adressen, kein toter Verweis |
| Lighthouse, vier Adressen, je drei Läufe | Barrierefreiheit, Best Practices, SEO, agentic browsing **100** überall; Performance 100 außer Startseite (98 mobil / 96 Desktop) |
| Sicherung (`cms:backup`) | angelegt: 33 Seiten, 100 Fassungen, 388 Mediendateien |
| Weiterleitungen der alten WordPress-Adressen | 14 Stück angelegt, alle beantwortet mit 301 |
Gemessen wurde im Produktionsmodus auf dem PHP-Entwicklungsserver, der dieselben Kopfzeilen sendet wie `public/.htaccess`. **Das ist kein Ersatz für eine Messung auf dem echten Hosting.**
## Nicht geprüft – muss vor dem Umschalten passieren
1. **E-Mail-Versand.** Nie ausgeführt, in keiner Umgebung. `MAILER_DSN=sendmail://default` funktioniert auf den meisten PHP-Hostings; verlangt der Anbieter SMTP, gehört dort eine Zeile der Form `smtp://benutzer:passwort@mailserver:587` hinein. **Nach dem Hochladen eine echte Anfrage über das Kontaktformular abschicken und prüfen, ob sie ankommt** – auch im Spam-Ordner. Die Anfrage wird vor dem Versand gespeichert; kommt keine Mail an, steht sie trotzdem unter „Formulare“ in der Verwaltung.
2. **HTTPS.** Zertifikat einrichten, dann in `public/.htaccess` die drei Zeilen der HTTPS-Umleitung einschalten. **Erst wenn das eine Weile läuft**, die HSTS-Zeile aktivieren – sie lässt sich in einem Browser ein halbes Jahr lang nicht zurücknehmen.
3. **www oder nicht.** Das alte kant-it.de ist kanonisch mit `www`. In `.htaccess` steht die Umleitung dafür vorbereitet und auskommentiert; sie muss zur Einstellung `SITE_ORIGIN` passen, die in `.env` steht. Beide zeigen auf dieselbe Schreibweise, sonst zeigt jede Seite auf eine Adresse, die weiterleitet.
4. **Datenbank.** Geprüft ist ausschließlich SQLite. Für MySQL ist alles vorbereitet, aber nichts erprobt – wer MySQL nimmt, sollte danach Seiten anlegen, veröffentlichen, Bilder hochladen und ein Formular absenden.
5. **Cronjobs.** Täglich `php bin/console cms:backup` und `php bin/console cms:stats:prune`. Ohne den ersten gibt es keine Sicherung außer der von Hand angelegten.
6. **Schriften.** Auf dem neuen Server einmal `php bin/console cms:fonts:fetch` ausführen oder im Designbereich den Knopf drücken – die Dateien liegen nicht im Repository. Bis dahin lädt die Seite die Schriften von Google, was sie nicht soll.
7. **Rechtstexte.** Impressum und Datenschutzerklärung sind vom alten Auftritt übernommen. Der Abschnitt über Google Fonts wurde an die Wirklichkeit angepasst (die Schriften kommen jetzt vom eigenen Server), die Abschnitte über Google Maps und reCAPTCHA entfernt, weil beides nicht eingebunden ist. **Das ersetzt keine anwaltliche Prüfung**, und es ist die Verantwortung des Betreibers, nicht des CMS.
8. **Texte.** Acht Projektbeschreibungen stammen nicht vom alten Auftritt, sondern sind neu geschrieben. Sie sollten gelesen werden, bevor sie öffentlich sind.
## Optional: die Verwaltung umbenennen
`/admin` ist die erste Adresse, die jeder Scanner probiert. `php bin/console cms:admin:path meine-verwaltung` legt sie woanders hin; der Name steht danach in `.env.local` und in keiner öffentlichen Datei. **Vorher notieren, wohin** – danach antwortet `/admin` mit 404 wie jede unbekannte Adresse. Zurück geht es mit `php bin/console cms:admin:path admin`.
## Optional: Spamschutz mit Cloudflare
Die Formulare haben drei eingebaute Prüfungen ohne Dritte. Kommt trotzdem Spam durch, gibt es unter
Einstellungen → „Spamschutz für Formulare" Cloudflare Turnstile: Schlüsselpaar im Cloudflare-Konto
anlegen (kostenlos), beide Schlüssel eintragen, Haken setzen. **Vorher wissen:** das Widget braucht
JavaScript, und die IP-Adresse des Absenders geht an Cloudflare – beides gehört in die
Datenschutzerklärung, bevor es scharf geschaltet wird.
## Reihenfolge am Umzugstag
1. Dateien auf das Hosting, `.env` mit `APP_ENV=prod`, `APP_DEBUG=0`, `DATABASE_URL`, `MEDIA_DIRECTORY`, `SITE_ORIGIN`, `MAILER_DSN` und einem neuen `APP_SECRET`.
2. Datenbank und Medienordner einspielen (oder `php vendor/bin/doctrine-migrations migrate` auf einer leeren Datenbank und dann der Installer).
3. `php bin/console cms:fonts:fetch`, danach eine Seite aufrufen und im Quelltext prüfen, dass nichts von `fonts.googleapis.com` geladen wird.
4. Kontaktformular abschicken, Mail prüfen.
5. **Jetzt erst** die Domain umstellen.
6. Danach: `CMS_URL=https://www.kant-it.de npm run test:links` von einem Rechner mit Node, `robots.txt` und `sitemap.xml` im Browser aufrufen, die Sitemap in der Google Search Console neu einreichen, und drei bis vier alte Adressen von Hand aufrufen (etwa `/productsolutions/`, `/kanttresor-dsgvo-konforme-archivierung-fuer-moderne-unternehmen/`) – sie müssen mit 301 auf die neue Adresse führen.
7. Eine Woche lang in der Search Console auf 404-Meldungen schauen; jede weitere alte Adresse bekommt eine Weiterleitung unter „Einstellungen → Weiterleitungen“.
## Rückweg
Das alte WordPress bleibt bis auf Weiteres bestehen. Geht etwas schief, wird die Domain dorthin zurückgestellt; die Inhalte des neuen Auftritts liegen unverändert in der Sicherung unter `var/backups/`.
+228
View File
@@ -0,0 +1,228 @@
# Eine neue Website mit KantSites bauen
Diese Anleitung richtet sich an den, der die Arbeit macht – Mensch oder Assistent. Sie sagt, in
welcher Reihenfolge etwas entsteht, welche Entscheidung wohin gehört und welche Fehler dieses
Projekt schon einmal gemacht hat. Wer sie befolgt, kommt ohne Umwege zu einer Website, die die
Prüfungen besteht.
**Lies zuerst:** `AGENTS.md` (Projektregeln), `docs/STATUS.md` (was es gibt und was fehlt),
`docs/ARCHITECTURE.md` (warum es so ist). Diese drei sind der Stand der Dinge; diese Datei hier ist
das Kochbuch.
---
## 0. Die Grundregel
**Alles, was eine Website ausmacht, steht in der Datenbank – nicht im Code.** Seiten, Kopf- und
Fußbereich, Menüs, Formulare, Farben, Schriften, Einstellungen. Wer für eine neue Website eine
Twig-Datei anfasst oder CSS in `public/assets/site.css` schreibt, hat sich verlaufen: dann ändert
sich das Aussehen *aller* Installationen, und beim nächsten Update ist es weg.
Ausnahme mit Ansage: eigenes CSS und eigenes Skript je Seite gibt es im Editor (Bereich „Code"),
und es landet ebenfalls in der Datenbank.
---
## 1. Installation aufsetzen
```bash
# Eigene Datenbank und eigener Medienordner - nie gegen die Entwicklungsinstallation arbeiten.
DATABASE_URL="sqlite:///…/var/kunde.db" php vendor/bin/doctrine-migrations migrate --no-interaction
DATABASE_URL="sqlite:///…/var/kunde.db" MEDIA_DIRECTORY="…/var/kunde-media" php bin/console cms:user:create
```
Oder im Browser über `/install` – sechs Schritte, legt Startseite, Kopf, Fuß, Menü, Impressum und
Datenschutzgerüst gleich mit an.
**Die Verwaltung muss nicht `/admin` heißen.** `ADMIN_PATH` in `.env` setzt die Adresse; `php
bin/console cms:admin:path <neu>` prüft, ändert und sagt, welcher Zwischenspeicher danach zu leeren
ist. Bei einer Kundenwebsite lohnt das: `/admin` ist die erste Adresse, die ein Scanner probiert.
Zum Ansehen ein eigener Server auf einem eigenen Port:
```bash
DATABASE_URL="sqlite:///…/var/kunde.db" MEDIA_DIRECTORY="…/var/kunde-media" \
SITE_ORIGIN=http://127.0.0.1:8090 php -S 127.0.0.1:8090 -t public bin/dev-router.php
```
`bin/dev-router.php` ist Pflicht: ohne ihn beantwortet der eingebaute Server `/sitemap.xml` und
`/robots.txt` selbst, und die Dateien kommen ohne die Kopfzeilen, die später `public/.htaccess`
setzt – jede Messung wäre dann falsch.
---
## 2. Inhalte anlegen: als Skript, nicht von Hand
Eine vollständige Website besteht aus Dutzenden Blöcken mit Hunderten Gestaltungswerten. Das klickt
niemand zweimal. **Schreibe ein Aufbauskript**, das Medien hochlädt, Dokumente zusammensetzt und
veröffentlicht – Vorlage ist das Skript, mit dem kant-it.de nachgebaut wurde.
Drei Eigenschaften, die ein solches Skript haben muss (alle drei sind aus Schaden gelernt):
1. **Wiederholbar.** Ein zweiter Lauf aktualisiert vorhandene Seiten, statt sie zu überspringen –
sonst ändert eine Korrektur im Skript nichts an der Website. Bilder werden am Titel
wiedererkannt, sonst wächst die Mediathek mit jedem Lauf.
2. **Eine Quelle für Adressen.** Verweise nie von Hand schreiben. Die Adresse eines Beitrags
entsteht aus derselben Funktion, die den Beitrag anlegt. Im Nachbau standen vier Links im Hero
von Hand darin – alle vier tot, weil die Beiträge längere Adressen bekamen.
3. **Über die Repository-Klassen, nicht per SQL.** `PageRepository::saveDraft()` und `publish()`
prüfen das Dokument. Ein per `INSERT` eingeschmuggeltes Dokument kann Felder enthalten, die der
Editor nie wieder öffnen kann.
Reihenfolge im Skript: Theme → Medien → Kopf- und Fußbereich → Seiten → Beiträge → Menüs →
Formulare → Einstellungen.
---
## 3. Das Dokumentformat
Ein Dokument ist `{schemaVersion, title, description, blocks: [...]}`. Ein Block ist
`{id, type, props, style, layout, children}`.
- **`id`** – höchstens 64 Zeichen, eindeutig im Dokument, erscheint als HTML-`id` und als
CSS-Selektor. Sprechend wählen (`hero-headline`, nicht `block-7`).
- **`type`** – siehe Liste im Editor; `section`, `row`, `column`, `heading`, `text`, `image`,
`button`, `postlist`, `slider`, `tabs`, `form`, `contact`, `stat`, `quote`, `iconcard`, …
- **`props`** – der Inhalt. Was hier steht, ist Text und Daten, nie Auszeichnung mit Wirkung.
- **`style`** – `{desktop: {...}, tablet: {...}, mobile: {...}}`, jeder Wert aus `StyleSchema`.
Seiten, Abstände und Ecken sind **Objekte** (`['top' => '10px', …]`), keine Kurzschreibweise.
- **`layout.effects`** – `{entrance: 'rise', delay: 'short'}` für das Einblenden beim Scrollen.
**Setze `schemaVersion` auf die aktuelle Fassung** (siehe `DocumentValidator::SCHEMA_VERSION`),
sonst sind neuere Bausteine und Eigenschaften nicht erlaubt – und der Editor kann in einem zu alten
Dokument nichts einfügen.
---
## 4. Gestaltung
**Erst das Theme, dann die Blöcke.** Farben und Schriften gehören in die Palette
(`/admin/theme`): ein Block, der `@accent` sagt, ändert sich mit dem Theme; einer, der `#c1121f`
sagt, nicht. Die Palette färbt auch Knöpfe, Symbole, Fokusringe und den Einwilligungsdialog.
Jedes Farbfeld hat einen Farbwähler und die Palettenfarben zur Auswahl. **Das Textfeld daneben
bleibt maßgeblich**: wer dort `@accent` stehen lässt, behält den Verweis auf die Palette; wer im
Farbwähler zieht, schreibt einen festen Wert hinein und kappt ihn.
Feste Größen nur, wo sie gemeint sind. `maxWidth` ohne `margin: auto` zentriert nichts – das war
hier schon zweimal die Ursache eines „geht nicht".
**Die Vorschau im Editor zeigt dieselbe Ausgabe wie die Website.** Was dort falsch aussieht, ist
falsch – nicht „nur die Vorschau".
---
## 5. Bilder
- Hochladen über `MediaLibrary::upload()`; daraus entstehen WebP-Varianten in 480/960/1600/2400 px
(nie größer als das Original). Das Original wird nicht behalten.
- **Vor dem Hochladen prüfen:** über 12 Megapixel oder 8000 px Kantenlänge werden abgelehnt, und
ein Hostingpaket mit 128 MB Speicher schafft große Bilder nicht. Vorher auf 2400 px verkleinern.
- Im Dokument immer alle `sources` mitgeben, damit `srcset` entsteht.
- Ein Bild mit fester Anzeigebreite bekommt `style.desktop.maxWidth` – daraus entsteht das
`sizes`-Attribut, und der Browser lädt die kleine Variante. Ohne das lud ein 190-px-Logo die
960er Datei.
- Hintergrundbilder werden je Gerät verkleinert, auch wenn sie nur einmal gesetzt wurden.
- „Beim Klick" entscheidet je Bild, ob nichts passiert, ob es sich vergrößert oder ob es zu einer
Adresse führt; eine Galerie hat dafür einen Schalter. Zugrunde liegt ein gewöhnlicher Verweis
auf die große Fassung – ohne JavaScript öffnet ein Klick sie, nichts ist kaputt.
---
## 6. Beiträge
Optional. Gibt es keine, verschwinden Archiv, Feed und Feedverweis von selbst; ein
Beitragslisten-Baustein zeigt öffentlich nichts.
Beim Anlegen wählt man einen von drei Aufbauten (`PostLayouts`: Bildkopf, Klassisch, Magazin), für
die Liste eine von vier Darstellungen (Karten, Liste, Kompakt, Wechselseitig). Beides sind
Startpunkte, keine Schablonen.
---
## 7. SEO
Meistens fertig, wenn der Inhalt stimmt: Titel, Beschreibung, Canonical, Open Graph, strukturierte
Daten, Sitemap, `robots.txt` entstehen aus dem, was dasteht. Zu setzen bleibt:
- **Titel für Suchergebnisse** je Seite, wenn er sich vom Titel auf der Seite unterscheiden soll.
- **Vorschaubild beim Teilen** – sonst nimmt ein Beitrag sein Beitragsbild und eine Seite gar nichts.
- **Unternehmensdaten** unter Einstellungen; daraus entsteht der strukturierte Datensatz.
- **Weiterleitungen**, wenn die Website eine alte ablöst. **Vor dem Umzug** die Adressliste der
alten Website besorgen (deren `sitemap.xml`) und für jede Adresse, die sich ändert, eine
Weiterleitung anlegen. Ohne das ist der Rang von Jahren weg.
---
## 8. Schriften
Nach dem Aufsetzen **einmal** `php bin/console cms:fonts:fetch` (oder der Knopf im Designbereich).
Ohne das lädt die Website die Schriften von Google – zwei fremde Verbindungen nacheinander und die
IP-Adresse jedes Besuchers an einen Dritten. Die Dateien der Pilotwebsite liegen im Repository;
wählt eine neue Website andere Schriften, holt dieser Befehl sie.
---
## 9. Prüfen
In dieser Reihenfolge, gegen die neue Website:
```bash
CMS_URL=http://127.0.0.1:8090 npm run test:links # kein toter Verweis
CMS_URL=http://127.0.0.1:8090 npm run test:browser # Barrierefreiheit, Tastatur, ohne JavaScript, Ladebudget
MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8090 CMS_PATHS="/,/leistungen,/kontakt" npm run audit
```
Für `npm run audit` muss die Installation im **Produktionsmodus** laufen (`APP_ENV=prod`,
`APP_DEBUG=0`), sonst misst man den Debugmodus.
Am Code geändert? Dann zusätzlich `php vendor/bin/phpunit` (beide PHP-Fassungen),
`npm run test:editor` und `composer format`.
**Zielwerte:** Barrierefreiheit, Best Practices und SEO je 100. Performance mindestens 95, 100
anstreben – eine bilderreiche Startseite mit Slider landet realistisch bei 96–98.
Findet die Prüfung einen Kontrastmangel aus einer im Editor gesetzten Farbe, wird er **gemeldet und
nicht behoben**: das ist eine Entscheidung des Redakteurs, keine des Codes.
---
## 10. Livegang
`docs/LIVEGANG.md` ist die Liste. Die drei Dinge, die dort am häufigsten vergessen werden:
E-Mail-Versand einmal wirklich ausprobieren, Schriften holen, und die Weiterleitungen der alten
Adressen prüfen.
---
## 11. Updates dieser Installation
Zwei Wege, und welcher gilt, entscheidet das Hosting:
- **Mit Git** (eigener Server, Shell-Zugriff): Bildschirm „System" oder `php bin/console cms:update`.
Vorprüfung, Sicherung, Wartungszustand, Checkout, Migration, Rauchtest, Rückweg.
- **Ohne Git** (geteiltes Hosting, über `install.php` installiert): im selben Bildschirm die Karte
„Update aus dem Releasearchiv" – zwei Knöpfe, der zweite führt die Migrationen aus. `var/`, `.env`,
hochgeladene Schriften und das erzeugte Stylesheet-Bündel bleiben unberührt.
Beides steht ausführlich in `docs/UPDATE.md`. Wichtig für den, der eine Website baut: **nichts von
dem, was diese Website ausmacht, liegt im Code**, also überlebt es beide Wege. Wer dafür doch eine
Twig-Datei angefasst hat, verliert die Änderung beim ersten Update – siehe Abschnitt 0.
---
## Was in diesem Projekt immer wieder schiefging
Wer diese fünf kennt, spart sich die Suche:
1. **Eine Einstellung, die speichert und nichts tut.** Das Feld war da, die Ausgabe nicht – beim
Vorschaubild fürs Teilen zwei Monate lang. **Nach jedem neuen Feld nachsehen, ob es vorne
ankommt**, und einen Test schreiben, der genau das prüft.
2. **`|default(true)` in Twig macht aus einem gespeicherten `false` ein `true`.** Für Schalter immer
`??` verwenden.
3. **Die Prüfung lief gegen die Demo, nicht gegen die Website.** Feste Pfade und Linktexte in einem
Prüfskript prüfen nichts, sobald der Inhalt anders ist.
4. **Messen statt vermuten.** Das Vorabladen des Hero-Bildes klang richtig und kostete fünf
Punkte. Jede Annahme über Geschwindigkeit wird gemessen, vorher und nachher.
5. **Der Entwicklungsserver ist nicht das Hosting.** Ohne Kompression und Cache-Kopfzeilen misst
man 90 statt 96 – und optimiert an der falschen Stelle.
+101 -1
View File
@@ -1,5 +1,104 @@
# Qualitätsbericht – erster ausführbarer Prototyp
## Ergänzung: Autorotation, Blättern, eigene Texte, 13.09.2026
- **395 PHP-Tests / 1672 Assertions** unter PHP 8.2.12 und 8.4.22, 18 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung. axe: null Verstöße auf Startseite, Projekteseite, Beitrag.
- **Die Autorotation ist als Verlauf geprüft, nicht als Schnappschuss:** 0 → 666 → 1332 → 1998 in drei Schritten zu je sieben Sekunden, und nach einem Mausrad-Ereignis bleibt der Wert stehen. Dabei fiel der Fehler der ersten Fassung auf: das Sprungziel wurde aus `offsetLeft` berechnet und der Offset des Sliders doppelt abgezogen – Ergebnis −78, also keine Bewegung.
- Belegt ist außerdem, dass das Skript **nur auf Seiten mit einem solchen Slider** geladen wird und die Richtlinie sonst bei `script-src 'none'` bleibt.
- **Blättern in der Beitragsliste** mit sieben Beiträgen geprüft: erste Seite ohne „Neuere“, letzte ohne „Ältere“, Seite 99 landet auf der letzten, und ohne den Schalter bleibt es eine Auswahl ohne Leiste.
- **Acht Projekttexte sind geschrieben, nicht übernommen** – für die Beiträge, die auf der laufenden Website keinen Text haben (zwei davon nicht einmal einen Auszug). Sie beschreiben Aufgabe, Weg und Ergebnis, ohne Zahlen, Namen oder Zusagen zu erfinden. **Sie gehören vor einem Livegang gegengelesen**, denn sie sind meine Formulierung über Umuts Projekte.
- **Nicht geprüft:** die Autorotation auf einem echten Touchgerät und in Firefox oder Safari; gemessen ist sie im Chromium der Browserprüfung.
## Ergänzung: Nachbau vollständig, 13.09.2026
- **390 PHP-Tests / 1641 Assertions** unter PHP 8.2.12 und 8.4.22, 18 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung. **axe: null Verstöße** auf Startseite, Projekteseite, Datenschutzerklärung, Projektbeitrag und mobiler Projekteseite.
- **Der Nachbau ist jetzt vollständig:** 29 Projektbeiträge mit den echten Bildern, Titeln, Auszügen und Texten der laufenden Website, die vollständige Datenschutzerklärung (27 Überschriften, rund 22.000 Zeichen), Impressum, Startseite, Projekteseite. 41 Medien, 33 Seiten.
- **Ein Befund, der für den echten Umzug zählt:** **11 der 29 Projektbilder** hätte die Mediathek auf einem Hosting mit 128 MB abgewiesen, zwei davon (13,5 und 12,0 Megapixel) auch mit mehr Speicher – sie überschreiten die Pixelgrenze. Der Umzug muss solche Bilder vorher verkleinern; für den Nachbau ist genau das geschehen (9 Bilder auf 2400 px). Die Meldung der Mediathek ist dabei richtig und verständlich, sie kommt nur an einer Stelle, an der niemand mit ihr rechnet.
- Die Größe der **Kennzahl** und der **Kundenstimme** ließ sich nicht einstellen: `.block-stat strong` und `.block-quote p` hatten feste Werte, gegen die eine Blockeinstellung nicht ankam. Beide erben jetzt vom Block – dieselbe Familie wie die Theme-Skala, die die Kartenüberschriften überschrieb.
- Gemessen: Startseite 56 KB HTML mit 15 Bildern, Projekteseite 32 KB mit 13, Datenschutzerklärung 38 KB. **Kein ausführbares Skript** auf keiner Seite; das einzige `<script>` ist strukturierte Daten als `application/ld+json`, und die Richtlinie sendet weiterhin `script-src 'none'`.
- **Nicht geprüft:** die Projektbeiträge sind inhaltlich übernommen, nicht redaktionell überarbeitet – einzelne Texte der laufenden Website sind kurz oder fehlen dort ganz (8 von 29 haben nur den Auszug). Und nichts davon steht auf echtem Hosting.
## Ergänzung: Detailabgleich mit dem Original, 13.09.2026
- **390 PHP-Tests / 1641 Assertions** unter PHP 8.2.12 und 8.4.22, 18 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung. **axe: null Verstöße** auf Startseite, Projekteseite und Handybreite des Nachbaus.
- **Der ernsteste Fund ist eine beinahe entstandene Lücke:** beim Freigeben von Auszeichnungen in Überschriften war die Dokumentfassung in der Schliessung des Filters nicht gebunden – der Filter lief nie, während die Ausgabe schon unescaped war. Ein Test, der `<script>alert(1)</script>` in eine Überschrift schreibt, hat es im ersten Lauf gezeigt. Er prüft jetzt beides: das hervorgehobene Wort kommt durch, das Skript nicht.
- Gegen das Original nachgemessen statt geschätzt: Kartenbreite 316 px (Original 324), Kartenhöhe 342 (330), Kartentitel 19 px/800 in beiden, Überschrift zweiter Ebene 44 px in beiden, Hauptüberschrift 66 px (68).
- **Nicht geprüft:** Punkte am Slider in Firefox und Safari – dort greift der Rückfall auf ein wischbares Schiebefeld, gesehen habe ich ihn in diesen Browsern nicht.
## Ergänzung: Nachbau überarbeitet, Dokumentschema v17, 13.09.2026
- **389 PHP-Tests / 1636 Assertions** unter PHP 8.2.12 und 8.4.22, **18 JavaScript-Modelltests**, Playwright-Editortest, öffentliche Browserprüfung.
- **Der schwerste Fund des Tages kommt aus dem Editor, nicht aus dem Nachbau:** in einem Dokument ab v13 ließ sich kein Baustein mehr einfügen, und Symbol, Fortschritt, Slider und eingebetteter Inhalt in gar keinem. Der bestehende Editortest hat das nicht bemerkt, weil er ein altes Dokument bearbeitet. Der neue Test fügt jeden Typ in Dokumente von v1 bis v17 ein.
- **Ein zweiter, der nur in der Gegenprobe sichtbar wird:** `|default(true)` macht in Twig aus einem gespeicherten `false` ein `true`. Die neuen Anzeigeoptionen der Beitragsliste hätten sich speichern lassen und nichts getan – dieselbe Krankheit wie Rahmenstärke und Bildrundung. Der Test prüft beide Richtungen.
- Neu geprüft: verlinktes Bild (mit denselben Linkregeln, sechs Fälle), klebender Kopfbereich als Einstellung, Projektraster gegen Blogarchiv.
- Am Nachbau nachgemessen: Lösungskarte 213 px statt 300, Abstand zur Projektliste 54 px, Projektkarte 337 px mit Bild und Titel – das Original liegt bei 324 px.
- **Nicht geprüft:** ob der klebende Kopfbereich auf einem Telefon mit dynamischer Adressleiste gut aussieht, und der Slider ist nicht auf einem echten Touchgerät gewischt worden.
## Ergänzung: kant-it.de nachgebaut, 12.09.2026
- **381 PHP-Tests / 1619 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung.
- **Der Nachbau ist die Prüfung:** Startseite mit neun Abschnitten, Projekteseite, vier Beiträge, Impressum, Datenschutzgerüst, Kopf- und Fußbereich, zwei Menüs, Kontaktformular, zwölf Bilder, eigene Palette und Schrift – gegen eine eigene Datenbank und einen eigenen Medienordner, nicht gegen die Entwicklungsinstallation.
- **Gefunden dabei: fünf Fehler** (ADR 042), darunter der größte: die Palette färbte Symbole, Knöpfe, Rahmen und den Einwilligungsdialog überhaupt nicht. Vier davon sind behoben und durch Tests abgesichert, der fünfte war mein eigener Kontrastfehler beim Beheben des ersten.
- **axe: null Verstöße** auf Startseite, Projekteseite, Beitrag und Impressum, in 1440 px und 390 px – gemessen an der nachgebauten Seite, nicht an der Demo.
- Gemessen am Nachbau: Startseite 45 KB HTML, 33 KB CSS, rund 430 KB Bilder in WebP, **0 Skripte**, 21 Abfragen. Die Abfragezahl ist die höchste bisher gemessene und erklärt sich aus der Beitragsliste; sie liegt unter der Obergrenze, ist aber der nächste Kandidat fürs Zusammenfassen.
- **Nicht geprüft:** die vollständige Datenschutzerklärung der laufenden Website (nur das Gerüst), die restlichen Projektbeiträge, die Produktseiten von HausKlar und KantTresor, und nichts davon auf echtem Hosting. Der Nachbau ist ein Abbild, keine Migration: die Inhalte sind neu geschrieben, nicht aus WordPress exportiert.
## Ergänzung: Installer, 12.09.2026
- **379 PHP-Tests / 1606 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung.
- **Die Abnahme des Pakets ist ein Test, kein Bericht:** ein Test geht die sechs Bildschirme durch die echte HTTP-Schicht, mit einem Kernel in einem wegwerfbaren Projektverzeichnis – leere Adresse → Weiterleitung auf `/install`, Datenbank, Migration, Website, Konto, fertig. Danach antwortet `/install` mit **404** und `/` mit **200**, und auf der Startseite steht der Name, den das Formular bekommen hat. Die Migrationen darin sind die echten.
- **Von Hand im Browser nachgestellt**, gegen eine Kopie der Anwendung in einem leeren Verzeichnis mit leerer Datenbank: erste Anfrage 302 auf `/install`, Systemprüfung, Datenbank (SQLite), 13 Migrationen, Website „Tischlerei Berg“. Den letzten Schritt – das Konto – habe ich nicht im Browser ausgefüllt, sondern dem Test überlassen; ich tippe keine Passwörter in Formulare.
- **axe auf allen vier erreichbaren Installationsbildschirmen: null Verstöße** (wcag2a, wcag2aa, wcag21a, wcag21aa).
- **Das Releasearchiv wurde entpackt und gestartet**, nicht nur gebaut: `kantsites-1.3.0.zip` in ein leeres Verzeichnis, Server darauf, erste Anfrage → Installer. Damit ist belegt, dass `vendor/` vollständig ist und auf dem Zielsystem weder Composer noch eine Konsole gebraucht wird.
- Die einzelne `install.php` ist dort geprüft, wo sie gefährlich wäre: neun Archiveinträge, die aus dem Zielverzeichnis ausbrechen wollen (`../`, absolut, Windows-Laufwerk, Rückwärtsschrägstrich, Nullbyte), landen nirgends – und ein Archiv mit einem solchen Eintrag bricht ab, ohne die Datei außerhalb anzulegen. Der Eintrag im Testarchiv ist echt: ZIP speichert `kantsites-1.2.0/../entwischt.txt` unverändert.
- **Nicht geprüft:** der Download selbst. Solange das Archiv nicht unter seiner Adresse liegt, ist die Kette „Datei hochladen → lädt → entpackt“ nur bis zum Entpacken belegt. Ebenso ungeprüft: MySQL als Zieldatenbank im Installer (der Verbindungstest ist da, ein echter MySQL-Server war es nicht), echtes Hosting, und ein Screenreader.
## Ergänzung: Bildgrößen und Releasearchiv, 12.09.2026
- **352 PHP-Tests / 1479 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung.
- **Der Browsertest hat den Fehler gefunden, den die Unittests nicht sehen konnten:** der Dokumentprüfer ließ höchstens **drei** `sources` je Bild zu. Mit der vierten Variante war jedes Dokument mit einem Bild darin ungültig – und eine abgewiesene Speicherung sieht im Editor so aus, als wäre nichts passiert. Gefunden wurde er, weil ein Galeriebild im Vorschaurahmen nicht erschien. Die Grenze hängt jetzt an `MediaLibrary::WIDTHS` statt an einer Zahl, und ein Test prüft beide Seiten: vier Quellen werden angenommen, fünf abgewiesen.
- **Die Bildpipeline ist nachgerechnet, nicht behauptet:** ein Test lädt ein erzeugtes Foto hoch, öffnet jede entstandene Datei und prüft, dass sie WebP ist, dass die vier Breiten 480/960/1600/2400 entstehen und dass das Seitenverhältnis erhalten bleibt. Ein zweiter Upload mit 700 px Breite bekommt zwei Stufen – 480 und 700 –, womit „nie hochskalieren“ geprüft ist und nicht nur im Kommentar steht.
- **Ein Testfehlschlag, der keiner war:** derselbe Test fiel um, sobald er in der gesamten Suite lief, mit der Meldung „dieses Bild benötigt mehr Arbeitsspeicher als das Hosting bereitstellt“. Das ist die Schutzprüfung vor dem Decodieren, die den bereits belegten Speicher mitrechnet – sie hat richtig gehandelt. Der Test benutzt jetzt eine kleinere Quelle und hebt das Limit für seine Dauer an, weil sein Gegenstand die Pipeline ist und nicht die Schutzprüfung.
- Hintergrundbilder sind je Gerät geprüft: die Desktopregel nennt die größte Variante, die Regel im `max-width:767px`-Block die 960er.
- **Das Releasearchiv ist gebaut, nicht geplant:** `kantsites-1.2.0.zip`, 3465 Dateien, 4,4 MB, SHA-256 `950eaa4c…`. Nachgesehen wurde, was **nicht** drin ist: kein PHPUnit, keine Tests, kein `var/`, kein `.env`.
- Der Abbruch bei geänderten Abhängigkeiten ohne Composer ist mit zwei Tests belegt: geänderte `composer.lock` ohne Composer bricht ab und stellt den alten Stand wieder her; unveränderte `composer.lock` läuft durch.
- **Nicht geprüft:** das Archiv ist auf keinem echten Hostingpaket entpackt und gestartet worden – das gehört zu den Arbeitspaketen J und N. Der Installer, der es herunterlädt, existiert noch nicht.
## 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.
- **Der Nachweis, um den es geht:** Sicherung anlegen, eine Seite und eine Bilddatei löschen, zurückspielen – beides ist wieder da. Einmal als Test gegen eine wegwerfbare Installation, einmal von Hand auf der Entwicklungsinstallation mit einer eigens angelegten Testseite (5 Seiten → gesichert → gelöscht, 4 → zurückgespielt, 5 → Testseite entfernt, 4). Damit ist der älteste offene Punkt aus Arbeitspaket I erledigt: „Eine Wiederherstellung ist nachgewiesen.“
- Geprüft ist außerdem, dass die Kopie der Datenbank eine **benutzbare** Datenbank ist, nicht nur eine Datei, die existiert: der Test öffnet sie und zählt die Seiten darin.
- Eine manipulierte Sicherung wird abgewiesen, bevor irgendetwas angefasst wird – nicht einmal die Sicherheitskopie entsteht. Eine Sicherung aus einer neueren Fassung wird abgewiesen, mit `--force` geht sie durch. Rotation behält die neuesten und meldet, was sie entfernt hat.
- **Ein Fall, den erst der Test zeigte:** zwei Sicherungen in derselben Sekunde. Nicht akademisch, denn die Wiederherstellung legt selbst eine Sicherheitskopie an – die landet in derselben Sekunde wie die Sicherung, die gleich eingespielt wird. Statt die zweite scheitern zu lassen, bekommt der Name eine Nummer.
- **Nicht geprüft:** MySQL (dort fehlt das Sichern über `mysqldump`, der Code sagt es), sehr große Medienordner, und ein Download der Sicherung über den Browser gibt es bewusst noch nicht.
## Ergänzung: Breite, Menüs, Seiten ohne Kopfbereich, 12.09.2026
- **336 PHP-Tests / 1354 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung.
- **Ein bestehender Test ist absichtlich umgeschrieben worden**, und das ist erwähnenswert: er sicherte zu, dass ein zweiter Kopfbereich abgewiesen wird. Diese Regel war richtig, solange keine Seite wählen konnte, und wurde falsch, sobald sie es kann. Der Test prüft jetzt die neue Regel – mehrere Bereiche mit eigener Adresse, doppelte Adresse weiter abgewiesen, Standardbereich nicht löschbar.
- **Der dritte Fehler des Tages aus derselben Familie:** die globale Seitenbreite ließ sich im Designbereich einstellen, aber `site.css` deckelte bei 1180 px. Schmaler wirkte, breiter nicht. Wie bei der Rahmenstärke und der Bildrundung: eine Einstellung, die sich speichern lässt und nichts tut, ohne jede Fehlermeldung. Ein Test liest jetzt die Regel aus `site.css` und prüft, dass sie den Theme-Wert nimmt.
- Seitenbereiche sind über echte Auslieferung geprüft: mit „keiner“ steht weder `site-header` noch `site-footer` im HTML, der Seiteninhalt aber schon; ein Verweis auf einen gelöschten Bereich fällt auf den Standard zurück.
- **Nicht geprüft:** der Burger ist nicht auf einem Touchgerät bedient worden, und kein Screenreader war beteiligt. Die vier Menüplätze sind im Test benutzt, aber nicht alle vier von Hand in der Menüverwaltung durchgeklickt.
## Ergänzung: Designer ausgebaut, 12.09.2026
- **315 PHP-Tests / 1309 Assertions** unter PHP 8.2.12 und 8.4.22, 16 JavaScript-Modelltests, Playwright-Editortest (axe auf dreizehn Bildschirmen, null Verstöße), öffentliche Browserprüfung.
- Die Gestaltungseigenschaften sind nicht „vorhanden“, sondern einzeln nachgerechnet: 16 Datensätze vergleichen die erzeugte Deklaration Zeichen für Zeichen mit der erwarteten. Das ist die Prüfung, die die beiden Fehler unten gefunden hätte, wenn es sie vorher gegeben hätte.
- **Zwei Fehler, die vorher niemand gesehen hat.** Erstens: eine Rahmenstärke je Seite wurde zu `border-width-top`. Das ist kein CSS, der Browser verwirft die Zeile stillschweigend – Rahmenstärken je Seite hatten **nie** eine Wirkung, und es gab keine Fehlermeldung, die darauf hingewiesen hätte. Zweitens: eine Rundung auf einem Bildblock landete auf dem `figure`, das Bild darin blieb eckig. Beides ist genau das, wonach Umut gefragt hat – er hat zwei echte Defekte gefunden, indem er die Funktion benutzen wollte.
- Ein Test hält das Schema und das Editorpanel zusammen: jede Gruppe im Schema muss im Panel einen Namen haben, sonst landen ihre Eigenschaften in einem unbenannten Aufklappfeld.
- Die drei neuen Bausteine sind über echte Seitenauslieferung geprüft, nicht nur über den Validator: Markup, Rollen, Klassen – und dass die Seite weiterhin `script-src 'none'` sendet, denn keiner von ihnen braucht ein Skript.
- **Nicht geprüft:** kein Screenreader. Der Slider ist nicht mit Touch auf einem echten Gerät bedient worden, nur mit Tastatur und Mausrad im Browser. Und der Vergleich mit Flatsome ist Wissen, keine Messung – die Erweiterung war nicht installierbar, das steht so im Vergleichsdokument.
## Ergänzung: Updater, 12.09.2026
- **285 PHP-Tests / 1244 Assertions** unter PHP 8.2.12 und 8.4.22, Playwright-Editortest (axe auf dreizehn Bildschirmen, null Verstöße), öffentliche Browserprüfung.
@@ -7,7 +106,8 @@
- Der wichtigste davon ist der Fehlschlag: die neue Fassung bringt ein Migrationsskript, das mit Exitcode 1 endet. Geprüft wird danach, dass der Code auf dem alten Commit steht, die Datenbank den Inhalt von vor dem Update enthält, der Wartungszustand aufgehoben ist (weil der alte Stand antwortet) und im Protokoll `FEHLER` steht.
- Die Reihenfolge der Schritte ist als Liste festgehalten, nicht nur ihr Ergebnis: Vorprüfung, Ziel, Sicherung, Wartung, Checkout, Fassung prüfen, Abhängigkeiten, Migration, Cache, Rauchtest, Fertig. Wer sie umstellt, bricht den Test.
- Geprüft ist außerdem, dass die Sicherung vor dem Checkout entsteht, dass sie die Datenbank enthält und den Medienordner **nicht**, und dass Datenbank und Medienordner der Installation unverändert bleiben.
- **Am echten Repository durchgespielt**, nicht nur im Test – siehe den Bericht in STATUS: Tag v1.0.0 auf `git.kant-it.de` gesetzt, Installation darauf gestellt, v1.0.1 veröffentlicht, Update ausgeführt.
- **Am echten Repository durchgespielt**, nicht nur im Test: `git.kant-it.de/kantarci/KantSites`, Tag `v1.0.0` gesetzt, die Entwicklungsinstallation darauf gestellt (`git checkout v1.0.0`), `v1.0.1` veröffentlicht und `php bin/console cms:update` ausgeführt. Ergebnis: elf Schritte, alle ok, in dieser Reihenfolge – Vorprüfung, Ziel (v1.0.1 von 52f0ab38, Fassung 1.0.0), Sicherung (220 KB nach `var/backups/update-20260912-181159`, darin `cms.db`, `.env`, `.env.local`, kein Medienordner), Wartungszustand, Checkout, Fassung geprüft, Abhängigkeiten (Composer fehlt, vendor/ bleibt liegen), Migration („Already at the latest version“), Cache, Rauchtest („Datenbank erreichbar, Migration 20260912001200, Startseite /home lesbar, Fassung 1.0.1“), fertig. Danach: `VERSION` 1.0.1, `git describe` v1.0.1, kein Wartungszustand, `/` antwortet 200, `var/updates.log` enthält `OK v1.0.1 (11 Schritte)`.
- Der Wartungszustand ist danach einzeln geprüft: eingeschaltet antwortet `/` mit **503 und `Retry-After: 600`** und der Wartungsseite, `/admin/login` mit 200; ausgeschaltet wieder 200.
- **Nicht geprüft:** MySQL (dafür fehlt die Installation, siehe `docs/UPDATE.md`, „Was anders wurde“), ein Hosting ohne `proc_open` (die Vorprüfung meldet es, der Fall selbst ist nicht durchgespielt), und ein Update über die Weboberfläche unter einem knappen Zeitlimit.
## Ergänzung: Statistik, 12.09.2026
+78 -2
View File
@@ -1,9 +1,19 @@
# Projektstatus
Stand: 12.09.2026. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **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: 13.09.2026, Fassung **1.4.6**. 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.
**Für den Livegang vorbereitet (13.09.2026):** `docs/LIVEGANG.md` trennt, was geprüft ist, von dem, was nur auf dem echten Hosting geprüft werden kann, und nennt die Reihenfolge am Umzugstag. **14 Weiterleitungen** von den alten WordPress-Adressen auf die neuen sind angelegt und beantwortet (`/productsolutions` → `/projekte`, die gekürzten Beitragsadressen, `/category/allgemein`); ohne sie hätte jede bei Google stehende Adresse nach dem Umzug mit 404 geantwortet. In der Datenschutzerklärung wurde der Abschnitt über Google Fonts an die Wirklichkeit angepasst (die Schriften kommen jetzt vom eigenen Server) und die Abschnitte über Google Maps und reCAPTCHA entfernt, weil beides nicht eingebunden ist – eine Erklärung, die Datenflüsse beschreibt, die es nicht gibt, ist so falsch wie eine, die welche verschweigt. **Nicht geprüft und nicht prüfbar von hier: der E-Mail-Versand.**
**Verwaltung und Updateweg (1.4.3, 13.09.2026):** Die Farbfelder im Designbereich haben einen **Farbwähler** samt Palettenauswahl; das Textfeld bleibt maßgeblich, damit `@akzent` ein Verweis bleibt und nicht beim ersten Klick zu einem festen Hexwert erstarrt (ADR 066). Die Verwaltung ist **auf dem Telefon bedienbar**: das Menü ist unter 760 px ein zugeklapptes `<details>`, das den aktuellen Bereich nennt, mit 44 px hohen Zielen – ohne Skript. Die zwölf Navigationssymbole sind **eigene SVG-Pfade** statt Unicode-Zeichen, die ein Telefon zu bunten Emoji macht (ADR 067). Und es gibt ein **Update ohne Git**: `ArchiveUpdate` lädt das Releasearchiv, vergleicht die Prüfsumme, sichert, ersetzt die Dateien und führt die Migrationen in einer zweiten Anfrage aus – der einzige Updateweg auf geteiltem Hosting (ADR 068, `docs/UPDATE.md`).
**Nachbau nachgezogen (13.09.2026):** Der Fußbereich ist jetzt vierspaltig wie im Original – Zeichen und Satz, „Leistungen", „Unternehmen", „Kontakt", darunter die Zeile mit Copyright. Die beiden Linklisten sind **Menüs**, keine Dokumentinhalte: sie lassen sich in der Verwaltung pflegen. Alle **29 Beiträge haben jetzt Text** – die elf fehlenden sind neu geschrieben, denn auch im Original stehen dort nur Bildergalerien ohne Beschreibung. Die Karte „Bereit für den nächsten Schritt" hat 34 px Abstand zu den Kontaktzeilen darunter. **Nicht erledigt: das TeamViewer-Logo** im Fernwartungsbereich – dazu unten.
**Vom Original übernommen (13.09.2026):** die Meta-Beschreibung der Startseite im Wortlaut von kant-it.de, das Symbol der Website (dort 192 × 192) und die Bot-Regeln der robots.txt. Titel, Canonical, Open Graph, strukturierte Daten (`LocalBusiness` statt Yoasts `ProfessionalService`), Sitemap, robots.txt und `llms.txt` entstehen in KantSites aus dem Inhalt und stimmen mit dem Original überein. **Beim Abgleich aufgefallen:** das `og:image` des echten kant-it.de zeigt auf `wp-content/uploads/kant-it-share.png` – die Datei antwortet mit 404, beim Teilen erscheint dort also kein Bild.
**Im Nachbau von kant-it.de gefunden und behoben:** vier tote Verweise im Hero-Slider – die Adressen waren im Aufbauskript von Hand geschrieben, die Beiträge bekamen längere. Sie werden jetzt aus derselben Regel abgeleitet, mit der die Beiträge entstehen, und ein zweiter Lauf des Skripts aktualisiert vorhandene Seiten, statt sie zu überspringen. Eine Prüfung aller internen Verweise auf 404 gibt es im CMS noch nicht; gefunden wurden sie mit einem einmaligen Durchlauf im Browser.
## Erledigt
- `AGENTS.md` und `ENTWICKLUNGSPLAN.md` unverändert aus Downloads im Projektstamm abgelegt; SHA-256 jeweils mit dem Original identisch.
@@ -19,12 +29,70 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
- Drei bearbeitbare Abschnittsvorlagen (Intro, zwei Leistungen, Kontaktaufruf), einklappbarer Werkzeugkasten, Struktursuche mit Vorfahren, einklappbare Teilbäume, Pfadnavigation und dauerhaft erreichbare Blockaktionen.
- Dokumentformat v2, erhaltender idempotenter v1-Konverter, gemeinsame Tokenliste für Validator/Editor/CSS und dokumentierter Migrations-/Rückweg. Bestehende v1-Seiten werden beim Öffnen nicht geändert; keine neue DB-Migration erforderlich.
- Dokumentformat v3 für Galerie, Bildbeschriftungen/-varianten und Effekte, mit erhaltendem Upgrade ohne Downgrade und Undo. v1/v2 bleiben unterstützt. Schatten, Hover-Anheben/Bildzoom und Einblenden beim Laden ohne öffentliches JavaScript; Bewegungsreduktion wird berücksichtigt.
- Medienverwaltung im Editor und unter `/admin/media`: JPEG/PNG/WebP-Upload, echte Bildvalidierung, Größen-/Pixel-/Speichergrenzen, WebP-Varianten bis 480/960/1600 px, Suche, Titel/Alt-Text, Archivieren/Wiederherstellen und Schreibkonflikterkennung. Bildauswahl für einzelne Bilder und Galerien, kontextbezogene Alt-Texte/dekorative Verwendung und Beschriftungen.
- Medienverwaltung im Editor und unter `/admin/media`: JPEG/PNG/WebP-Upload, echte Bildvalidierung, Größen-/Pixel-/Speichergrenzen, WebP-Varianten bis 480/960/1600/2400 px (ohne Hochskalieren, Hintergrundbilder geräteabhängig), Suche, Titel/Alt-Text, Archivieren/Wiederherstellen und Schreibkonflikterkennung. Bildauswahl für einzelne Bilder und Galerien, kontextbezogene Alt-Texte/dekorative Verwendung und Beschriftungen.
- Neue Medientabelle durch Migration `20260912000300` lokal angewendet. Vorher konsistentes SQLite-Backup unter `var/backups/before-media-20260912-051258.db`. Bestehende Seiten/Benutzer nicht geändert. Lokal läuft PHP 8.4 mit 8M Upload-, 10M POST- und 256M Speicherlimit; originale Uploads werden nicht gespeichert.
- Speichern/Laden von Entwürfen, separates Veröffentlichen mit Bestätigung, sichtbare Speicher-/Fehler-/Konfliktzustände, JSON-Sicherung und bewusster Server-Neuladedialog. Keine Tests schreiben in den echten Seitenbestand.
- 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.
- **Slider mit Punkten ohne Skript** (ADR 044): `::scroll-marker` zeichnet je Folie einen Punkt, der aktive ist erkennbar, die Rollleiste entfällt; ältere Browser behalten das wischbare Schiebefeld. Beitragskarten sind echte Karten (Bild 16:10, 18 px Ecken). Überschriften dürfen ein Wort hervorheben (`<mark>`, in Akzentfarbe). Behoben: die Theme-Schriftskala überschrieb die Größen in Karten und Aufruf-Bändern.
- **„Weiterlesen" ist jetzt ein Link.** In der wechselseitigen Beitragsliste stand dort eine reine Beschriftung: sie sah aus wie ein Knopf und war keiner. Der Titel steckt für Vorlesesoftware im Linktext, damit nicht zwölf Links gleich heißen.
- **Behoben in 1.4.5: die Verwaltung zeigte Git-Fehler auf einer Installation ohne Git** (ADR 072). `git` installiert und „Arbeitskopie" waren derselbe Schalter; auf Umuts Hosting ist Git vorhanden, die Installation kam aber aus dem Releasearchiv. Dort stand `fatal: Not a git repository` an der Stelle der Fassungsnummer und eine rote Vorprüfung für Bedingungen, die gar nicht gelten. Ohne Arbeitskopie verschwinden die Git-Karten jetzt.
- **Behoben in 1.4.5: „Sicherung anlegen" endete mit 500** (ADR 072). `VACUUM INTO` gibt es erst seit SQLite 3.27; Umuts Hosting hat eine ältere Fassung. Bei älteren wird unter einer Schreibsperre kopiert, und der Sicherungsknopf fängt jeden Fehler ab, statt mit einer 500-Seite zu enden.
- **Das Update schreibt mit** (1.4.6, ADR 071): Jeder Schritt des Archiv-Updaters geht sofort nach `var/updates.log` – während des Laufs, nicht am Ende. Auf Umuts Hosting war die Ergebnisseite tot, und damit war auch verloren, was der Updater berichtet hatte; genau so wurde der nächste Fehlschlag dann doch noch lesbar („`bin` ließ sich nicht beiseitelegen – der alte Stand wurde zurückgeschoben"). Die Ergebnisseite versteht zusätzlich die Schreibweise bis 1.4.4: Beim Update schreibt der **alte** Code die Schritte und der **neue** zeigt sie an.
- **Behoben in 1.4.5: der Updateknopf endete mit 500** (ADR 071). Auf Umuts Hosting gefunden, mit einer echten 1.4.3-Installation nachgestellt: Das Update lief vollständig durch – alle neun Schritte, Dateien getauscht, `var/` und `.env` unberührt –, aber die **Ergebnisseite** starb daran. Der Updater über Git nennt einen Schritt `step`, der über das Archiv nannte ihn `label`, beide legen ihn unter demselben Sitzungsschlüssel ab, und die Vorlage liest `step`. Damit war auch der zweite Knopf unerreichbar und die Website blieb im Wartungszustand. Zehn Tests prüften das Rückgabe-Array, keiner die Seite, die es anzeigt; der neue Test rendert sie und wurde gegengeprüft, indem der Fehler noch einmal eingebaut wurde.
- **Migrationen im Browser ausführen** (1.4.4, ADR 070): Wer ein Update von Hand über FTP einspielt, hat danach neuen Code und eine alte Datenbank. Der Bildschirm „System" zeigt jetzt eine Karte, sobald etwas aussteht, nennt die Namen und führt sie auf Knopfdruck aus – vorher ging das nur über eine Konsole, also genau das, was auf geteiltem Hosting fehlt. Der Weg von Hand steht Schritt für Schritt in `docs/UPDATE.md`.
- **Ein Fehlschlag beim Laden sagt, woran es lag** (1.4.4, ADR 070): „Die Releaseseite antwortet nicht" war drei Ursachen in einem Satz. Beim Gegenprüfen des Releases fiel auf, dass diesem PHP der **Zertifikatsspeicher** fehlt – auf einem Webhosting der häufigste Grund und der einzige, den man in einer Minute behebt. Die Meldung nennt ihn jetzt, und bei genau diesem Fehler wird der zweite Weg über den Dateizeiger versucht. Abgeschaltet wird die Prüfung nicht.
- **Das Hintergrundbild des ersten Abschnitts wird vorab geholt** (1.4.3, ADR 069): Auf der Startseite des Piloten ist das größte Element über der Falz kein `<img>`, sondern der Hintergrund des Heros – und der steht nur im Stylesheet, das der Browser erst nach dem HTML holt. Drei Vorabverweise mit denselben Gerätegrenzen wie der Compiler holen **genau die Variante, die die Regel auch nimmt**; ein `imagesrcset` hätte bei manchen Fensterbreiten ein zweites Bild geladen. Gemessen: LCP 1,3 s → 1,1 s, Leistung Desktop 96 → 97, Mobil 98 → 99 (auf dem Entwicklungsserver ohne Kompression). Dabei gefunden: dasselbe Bild wurde je Geräteregel neu abgefragt – die Mediathek merkt sich jetzt, wonach sie in dieser Anfrage schon gefragt wurde, **Startseite 19 → 15 Abfragen**.
- **Update aus dem Releasearchiv** (1.4.3, ADR 068): der Weg für Installationen ohne Git – bisher stand dort nur „Dieses Verzeichnis ist keine Git-Arbeitskopie". Zwei Knöpfe im Bildschirm „System": der erste lädt, prüft die Prüfsumme, sichert und ersetzt die Dateien, der zweite führt die Migrationen aus – in einer eigenen Anfrage, weil ein Prozess, der sich selbst ausgetauscht hat, mit dem alten Code weiterarbeitet. `var/`, `.env`, hochgeladene Schriften und das erzeugte Stylesheet-Bündel bleiben unberührt, `public/` wird dazu Datei für Datei zusammengeführt. Der alte Stand liegt danach unter `var/update/`. Geprüft mit acht Tests gegen eine Releaseseite im Speicher (`tests/ArchiveUpdateTest.php`), einer davon hält die Ersetzungsliste gegen die Mitnahmeliste des Releaseskripts.
- **Eigene Symbole in der Verwaltung** (1.4.3, ADR 067): zwölf SVG-Pfade statt Unicode-Zeichen, die auf dem Telefon als Emoji in fremdem Stil erscheinen. Ein Test findet Emoji-Zeichen in der Navigationsvorlage und zwei Einträge mit demselben Pfad – beides war vorhanden.
- **Verwaltung auf dem Telefon** (1.4.3, ADR 067): das Menü klappt unter 760 px zu und nennt in der Zusammenfassung den Bereich, in dem man steht; Ziele 44 px hoch, kein Skript, kein seitliches Scrollen. Geprüft bei 390 px.
- **Farbwähler im Designbereich** (1.4.3, ADR 066): Farbfeld und Palettenauswahl neben dem Textfeld, das maßgeblich bleibt – ein `<input type="color">` allein hätte `@akzent` in einen festen Wert verwandelt und die Verbindung zur Palette gekappt. Ohne JavaScript bleibt das Textfeld.
- **Behoben in 1.4.2: die Systemprüfung nennt jetzt die SQLite-Fassung.** Auf dem Hosting scheiterte die Installation im dritten Schritt mit „near \"(\": syntax error" – dort läuft SQLite in einer Fassung vor 3.16, und Doctrine liest das Schema mit Tabellenfunktionen (`pragma_table_info(...)`), die es erst seit 3.16 gibt. Die Prüfung nennt die gefundene Fassung vor der Wahl der Datenbank, und der Datenbankschritt bietet SQLite gar nicht erst an, wenn sie zu alt ist – mit dem Hinweis auf MySQL. Eine Voraussetzung, die erst mitten in der Installation auffällt, ist keine Prüfung wert.
- **Behoben in 1.4.1: dem Releasearchiv fehlten `migrations.php` und `cli-config.php`.** Auf dem Hosting brach die Installation deshalb im dritten Schritt ab – „The configuration file does not exist", genau dort, wo die Tabellen entstehen sollten. Die beiden Dateien standen weder in der Mitnahme- noch in der Ausschlussliste des Releaseskripts, also fiel niemandem auf, dass sie fehlen. Ein Test ordnet jetzt **jeden** Eintrag im Projektstamm einer der beiden Listen zu; was neu dazukommt und unbedacht bleibt, lässt ihn fehlschlagen. Der Installationsweg wurde anschließend vollständig durchgespielt: 3504 Dateien entpackt, 14 Migrationen ausgeführt, 15 Tabellen angelegt.
- **Fassung 1.4.0 ist veröffentlicht** und der Installationsweg einmal vollständig durchgespielt: `install.php` aus dem Release geladen (anonym, ohne Anmeldung), in ein leeres Verzeichnis gelegt, aufgerufen – es lädt `kantsites-1.4.0.zip` (4,7 MB, 3502 Dateien mit `vendor/`), prüft die SHA-256-Summe, entpackt und löscht sich selbst. Die frische Installation antwortet mit der Systemprüfung unter `/install`. **Das Repository ist dafür öffentlich** – ein privates liefert die Downloadadresse ohne Anmeldung nicht aus.
- **Die Verwaltung kann umziehen** (ADR 065): `php bin/console cms:admin:path meine-verwaltung` legt sie auf eine andere Adresse – geprüft wird, dass der Name frei ist und keine Seite verdeckt. Der Wert steht in `.env.local` und gilt für Routen, Firewall, Schutzkopfzeilen und Statistik. **In der `robots.txt` wird ein geänderter Name nicht genannt**; beim Standardnamen `admin` bleibt die Zeile, weil diese Adresse ohnehin bekannt ist. Die Systemseite zeigt die aktuelle Adresse an.
- **Dokumentschema v19**: Baustein **Rechtszeile** (Impressum, Datenschutz, „Einwilligung ändern") für den Fußbereich – gestaltbar und an der richtigen Stelle statt als heller Streifen unter einem dunklen Fuß; die eingebaute Leiste erscheint nur noch, wenn die Seite den Baustein nicht hat. Dazu **„Beim Klick"** je Bild (nichts / vergrößern / Adresse) und **„Bilder vergrößerbar"** je Galerie.
- **Bildbetrachter** (ADR 063): ohne JavaScript ein Verweis auf die große Bildfassung, mit JavaScript ein Betrachter über der Seite – `<dialog>` mit Blättern, Escape, Pfeiltasten und Zähler. Das Skript lädt nur auf Seiten, die ein vergrößerbares Bild enthalten.
- **Knopf „nach oben"** (ADR 062), abschaltbar und vollständig einstellbar: Seite, Form, Größe, Farben, Schwelle, Beschriftung. Ein Anker ohne Skript; eingeblendet über eine Scroll-Zeitachse, wo der Browser sie kennt.
- **Behoben: zwei Stylesheet-Regeln griffen seit drei Veröffentlichungen nicht mehr** (ADR 064). Beim Entfernen alter Regeln waren Selektorreste stehen geblieben; CSS nimmt dann die nächste Regel mit. Betroffen waren der Einwilligungsdialog und die Rechtszeile – beide standen ungestaltet unter dem Fuß. Ein Test prüft jetzt jede CSS-Datei auf abgebrochene Selektoren.
- **Menüs kosten nicht mehr je eine Abfrage.** Jedes Menü holte die Seitenliste selbst; mit vier Menüs im Fuß waren das vier gleiche Abfragen je Seitenaufruf. Jetzt einmal je Anfrage gemerkt – Startseite des Nachbaus **21 → 18 Abfragen**.
- **Behoben: die Vorschau zeigte beim Aufbau bis zu 1440 ms lang eine Seite ohne Gestaltung** (ADR 061). Die Stile wurden erst angewandt, wenn das letzte Bild geladen war. Jetzt sitzen sie, sobald das Dokument geparst ist, und der Rahmen bleibt bis dahin verdeckt. Die Farben selbst stimmten immer – Vorschau und veröffentlichte Seite geben nachweislich dieselben berechneten Werte.
- **Cloudflare Turnstile für Formulare, optional** (ADR 059): aus, bis in den Einstellungen beide Schlüssel stehen; dann erscheint die Prüfung in jedem Formular, und die Sicherheitsrichtlinie öffnet sich nur auf Seiten mit Formular für `challenges.cloudflare.com`. Antwortet Cloudflare nicht, wird die Anfrage trotzdem angenommen. Die eingebauten drei Prüfungen ohne Dritte bleiben unabhängig davon bestehen. Der Preis steht in der Verwaltung dabei: das Widget braucht JavaScript.
- **Behoben: die Fehlerseite eines Formulars hatte keine Gestaltung** (ADR 060). Wer ein Pflichtfeld vergaß, bekam eine nackte Seite – der Formularcode baute sie selbst auf und ließ Theme, Blockstile und Schriften weg. Jetzt rendert derselbe Controller wie sonst auch.
- **Die Schriften liegen im Repository** (`public/assets/fonts/`, mit `lato-OFL.txt` und `LIZENZ.md` daneben, wie die Open Font License es verlangt): auf einem frischen Server fehlt damit nichts, und es wird nie versehentlich von Google geladen. Andere Installationen holen ihre eigenen mit `cms:fonts:fetch`.
- **`docs/NEUE-WEBSITE.md`**: die Anleitung, mit der eine neue Kundenwebsite entsteht – Reihenfolge, Dokumentformat, Bilder, SEO, Prüfläufe, und am Ende die fünf Fehler, die in diesem Projekt immer wieder passiert sind.
- **Schutzkopfzeilen ergänzt und geprüft**: `Permissions-Policy` (Kamera, Mikrofon, Standort und Sensoren abgeschaltet) auf öffentlichen Seiten und in der Verwaltung; `X-Powered-By` wird entfernt – der Server nannte bisher in jeder Antwort seine genaue PHP-Fassung. Die Browserprüfung misst die Kopfzeilen jetzt, statt sie anzunehmen.
- **Behoben: der Editor kannte Schema v17, der Validator v18.** In einem aktuellen Dokument war die Beitragsnavigation nicht einfügbar. Ein Test liest die höchste Fassung jetzt aus dem Validator und vergleicht sie mit der des Editors – dieselbe Falle war schon bei v13 zugeschnappt.
- **Getrennter Titel für Suchergebnisse** (Migration `20260913000100`): jede Seite und jeder Beitrag kann einen eigenen `<title>` tragen, unabhängig von der Überschrift auf der Seite. Leer heißt weiterhin: der Titel der Seite gilt. Er zählt auch für `og:title`.
- **Schriften kommen vom eigenen Server** (ADR 057): ein Knopf im Designbereich oder `php bin/console cms:fonts:fetch` lädt die gewählten Google-Schnitte als WOFF2 nach `public/assets/fonts` und schreibt die `@font-face`-Regeln daneben. Damit lädt keine Seite mehr etwas von Google – das spart zwei fremde Verbindungen nacheinander (gemessen 820 ms) und die datenschutzrechtliche Angreifbarkeit gleich mit. Ohne Dateien bleibt es beim bisherigen Verweis.
- **Ein Stylesheet statt vier, und eine `.htaccess`, die etwas tut** (ADR 056): gebündelt, ohne Kommentare, mit Prüfsumme im Dateinamen; Kompression, ein Jahr Cache für Dateien mit Fassung oder Kennung in der Adresse, Schutz vor dem Ausliefern interner Dateien, HTTPS-Umleitung und HSTS vorbereitet und auskommentiert. Der Entwicklungsserver sendet dieselben Kopfzeilen, damit lokale Messungen etwas bedeuten.
- **Bilder in der Größe, in der sie erscheinen** (ADR 058): ein Hintergrundbild, das nur für „Desktop“ gesetzt wurde, gilt auf dem Telefon in der Telefongröße (gemessen 189 KB → 40 KB); `<img>` nennt seine feste Breite, wenn es eine hat, und sonst `sizes="auto"`; `fetchpriority="high"` liegt auf dem ersten Bild des Inhalts statt auf dem Logo im Kopfbereich.
- **Lighthouse gegen den Nachbau von kant-it.de** (`docs/quality/lighthouse-kantit.json`, vier Adressen, je drei Läufe, Median): Barrierefreiheit, Best Practices, SEO und „agentic browsing“ **100 auf allen geprüften Seiten, mobil wie am Schreibtisch**. Performance: Projekte-, Beitrags- und Impressumsseite **100/100**, Startseite **98 mobil / 96 am Schreibtisch** – dort liegen vier Sliderbilder und ein großflächiges Hintergrundbild über der Falz. Gemessen im Produktionsmodus mit denselben Kopfzeilen, die `public/.htaccess` setzt; keine Felddaten und keine Messung auf echtem Hosting.
- **Verweise werden geprüft** (`npm run test:links`): folgt der Sitemap, sammelt jeden internen Verweis samt Bildern, Stylesheets und Hintergrundbildern und meldet jede Antwort ab 400. Im Nachbau 408 Adressen, kein toter Verweis.
- **Das Vorschaubild beim Teilen erscheint jetzt tatsächlich** (ADR 056): `og:image` wird aus dem Feld der Seite aufgelöst, ersatzweise aus dem Beitragsbild – vorher speicherte das Feld und bewirkte nichts, keine KantSites-Seite hatte ein Teilen-Bild. Dazu `og:site_name`, `og:locale`, `og:image:alt`, `twitter:card` (klein ohne Bild, groß mit) und das Bild im strukturierten Datensatz eines Beitrags. Archive haben jetzt ebenfalls Open-Graph-Angaben.
- **Eigene robots.txt-Zeilen: 200 statt 30, Leerzeilen bleiben** (ADR 056), damit sich Regeln je Bot in Blöcken schreiben lassen – etwa welche KI-Crawler lesen dürfen. Ein Sitemap-Index ist bewusst nicht gebaut: nötig wird er ab 50.000 Adressen.
- **Beiträge sind optional** (ADR 053): ohne einen einzigen veröffentlichten Beitrag antworten `/beitraege` und `/feed.xml` mit 404, der Feedverweis fehlt im Kopf, die Sitemap kennt kein Archiv, und ein Beitragslisten-Baustein zeigt öffentlich nichts – der Hinweis „hier erscheinen Beiträge“ bleibt nur im Editor. Ein Kategorie- oder Schlagwortarchiv verschwindet ebenso, sobald es nichts mehr zu zeigen hat; der Begriff selbst bleibt. Dabei behoben: die Beitragsliste fragte je Beitrag einzeln nach Bild und Begriffen – jetzt je eine Abfrage für die ganze Liste, Startseite des Nachbaus **22 → 16 Abfragen**.
- **Unterstriche nur noch im Fließtext** (ADR 054), dort mit Schalter im Designbereich („Links im Fließtext unterstreichen“, Standard an). Karten, Menü, Kontaktzeilen, Rechtszeile und der „Ansehen“-Streifen über Kartenbildern haben keinen mehr; beim Überfahren erscheint er überall. Die aktuelle Seite im Menü wird über das Schriftgewicht kenntlich – die Akzentfarbe fiel im dunklen Fußmenü durch die Kontrastprüfung.
- **Eigenes Symbol der Website** (ADR 055): ein Bild aus der Mediathek in den Einstellungen wählen, beim Speichern entstehen daraus quadratische PNG-Dateien in 32 und 180 px (`/favicon.png`, `/apple-touch-icon.png`). Ohne Auswahl bleibt es beim mitgelieferten Zeichen.
- **Die Browserprüfung prüft jetzt jede Installation, nicht nur die Demo**: die geprüften Seiten kommen aus der Sitemap, Tastatur- und Ohne-JavaScript-Weg folgen dem ersten Verweis im Inhalt statt festen Linktexten der Demo. Das Ladebudget trennt Bilder (Inhalt) vom Beiwerk (≤ 12 Anfragen) und deckelt stattdessen das übertragene Gewicht (1,5 MB); Skripte werden gegen die Richtlinie der Seite gezählt, strukturierte Daten zählen nicht mit. Der Nachbau von kant-it.de besteht die vollständige Prüfung ohne Befund.
- **Einblendungen laufen beim Scrollen** (ADR 052): `animation-timeline: view()` hinter Bewegungsschranke und `@supports`; auf dem Scrollweg nur Bewegung, keine Deckkraft, damit Text außerhalb des Blicks lesbar bleibt. Die drei Beitragsvorlagen bringen sie mit.
- **Vier Darstellungen der Beitragsliste** (ADR 051): Karten, Liste, Kompakt und Wechselseitig – am Baustein einstellbar, dieselben Daten, nur andere Regeln.
- **Drei Aufbauten für neue Beiträge** (ADR 050): „Bildkopf“, „Klassisch“ und „Magazin“, wählbar beim Anlegen. Startpunkte, keine Schablonen – jeder Block bleibt änderbar.
- **Dokumentschema v18** (ADR 049): Baustein „Beitragsnavigation“ – Vor/Zurück zwischen Beiträgen derselben Kategorie, vom Renderer gefüllt, ohne Umbruch am Rand. Dazu Autorotation für den Slider (Sekunden, eigenes Skript nur auf Seiten mit einem solchen Slider) und Blättern in der Beitragsliste.
- **Dokumentschema v17** (ADR 043): eine Beitragsliste kann Datum, Auszug und Kategorie ausblenden und damit als Projektraster erscheinen; ein Bild kann verlinken (Logo auf die Startseite). Dazu die Einstellung „Kopfbereich beim Scrollen oben halten“. Behoben: im Editor ließ sich in Dokumenten ab v13 kein Baustein mehr einfügen.
- **Die Gestaltung färbt jetzt auch die Bausteine** (ADR 042): Symbole, Knöpfe, Rahmen, ruhiger Text, Fokusring und Einwilligungsdialog nehmen die Farben des Themes statt fester Werte aus `site.css`. Der Einwilligungsdialog lässt sich zusätzlich eigens färben über eine Palettenfarbe mit dem Schlüssel `consent`. Überschriften werden nicht mehr getrennt.
- **Installer (Arbeitspaket N, ADR 041):** `/install` führt in sechs Schritten von einer leeren Installation zu einer Website – Systemprüfung, Datenbank (SQLite oder MySQL, Verbindung sofort geprüft), Migration im Prozess ohne Konsole, Website mit Startseite, Kopf-/Fußbereich, Menü sowie Impressum und Datenschutzgerüst, erstes Administratorkonto, Abschlussliste. Solange keine Installation existiert, führt jede Adresse dorthin; sobald ein Konto existiert, antwortet `/install` mit 404. Davor liegt `scripts/install.php`: eine einzelne Datei, die das Releasearchiv mit fester Adresse und fester SHA-256 lädt, gegen ZIP-Slip gesichert entpackt und sich danach selbst löscht.
- **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.
- **Die globale Seitenbreite wirkt jetzt auch nach oben.** `containerWidth` im Designbereich gab es, aber `site.css` deckelte bei 1180 px – schmaler ging, breiter nicht. Dritter Fehler dieser Art an einem Tag: eine Einstellung, die sich speichern ließ und nichts tat.
- **Vollflächige Bilder und volle Bildschirmhöhe**: Abschnitte können **randlos** sein (ohne Begrenzung, ohne Rand, von Kante zu Kante) oder breit (1600 px). Für die Höhe gibt es jetzt die Einheiten **dvh, svh, lvh** – `100vh` ist auf dem Handy höher als der Bildschirm, `100dvh` ist das, was gemeint ist. Die Vorlage **„Vollbild-Hero“** setzt die ganze Kombination auf einen Klick: randlos, 100dvh (mobil 80dvh), Inhalt senkrecht zentriert, Hintergrundbild formatfüllend, dunkle Überlagerung, Überschrift, Text, Button.
- **Designer ausgebaut** (ADR 036, Dokumentschema v15): **93 Gestaltungseigenschaften** statt 50, je Gerät und teils für Hover. Neu: Bild-Eigenschaften, die auf das Bild *im* Block wirken (Ausschnitt, Bildmitte, Seitenverhältnis, Höhe, Ecken, Rahmen), Positionierung mit z-index, getrennte Zeilen- und Spaltenabstände, Flex-Eigenschaften, Innen- und Textschatten, acht Bildfilter, Mischmodus, Zusatzrahmen. Neun Einblendungen mit Verzögerung, sieben Hover-Effekte, alle innerhalb von `prefers-reduced-motion`. Drei neue Bausteine: **Symbol**, **Fortschritt** (mit `role="meter"`) und **Slider** als Scrollbereich mit Einrasten – ohne JavaScript, ohne Autoplay. „Zeile, 2/3/4 Spalten“ legt eine Zeile samt Spalten auf einen Klick an. Der Vergleich mit Flatsome steht in `docs/DESIGNER-VERGLEICH.md`, mit dem was fehlt und warum.
- **Zwei Fehler dabei gefunden und behoben:** Rahmenstärken je Seite erzeugten `border-width-top` – kein gültiges CSS, vom Browser stillschweigend verworfen, also **ohne jede Wirkung**. Und eine Rundung auf einem Bildblock rundete den Rahmen, nicht das Bild.
- **Update über Git** (ADR 035, Arbeitspaket N/UPDATE.md): `cms:update` und ein Bildschirm „System“ zeigen Fassung, Commit, verfügbaren Tag und eine Vorprüfung, die jede Bedingung einzeln nennt – unter anderem, ob Datenbank und Medienordner von Git ausgenommen sind. Der Ablauf: sichern, Wartungszustand (503 mit `Retry-After`, `/admin` bleibt offen), Checkout des Tags, Fassung prüfen, Composer, Migration, Cache, Rauchtest als eigener Prozess (`cms:health`). Scheitert ein Schritt, wird auf den vorherigen Commit zurückgesetzt, die Datenbank nur dann zurückgeschrieben, wenn die Migration schon begonnen hatte – und wenn der alte Stand antwortet, geht die Website wieder online. `--check`, `--dry-run`, `--to=v1.2.0` und `--maintenance=an|aus` gibt es auf der Konsole.
- **Statistik** (ADR 034, Arbeitspaket L): Aufrufe je Tag und Stunde, meistaufgerufene Seiten, Verweise von außen und nicht gefundene Adressen, als CSV exportierbar. Gespeichert wird **ein Zähler je (Tag, Stunde, Pfad, Status, Verweis-Domain)**, kein Datensatz je Aufruf – keine IP-Adresse, keine Kennung, kein Cookie, auch nicht gehasht. Deshalb braucht diese Messung keine Einwilligung. Diagramme zeichnet der Server als SVG, ohne Skript. Was sie nicht kann, steht auf dem Bildschirm: Aufrufe statt Besucher, keine Verweildauer, keine Absprungrate, kein Klickweg, kein Land. Nach 60 Tagen werden Stunden zu Tageswerten verschmolzen, nach 425 Tagen gelöscht (`cms:stats:prune`).
- **Einwilligung** (ADR 032, Dokumentschema v14): serverseitig gerendertes Banner ohne JavaScript, „Alle akzeptieren“ und „Nur notwendige“ gleich groß nebeneinander, Auswahl je Gruppe im Aufklappfeld. Die Entscheidung steht in einem eigenen Cookie – keine Kennung, kein Serverabgleich, ein halbes Jahr. Unter `/einwilligung` jederzeit änderbar, von jeder Seite verlinkt. **Der neue Block „Externer Inhalt“ lädt ohne Einwilligung nichts:** kein iframe, keine Adresse im Markup, `frame-src 'none'`. Der Platzhalter nennt den Anbieter und gewährt auf Knopfdruck genau diese Gruppe. Ist der Hinweis abgeschaltet, laden die Inhalte – sonst würde das Abschalten jede Seite stillschweigend leeren.
@@ -73,6 +141,12 @@ 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.
- Designer geprüft: 16 Datensätze prüfen, dass jede Eigenschaftsform genau die versprochene Deklaration erzeugt – Rahmen je Seite in der richtigen Schreibweise, Ecken, Positionierung, Flex, getrennte Abstände, Innenschatten, Textschatten, Filter in fester Reihenfolge, Mischmodus, und die Bild-Eigenschaften in einer zweiten Regel `#id img{…}`. Jeder davon muss außerdem den Validator überleben, sonst bietet der Editor etwas an, das nicht gespeichert werden kann. Ein Test hält Schema und Editorpanel zusammen: eine Gruppe, die das Panel nicht kennt, würde ihre Eigenschaften in einem unbenannten Aufklappfeld verstecken.
- Neue Bausteine geprüft: vier Darstellungen (dekoratives Symbol vor Screenreadern verborgen, sprechendes Symbol mit Text, Fortschritt mit `role="meter"` und Wert im Markup, Slider mit Klasse je Gerät) und acht abgewiesene Eingaben (Symbolgröße außerhalb der Liste, unbekanntes Symbol, sprechendes Symbol ohne Beschriftung, dekoratives mit Beschriftung, Fortschritt über 100, Fortschritt als Text, Slider mit sieben Folien, Slider ohne Gerätangabe). Keiner der drei braucht ein Skript: die Seite behält `script-src 'none'`.
- Updater geprüft: sechs Tests gegen echte Git-Repositorys in einem Temp-Verzeichnis – Vorprüfung benennt geänderte Dateien und eine nicht ausgenommene Datenbank, ein Update wechselt den Tag in der geprüften Reihenfolge und hebt den Wartungszustand auf, ein gescheiterter Schritt setzt Code **und** Datenbank zurück und bringt die Website auf dem alten Stand wieder online, ein Probelauf verändert nichts, ein unbekannter Tag wird beim Checkout abgewiesen, und eine bereits installierte Fassung ist kein Fehler. Zusätzlich am echten Repository durchgespielt (siehe QUALITY).
- Statistik geprüft: 32 Tests, darunter die Spaltenliste der Tabelle gegen die Zusage „keine Spalte, aus der eine Person hervorgeht“, 14 Anfragearten (Verwaltung, Bild, Stylesheet, Feed, Weiterleitung, POST, Googlebot, Überwachungsdienst, Headless, curl – alle nicht gezählt), sieben Verweisformen (nur der Host bleibt, die eigene Domain gilt nicht als Verweis), sechs Adressformen (Query verworfen, erfundene 404 gesammelt) und das Verschmelzen und Löschen alter Tage. Dazu durch echte Anfragen: fünf Aufrufe werden ein Zähler, ein 404 zählt nicht als Aufruf, Verwaltung und Feed zählen nicht.
- Einwilligung geprüft: 26 PHP-Tests, darunter sieben Cookiezustände (gewährt, abgelehnt, andere Gruppe, abgelaufen, ältere Fassung der Frage, Unsinn, unbekannte Gruppe) und zehn abgewiesene Einbettungsadressen (http, protokollrelativ, `javascript:`, `data:`, mit Port, mit Zugangsdaten, Leerzeichen im Host, ohne Titel, unbekannte Gruppe, zu geringe Höhe). Im Browsertest zusätzlich: Banner erscheint, kein iframe und keine Adresse im Markup, Einwilligen über den Platzhalter lädt den Inhalt, Widerruf hält ihn wieder zurück, axe ohne Verstöße auf Banner und Einwilligungsseite.
@@ -118,6 +192,8 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
- v3-Galerieseite mit echten optimierten Testbildern und Effekten: sechs Lighthouse-Läufe, alle fünf Kategorien jeweils 100; LCP-Median mobil 937 ms/Desktop 279 ms, CLS/TBT 0. Upload, Metadaten, Mehrfachauswahl per Tastatur, Bildbeschriftung, Sortier-Undo, Archivieren/Wiederherstellen, Bewegungsreduktion und öffentliche Bilder im isolierten Browserablauf bestanden. Keine axe-Verstöße in Desktop-/Mobil-Mediathek; Screenshots geprüft. Berichte: `docs/quality/editor-media-browser.json` und `editor-media-lighthouse.json`.
- Keine bekannten Dependency-Advisories im ausgeführten Composer-Audit/npm-Installationsaudit.
**Gemessen am Nachbau selbst (13.09.2026, Fassung 1.4.3):** je drei mobile und drei Desktop-Läufe auf `/`, `/projekte` und `/impressum` im Produktionsmodus, Median. Barrierefreiheit, Best Practices, SEO und agentisches Browsen überall **100**. Leistung: `/projekte` und `/impressum` **100/100**, die Startseite **99 mobil / 97 Desktop** – dort bleibt das blockierende Stylesheet (rund 300 ms erste Anzeige) das Letzte, und das ist zu einem guten Teil der Entwicklungsserver ohne Kompression und Cache-Kopfzeilen. Bericht: `docs/quality/kantit-lighthouse.json`. **Auf dem echten Hosting nachmessen** – dort greifen die Regeln aus `public/.htaccess`.
## Bekannte Einschränkungen
- 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.
+51 -2
View File
@@ -29,7 +29,7 @@ Jeder Schritt hat eine Abbruchbedingung. Bricht ein Schritt ab, bleibt der Wartu
3. **Sichern.** Datenbank kopieren (bei SQLite Datei, bei MySQL `mysqldump`), dazu `.env` und den aktuellen Commit-Hash, nach `var/backups/update-<zeitstempel>/`. Der Medienordner wird **nicht** mitkopiert – er ist zu groß, und kein Update schreibt darin. Das steht auch so in der Oberfläche, damit niemand die Sicherung für vollständig hält.
4. **Wartungszustand an.** Eine Datei `var/maintenance`. Öffentliche Anfragen antworten mit 503, einer erklärenden Seite und `Retry-After`; `/admin` bleibt erreichbar, sonst kann niemand das Update beenden. Suchmaschinen verstehen 503 als „später wieder“, nicht als „weg“.
5. **Code wechseln.** `git checkout --force <tag>`. Danach prüfen, dass `VERSION` den erwarteten Wert hat – erst das beweist, dass der Wechsel wirklich stattgefunden hat.
6. **Abhängigkeiten.** `composer install --no-dev --optimize-autoloader`, wenn Composer auf dem Server läuft. Wenn nicht – der wahrscheinliche Fall auf geteiltem Hosting – muss `vendor/` Teil des Tags sein. **Das ist die Entscheidung, die ansteht:** entweder Composer auf dem Server, oder ein Releasebranch, in dem `vendor/` mitgeliefert wird und den ein Build-Rechner erzeugt. Beides ist vertretbar, aber es muss vor dem ersten Livegang entschieden sein, weil der Updater danach anders aussieht.
6. **Abhängigkeiten.** `composer install --no-dev --optimize-autoloader`, wenn Composer auf dem Server aufrufbar ist. Wenn nicht – der wahrscheinliche Fall auf geteiltem Hosting – gilt: **ändert dieses Update `composer.lock`, bricht der Updater hier ab** und verweist auf das Releasearchiv, das `vendor/` mitbringt (`scripts/build-release.php`). Ändert das Update die Abhängigkeiten nicht, läuft es ohne Composer durch. Der Vergleich läuft über die Prüfsumme der Datei vor und nach dem Checkout, nicht über Vertrauen.
7. **Migrieren.** `doctrine-migrations migrate --no-interaction`. Die Migrationen sind bewusst nicht umkehrbar: `down()` würde Spalten entfernen, in denen Inhalte stehen. Der Rückweg ist die Sicherung aus Schritt 3, nicht ein `down()`.
8. **Cache.** `cache:clear --env=prod` und Aufwärmen. Der Grund steht in `docs/QUALITY.md`: ein laufender Prozess kann einen älteren kompilierten Container halten und dann Seiten mit 500 beantworten, obwohl der Code richtig ist. Bei PHP-FPM gehört ein Neuladen dazu.
9. **Rauchtest.** Intern `/`, `/admin/login` und `/sitemap.xml` abrufen und auf 200 prüfen, dazu eine Abfrage auf `page` und `revision`. Schlägt das fehl, gilt das Update als gescheitert, auch wenn jeder Schritt davor gemeldet hat, dass er fertig ist.
@@ -62,9 +62,58 @@ Der teuerste Fall ist ein Update, das durchläuft und erst Tage später als fals
- Kein `down()`-Rückweg für Migrationen. Der Rückweg ist die Sicherung, und das ist eine Entscheidung, nicht eine Lücke.
- Kein Update ohne Sicherung, auch nicht mit Häkchen „ich weiß, was ich tue“.
## Der zweite Weg: Update aus dem Releasearchiv
Alles bis hierhin setzt eine Git-Arbeitskopie voraus. Auf geteiltem Hosting gibt es die nicht – dort wird über `install.php` aus dem Releasearchiv installiert, und danach stand in der Verwaltung „Dieses Verzeichnis ist keine Git-Arbeitskopie". **`src/Ops/ArchiveUpdate.php` ist der Weg für genau diese Installationen** (ADR 068). Er steht im Bildschirm „System" als eigene Karte neben dem Update über Git.
Der Ablauf ist derselbe wie oben, nur ohne `git`:
1. **Fassung ermitteln.** Die Releaseliste nennt den neuesten Tag. Antwortet sie nicht – viele Hoster lassen keine ausgehenden Verbindungen zu –, endet es hier, und es wurde nichts angefasst.
2. **Sichern.** Datenbank und Medien nach `var/backups/`, vor dem ersten Handgriff.
3. **Archiv laden und Prüfsumme vergleichen.** Stimmt sie nicht, wird nicht entpackt. Die Adresse steht in der Konfiguration, nicht in der Anfrage.
4. **Entpacken und auf Vollständigkeit prüfen.** Fehlen `VERSION` oder `vendor/`, ist es kein brauchbares Release – und das fällt auf, bevor der Wartungszustand angeht.
5. **Wartungszustand an, Dateien ersetzen.** `bin`, `config`, `migrations`, `src`, `templates`, `vendor`, `composer.json`, `composer.lock`, `migrations.php`, `cli-config.php`, `VERSION`. Der alte Stand wandert nach `var/update/<zeitstempel>/alt/`, er wird nicht gelöscht.
6. **`public/` wird zusammengeführt, nicht ersetzt.** Dort liegen Dateien der Anwendung und Dateien des Betreibers nebeneinander: `assets/fonts` (hochgeladene Schriften) und `assets/build` (das erzeugte Stylesheet-Bündel) bleiben unangetastet.
7. **Zwischenspeicher leeren.**
8. **Zweiter Knopf: Migrationen.** Sie laufen in einer **eigenen Anfrage**, weil ein PHP-Prozess, der sich selbst ausgetauscht hat, mit den Klassen weiterarbeitet, die er beim Start geladen hat. Erst danach geht der Wartungszustand aus.
Unberührt bleiben in jedem Fall `var/`, `.env` und `.env.local`. Scheitert das Ersetzen mittendrin, schiebt der Updater zurück, was er bewegt hat, und nimmt den Wartungszustand wieder heraus.
**Geprüft wird ohne Netz** (`tests/ArchiveUpdateTest.php`): eine Releaseseite im Speicher, echte Zip-Archive, echte Prüfsummen, echte Umbenennungen in einem Wegwerfverzeichnis. Ein Test hält zusätzlich die Ersetzungsliste gegen die Mitnahmeliste von `scripts/build-release.php`: Was neu ins Archiv kommt, muss beim Update auch ersetzt werden.
## Der dritte Weg: von Hand über FTP
Der Weg für den einen Fall, in dem die Karte im Bildschirm „System" noch nicht existiert: **das Update auf die Fassung, die sie mitbringt.** Eine Installation der Fassung 1.4.2 kennt sie nicht – sie muss einmal von Hand nachgezogen werden, danach gibt es den Knopf.
1. **Sichern.** Verwaltung → System → „Sicherung anlegen". Zusätzlich per FTP `var/` und `.env` herunterladen. Migrationen sind nicht umkehrbar; der Rückweg ist die Sicherung.
2. **Archiv holen und auspacken.** `kantsites-<fassung>.zip` von der Releaseseite, auf dem eigenen Rechner entpacken. Darin liegt ein Ordner `kantsites-<fassung>/`.
3. **Wartungszustand einschalten.** Verwaltung → System → „Wartung". Sonst sieht ein Besucher währenddessen eine halb ersetzte Anwendung.
4. **Diese Einträge hochladen und ersetzen:** `bin`, `config`, `migrations`, `src`, `templates`, `vendor`, `composer.json`, `composer.lock`, `migrations.php`, `cli-config.php`, `VERSION`. Verzeichnisse ersetzen, nicht hineinkopieren – eine Datei, die es in der neuen Fassung nicht mehr gibt, muss weg.
5. **`public/` Datei für Datei** – und **`public/assets/fonts` und `public/assets/build` auslassen**. Dort liegen die hochgeladenen Schriften und das erzeugte Stylesheet-Bündel; beide gehören dem Betreiber und stehen in keinem Archiv.
6. **Nicht anfassen:** `var/`, `.env`, `.env.local`. Dort stecken Inhalte, Medien und `APP_SECRET`.
7. **`var/cache` vollständig löschen.** Sonst läuft die neue Fassung mit den Wegweisern der alten und antwortet mit 500, obwohl der Code richtig ist.
8. **Migrationen ausführen.** Verwaltung → System → Karte „Migrationen stehen aus" → Knopf. Die Karte erscheint nur, wenn welche ausstehen. Wer eine Konsole hat, kann stattdessen `php vendor/bin/doctrine-migrations migrate --no-interaction` aufrufen.
9. **Wartungszustand aus**, Startseite und Verwaltung einmal aufrufen, Fassungsnummer im Bildschirm „System" prüfen.
Das ist einmalig: Ab 1.4.3 kennt jede Installation den Weg über das Archiv, und dann entfällt das alles. Dann steht im selben Bildschirm die Karte „Update aus dem Releasearchiv", und die Schritte 2 bis 8 erledigt sie selbst.
## Ein Release veröffentlichen
`php scripts/build-release.php` baut auf einem Rechner mit Composer das Archiv – Anwendung plus `vendor/` ohne Entwicklungspakete – und schreibt die SHA-256-Summe in eine Kopie von `install.php`. Beides gehört anschließend an das Git-Release des passenden Tags:
```
var/releases/kantsites-<fassung>.zip → Release-Anhang
var/releases/kantsites-<fassung>.zip.sha256 → Release-Anhang
var/releases/install.php → Release-Anhang
```
`install.php` erwartet das Archiv unter `https://git.kant-it.de/kantarci/KantSites/releases/download/v<fassung>/kantsites-<fassung>.zip`. Genau dort landet es, wenn es als Anhang am Release dieses Tags hängt – eine andere Adresse lässt sich beim Bauen mit `--url=` setzen.
**Ein privates Repository liefert diese Adresse nicht aus.** Ohne Anmeldung antwortet sie mit 404, und `install.php` kann nichts herunterladen; das ist der Zweck der Prüfsumme, nicht ihr Fehler. Damit die Installation auf einem fremden Hosting funktioniert, müssen Archiv und Installer ohne Anmeldung erreichbar sein – entweder weil das Repository öffentlich ist, oder weil beide Dateien auf einem eigenen Webspace liegen und das Release mit `--url=` darauf gebaut wurde.
## Offen, bevor das gebaut wird
1. **Composer auf dem Server oder `vendor/` im Tag?** Hängt am Hosting aus Arbeitspaket J.
1. ~~**Composer auf dem Server oder `vendor/` im Tag?**~~ Entschieden: `vendor/` gehört nicht ins Repository, sondern in ein Releasearchiv, das ein Build-Rechner erzeugt (`scripts/build-release.php`, SHA-256 daneben). Der Updater über Git bleibt für Installationen mit Composer und für Updates ohne Abhängigkeitsänderung.
2. **Wie kommt der Code auf den Server?** Git-Klon im Projektstamm setzt Shell-Zugriff voraus. Gibt es den nicht, ist der Updater in dieser Form nicht möglich, und der Weg ist ein Deployment von außen (rsync eines Releaseverzeichnisses, Umschalten eines Symlinks). Das ist derselbe Ablauf, nur mit einem anderen Schritt 5.
3. **Ein Repository oder zwei?** Für den Piloten reicht eines. Sobald mehrere Websites dasselbe CMS nutzen, gehört der Code in ein eigenes Repository und die Inhalte bleiben je Installation.
+434
View File
@@ -0,0 +1,434 @@
[
{
"path": "/",
"profile": "mobile",
"scores": {
"performance": 0.99,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"settings": {
"output": "json",
"maxWaitForFcp": 30000,
"maxWaitForLoad": 45000,
"pauseAfterFcpMs": 1000,
"pauseAfterLoadMs": 1000,
"networkQuietThresholdMs": 1000,
"cpuQuietThresholdMs": 1000,
"formFactor": "mobile",
"throttling": {
"rttMs": 150,
"throughputKbps": 1638.4,
"requestLatencyMs": 562.5,
"downloadThroughputKbps": 1474.5600000000002,
"uploadThroughputKbps": 675,
"cpuSlowdownMultiplier": 4
},
"throttlingMethod": "simulate",
"screenEmulation": {
"mobile": true,
"width": 412,
"height": 823,
"deviceScaleFactor": 1.75,
"disabled": false
},
"emulatedUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36",
"auditMode": false,
"gatherMode": false,
"clearStorageTypes": [
"file_systems",
"shader_cache",
"service_workers",
"cache_storage"
],
"disableStorageReset": false,
"debugNavigation": false,
"channel": "node",
"usePassiveGathering": false,
"disableFullPageScreenshot": false,
"skipAboutBlank": false,
"blankPage": "about:blank",
"ignoreStatusCode": false,
"locale": "en-US",
"blockedUrlPatterns": null,
"additionalTraceCategories": null,
"extraHeaders": null,
"precomputedLanternData": null,
"onlyAudits": null,
"onlyCategories": null,
"skipAudits": null
},
"environment": {
"networkUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Mobile Safari/537.36",
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
"benchmarkIndex": 5208.5,
"credits": {
"axe-core": "4.13.0"
}
},
"note": "Local prototype only. No customer-site acceptance or field-data claim."
},
{
"path": "/",
"profile": "desktop",
"scores": {
"performance": 0.97,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"settings": {
"output": "json",
"maxWaitForFcp": 30000,
"maxWaitForLoad": 45000,
"pauseAfterFcpMs": 1000,
"pauseAfterLoadMs": 1000,
"networkQuietThresholdMs": 1000,
"cpuQuietThresholdMs": 1000,
"formFactor": "desktop",
"throttling": {
"rttMs": 40,
"throughputKbps": 10240,
"requestLatencyMs": 0,
"downloadThroughputKbps": 0,
"uploadThroughputKbps": 0,
"cpuSlowdownMultiplier": 1
},
"throttlingMethod": "simulate",
"screenEmulation": {
"mobile": false,
"width": 1350,
"height": 940,
"deviceScaleFactor": 1,
"disabled": false
},
"emulatedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
"auditMode": false,
"gatherMode": false,
"clearStorageTypes": [
"file_systems",
"shader_cache",
"service_workers",
"cache_storage"
],
"disableStorageReset": false,
"debugNavigation": false,
"channel": "node",
"usePassiveGathering": false,
"disableFullPageScreenshot": false,
"skipAboutBlank": false,
"blankPage": "about:blank",
"ignoreStatusCode": false,
"locale": "en-US",
"blockedUrlPatterns": null,
"additionalTraceCategories": null,
"extraHeaders": null,
"precomputedLanternData": null,
"onlyAudits": null,
"onlyCategories": null,
"skipAudits": null
},
"environment": {
"networkUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
"benchmarkIndex": 5277.5,
"credits": {
"axe-core": "4.13.0"
}
},
"note": "Local prototype only. No customer-site acceptance or field-data claim."
},
{
"path": "/projekte",
"profile": "mobile",
"scores": {
"performance": 1,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"settings": {
"output": "json",
"maxWaitForFcp": 30000,
"maxWaitForLoad": 45000,
"pauseAfterFcpMs": 1000,
"pauseAfterLoadMs": 1000,
"networkQuietThresholdMs": 1000,
"cpuQuietThresholdMs": 1000,
"formFactor": "mobile",
"throttling": {
"rttMs": 150,
"throughputKbps": 1638.4,
"requestLatencyMs": 562.5,
"downloadThroughputKbps": 1474.5600000000002,
"uploadThroughputKbps": 675,
"cpuSlowdownMultiplier": 4
},
"throttlingMethod": "simulate",
"screenEmulation": {
"mobile": true,
"width": 412,
"height": 823,
"deviceScaleFactor": 1.75,
"disabled": false
},
"emulatedUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36",
"auditMode": false,
"gatherMode": false,
"clearStorageTypes": [
"file_systems",
"shader_cache",
"service_workers",
"cache_storage"
],
"disableStorageReset": false,
"debugNavigation": false,
"channel": "node",
"usePassiveGathering": false,
"disableFullPageScreenshot": false,
"skipAboutBlank": false,
"blankPage": "about:blank",
"ignoreStatusCode": false,
"locale": "en-US",
"blockedUrlPatterns": null,
"additionalTraceCategories": null,
"extraHeaders": null,
"precomputedLanternData": null,
"onlyAudits": null,
"onlyCategories": null,
"skipAudits": null
},
"environment": {
"networkUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Mobile Safari/537.36",
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
"benchmarkIndex": 5231,
"credits": {
"axe-core": "4.13.0"
}
},
"note": "Local prototype only. No customer-site acceptance or field-data claim."
},
{
"path": "/projekte",
"profile": "desktop",
"scores": {
"performance": 1,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"settings": {
"output": "json",
"maxWaitForFcp": 30000,
"maxWaitForLoad": 45000,
"pauseAfterFcpMs": 1000,
"pauseAfterLoadMs": 1000,
"networkQuietThresholdMs": 1000,
"cpuQuietThresholdMs": 1000,
"formFactor": "desktop",
"throttling": {
"rttMs": 40,
"throughputKbps": 10240,
"requestLatencyMs": 0,
"downloadThroughputKbps": 0,
"uploadThroughputKbps": 0,
"cpuSlowdownMultiplier": 1
},
"throttlingMethod": "simulate",
"screenEmulation": {
"mobile": false,
"width": 1350,
"height": 940,
"deviceScaleFactor": 1,
"disabled": false
},
"emulatedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
"auditMode": false,
"gatherMode": false,
"clearStorageTypes": [
"file_systems",
"shader_cache",
"service_workers",
"cache_storage"
],
"disableStorageReset": false,
"debugNavigation": false,
"channel": "node",
"usePassiveGathering": false,
"disableFullPageScreenshot": false,
"skipAboutBlank": false,
"blankPage": "about:blank",
"ignoreStatusCode": false,
"locale": "en-US",
"blockedUrlPatterns": null,
"additionalTraceCategories": null,
"extraHeaders": null,
"precomputedLanternData": null,
"onlyAudits": null,
"onlyCategories": null,
"skipAudits": null
},
"environment": {
"networkUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
"benchmarkIndex": 5272.5,
"credits": {
"axe-core": "4.13.0"
}
},
"note": "Local prototype only. No customer-site acceptance or field-data claim."
},
{
"path": "/impressum",
"profile": "mobile",
"scores": {
"performance": 1,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"settings": {
"output": "json",
"maxWaitForFcp": 30000,
"maxWaitForLoad": 45000,
"pauseAfterFcpMs": 1000,
"pauseAfterLoadMs": 1000,
"networkQuietThresholdMs": 1000,
"cpuQuietThresholdMs": 1000,
"formFactor": "mobile",
"throttling": {
"rttMs": 150,
"throughputKbps": 1638.4,
"requestLatencyMs": 562.5,
"downloadThroughputKbps": 1474.5600000000002,
"uploadThroughputKbps": 675,
"cpuSlowdownMultiplier": 4
},
"throttlingMethod": "simulate",
"screenEmulation": {
"mobile": true,
"width": 412,
"height": 823,
"deviceScaleFactor": 1.75,
"disabled": false
},
"emulatedUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36",
"auditMode": false,
"gatherMode": false,
"clearStorageTypes": [
"file_systems",
"shader_cache",
"service_workers",
"cache_storage"
],
"disableStorageReset": false,
"debugNavigation": false,
"channel": "node",
"usePassiveGathering": false,
"disableFullPageScreenshot": false,
"skipAboutBlank": false,
"blankPage": "about:blank",
"ignoreStatusCode": false,
"locale": "en-US",
"blockedUrlPatterns": null,
"additionalTraceCategories": null,
"extraHeaders": null,
"precomputedLanternData": null,
"onlyAudits": null,
"onlyCategories": null,
"skipAudits": null
},
"environment": {
"networkUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Mobile Safari/537.36",
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
"benchmarkIndex": 5205,
"credits": {
"axe-core": "4.13.0"
}
},
"note": "Local prototype only. No customer-site acceptance or field-data claim."
},
{
"path": "/impressum",
"profile": "desktop",
"scores": {
"performance": 1,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"settings": {
"output": "json",
"maxWaitForFcp": 30000,
"maxWaitForLoad": 45000,
"pauseAfterFcpMs": 1000,
"pauseAfterLoadMs": 1000,
"networkQuietThresholdMs": 1000,
"cpuQuietThresholdMs": 1000,
"formFactor": "desktop",
"throttling": {
"rttMs": 40,
"throughputKbps": 10240,
"requestLatencyMs": 0,
"downloadThroughputKbps": 0,
"uploadThroughputKbps": 0,
"cpuSlowdownMultiplier": 1
},
"throttlingMethod": "simulate",
"screenEmulation": {
"mobile": false,
"width": 1350,
"height": 940,
"deviceScaleFactor": 1,
"disabled": false
},
"emulatedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
"auditMode": false,
"gatherMode": false,
"clearStorageTypes": [
"file_systems",
"shader_cache",
"service_workers",
"cache_storage"
],
"disableStorageReset": false,
"debugNavigation": false,
"channel": "node",
"usePassiveGathering": false,
"disableFullPageScreenshot": false,
"skipAboutBlank": false,
"blankPage": "about:blank",
"ignoreStatusCode": false,
"locale": "en-US",
"blockedUrlPatterns": null,
"additionalTraceCategories": null,
"extraHeaders": null,
"precomputedLanternData": null,
"onlyAudits": null,
"onlyCategories": null,
"skipAudits": null
},
"environment": {
"networkUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
"benchmarkIndex": 5179.5,
"credits": {
"axe-core": "4.13.0"
}
},
"note": "Local prototype only. No customer-site acceptance or field-data claim."
}
]
+106
View File
@@ -0,0 +1,106 @@
[
{
"path": "/",
"profile": "mobile",
"scores": {
"performance": 0.98,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
},
{
"path": "/",
"profile": "desktop",
"scores": {
"performance": 0.96,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
},
{
"path": "/projekte",
"profile": "mobile",
"scores": {
"performance": 1,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
},
{
"path": "/projekte",
"profile": "desktop",
"scores": {
"performance": 1,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
},
{
"path": "/simplescan",
"profile": "mobile",
"scores": {
"performance": 1,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
},
{
"path": "/simplescan",
"profile": "desktop",
"scores": {
"performance": 1,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
},
{
"path": "/impressum",
"profile": "mobile",
"scores": {
"performance": 1,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
},
{
"path": "/impressum",
"profile": "desktop",
"scores": {
"performance": 1,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
}
]
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260912001300 extends AbstractMigration
{
public function getDescription(): string
{
return 'Per page choice of header and footer area, including none at all.';
}
public function isTransactional(): bool
{
return false;
}
public function up(Schema $schema): void
{
// Columns only: on SQLite an index change rebuilds the table, and this one holds the
// pages. NULL means "the standard area", the string 'none' means no area at all, and any
// other value is the address of another header or footer.
$page = $schema->getTable('page');
$page->addColumn('header_area', 'string', ['length' => 80, 'notnull' => false]);
$page->addColumn('footer_area', 'string', ['length' => 80, 'notnull' => false]);
}
public function down(Schema $schema): void
{
$this->throwIrreversibleMigrationException('Dropping these would silently put the standard header back onto landing pages that deliberately have none. Restore a verified backup with matching code.');
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260913000100 extends AbstractMigration
{
public function getDescription(): string
{
return 'A separate title for search results, independent of the page title.';
}
public function isTransactional(): bool
{
return false;
}
public function up(Schema $schema): void
{
// Der Titel in der Suche muss nicht der Titel der Seite sein: auf der Seite steht
// "Leistungen", im Suchergebnis hilft "IT-Leistungen für Mittelbaden – Kant-IT". NULL
// heisst: der Titel des Dokuments gilt, wie bisher.
$schema->getTable('page')->addColumn('seo_title', 'string', ['length' => 160, 'notnull' => false]);
}
public function down(Schema $schema): void
{
$this->throwIrreversibleMigrationException('Dropping this would silently discard every title written for search results. Restore a verified backup with matching code.');
}
}
+1
View File
@@ -7,6 +7,7 @@
"test": "node --test tests/*.test.mjs",
"test:editor": "node scripts/editor-check.mjs",
"test:browser": "node scripts/browser-check.mjs",
"test:links": "node scripts/link-check.mjs",
"audit": "node scripts/audit.mjs"
},
"devDependencies": {
+72
View File
@@ -1,5 +1,77 @@
DirectoryIndex index.php
Options -Indexes -MultiViews
# Alles, was keine Datei ist, beantwortet PHP.
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
# HTTPS erzwingen. Auskommentiert, weil diese Datei auch in Installationen ohne Zertifikat
# liegt und eine Endlosschleife schlimmer waere als fehlendes HTTPS. Vor dem Livegang mit
# Zertifikat: die drei Zeilen einschalten - und erst danach die HSTS-Zeile weiter unten,
# denn HSTS laesst sich in einem Browser ein halbes Jahr lang nicht zuruecknehmen.
#RewriteCond %{HTTPS} !=on
#RewriteCond %{HTTP:X-Forwarded-Proto} !=https
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
# Eine Domain, eine Adresse. Ohne diese Umleitung ist dieselbe Seite unter www und ohne www
# erreichbar - zwei Adressen fuer denselben Inhalt, und Suchmaschinen muessen raten, welche gilt.
# Die Richtung muss zu SITE_ORIGIN passen; hier die Fassung "immer mit www".
#RewriteCond %{HTTP_HOST} !^www\. [NC]
#RewriteCond %{HTTP_HOST} !^localhost [NC]
#RewriteCond %{HTTP_HOST} !^127\.0\.0\.1 [NC]
#RewriteRule ^ https://www.%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
# Uebertragen wird komprimiert. HTML, CSS, JavaScript und XML schrumpfen dabei auf etwa ein
# Viertel; Bilder und Schriften sind bereits komprimiert und bleiben aussen vor.
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css
AddOutputFilterByType DEFLATE application/javascript application/json application/xml
AddOutputFilterByType DEFLATE application/rss+xml image/svg+xml
</IfModule>
<IfModule mod_brotli.c>
AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript image/svg+xml
</IfModule>
# Wie lange ein Browser eine Datei behalten darf.
#
# Bilder und Schriften tragen ihre Kennung im Namen: aendert sich der Inhalt, aendert sich die
# Adresse. Sie duerfen deshalb ein Jahr bleiben. Stylesheets und Skripte tragen die Fassung als
# Parameter, also ebenfalls ein Jahr. HTML wird nie zwischengespeichert - eine veroeffentlichte
# Aenderung soll beim naechsten Aufruf sichtbar sein; die Seiten senden dafuer selbst no-store.
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/webp "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/svg+xml "access plus 1 year"
ExpiresByType font/woff2 "access plus 1 year"
ExpiresByType text/css "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
</IfModule>
<IfModule mod_headers.c>
<FilesMatch "\.(webp|png|jpe?g|gif|svg|woff2|css|js)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
# Schriften werden von derselben Adresse geladen; die Regel steht hier, damit sie auch dann
# gilt, wenn eine Installation die Dateien ueber ein CDN spiegelt.
<FilesMatch "\.woff2$">
Header set Access-Control-Allow-Origin "*"
</FilesMatch>
# Was der Server ueber sich selbst erzaehlt, geht niemanden etwas an: die genaue PHP-Fassung
# ist fuer einen Besucher wertlos und fuer einen Scanner eine Einkaufsliste.
Header always unset X-Powered-By
Header always unset X-AspNet-Version
# Erst einschalten, wenn HTTPS steht und bleiben soll (siehe oben).
#Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
</IfModule>
# Nichts ausliefern, was nur intern gebraucht wird - falls jemand das Paket versehentlich
# vollstaendig in den oeffentlichen Ordner legt statt nur public/.
<FilesMatch "^\.|\.(env|db|sqlite|lock|md|yaml|yml|dist|log)$">
Require all denied
</FilesMatch>
# Eine Ausnahme mit Grund: der Editor liest die Liste der Gestaltungswerte als JSON.
<Files "design-tokens.json">
Require all granted
</Files>
+89 -1
View File
@@ -144,7 +144,7 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
.nav-item:hover { background: #21362d }
.nav-item[aria-current=page] { background: #2b4a3b; color: #fff; font-weight: 650 }
.nav-item[aria-current=page] .nav-icon { color: var(--mint) }
.nav-icon { width: 19px; text-align: center; font-size: 14px; color: var(--shell-muted); flex-shrink: 0 }
.nav-icon { width: 18px; height: 18px; color: var(--shell-muted); flex-shrink: 0 }
.nav-out::after { content: '↗'; margin-left: auto; color: var(--shell-muted); font-size: 11px }
.shell-foot { margin-top: auto; padding-top: 20px; border-top: 1px solid #2a3d35; display: grid; gap: 6px }
@@ -600,3 +600,91 @@ details.card > summary:hover { background: #fafbf8 }
.check-bad { color: var(--danger) }
.log-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 6px }
.log-list code { font-size: 11px; overflow-wrap: anywhere }
.shell-version { margin: 0 0 4px; padding: 0 10px; font-size: 10px; color: var(--shell-muted) }
/* ---- the installer: six screens on an otherwise empty server ---------------------------- */
.install-body {
min-height: 100dvh;
display: grid;
place-items: start center;
background: radial-gradient(ellipse at 20% 0%, #dfeab0, transparent 60%), var(--ground);
padding: 40px 20px 60px;
}
.install-shell { width: 100%; max-width: 640px }
.install-shell .card { margin-top: 18px }
.install-shell h1 { font-size: 24px }
.install-shell form { margin-top: 4px }
.install-shell .sheet-actions { margin-top: 24px }
.install-shell h2 { font-size: 15px; margin: 30px 0 0 }
/* The step list is the only orientation there is: no navigation exists yet. */
.install-steps {
list-style: none;
margin: 14px 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
font-size: 12px;
color: var(--muted);
}
.install-steps li { display: flex; align-items: center; gap: 7px }
.install-steps .current { color: var(--ink); font-weight: 700 }
.install-step-number {
display: grid;
place-items: center;
width: 19px; height: 19px;
border-radius: 50%;
background: #e4e9df;
font-size: 11px;
font-weight: 700;
}
.install-steps .current .install-step-number { background: var(--accent); color: #fff }
.install-steps .done .install-step-number { background: #e7f0d6; color: #2c5340 }
/* A finding that is worth saying but does not block anything. */
.check-warn .check-mark { background: #f3f0e2; color: #6b6550 }
.button-link { text-decoration: none }
.install-details { margin-top: 18px; border: 1px solid var(--line); border-radius: 10px; padding: 0 16px 16px }
.install-details > summary { padding: 14px 0; font-size: 13px; font-weight: 600; cursor: pointer }
.install-details[open] > summary { border-bottom: 1px solid var(--line); margin-bottom: 6px }
.favicon-field{display:flex;align-items:center;gap:12px}
.favicon-field select{flex:1}
.favicon-preview{border-radius:6px;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.12)}
.color-field { display: flex; gap: 8px; align-items: center }
.color-field input[type="text"], .color-field > input:not([type="color"]) { flex: 1; min-width: 0 }
.color-field-swatch { width: 46px; min-height: 38px; padding: 2px; flex: 0 0 auto; cursor: pointer }
.color-field-palette { flex: 0 0 auto; max-width: 40% }
/* Die Verwaltung auf dem Telefon.
Bis 1000px stand die Navigation als waagerechte Wischleiste da - zwei Listen untereinander,
also zwei Zeilen, und der letzte Eintrag ausserhalb des Bildes. Auf einem Telefon ist ein
Klappmenue ehrlicher: eine Zeile im Ruhezustand, aufgeklappt die vollstaendige Liste mit
Flaechen, die ein Daumen trifft. Das details-Element macht das ohne Skript. */
.shell-menu > summary { display: none }
@media (max-width: 760px) {
.shell-nav { flex-wrap: wrap; padding: 10px 14px; gap: 10px; overflow: visible }
.shell-menu { flex: 1 1 100%; order: 3 }
.shell-menu > summary {
display: flex; align-items: center; gap: 10px; list-style: none; cursor: pointer;
min-height: 44px; padding: 0 14px; border-radius: 10px;
background: #ffffff14; color: #fff; font-weight: 600; font-size: 15px;
}
.shell-menu > summary::-webkit-details-marker { display: none }
.shell-menu > summary:focus-visible { outline: 3px solid var(--mint); outline-offset: 2px }
.shell-menu-icon { font-size: 17px }
.shell-menu[open] > summary { margin-bottom: 8px }
.shell-menu nav { display: block }
.shell-menu:not([open]) nav { display: none }
.nav-label { display: block; margin: 12px 4px 6px; font-size: 11px; letter-spacing: 1.4px; color: #9db3a8 }
.nav-list { display: grid; gap: 2px }
/* Vierundvierzig Pixel: die Flaeche, die ein Daumen zuverlaessig trifft. */
.nav-item { min-height: 44px; display: flex; align-items: center; gap: 12px; padding: 10px 14px; white-space: normal }
.shell-foot { margin-left: auto; border-left: 0; padding-left: 0; gap: 10px }
.shell-version { display: none }
.shell-user strong { display: none }
}
+74
View File
@@ -0,0 +1,74 @@
/*
* Farbfelder in den Verwaltungsformularen.
*
* Eine Farbe tippt man nicht, man wählt sie. Im Editor gibt es dafür längst ein Bedienelement aus
* Farbwähler, Textfeld und Palettenliste - in den Formularen unter „Design" und „Einstellungen"
* stand bisher nur ein nacktes Textfeld.
*
* Das Textfeld bleibt die Wahrheit: Es nimmt einen Wert wie `#1a2b3c` **oder** einen Schlüssel aus
* der Palette (`@accent`), und das kann ein Farbwähler nicht. Deshalb wird es nicht ersetzt,
* sondern ergänzt. Ohne JavaScript bleibt das Feld genau das, was es vorher war.
*/
(() => {
const felder = [...document.querySelectorAll('input[data-color]')];
if (0 === felder.length) {
return;
}
for (const feld of felder) {
const palette = JSON.parse(feld.dataset.palette || '[]');
const reihe = document.createElement('div');
reihe.className = 'color-field';
feld.parentNode.insertBefore(reihe, feld);
const waehler = document.createElement('input');
waehler.type = 'color';
waehler.className = 'color-field-swatch';
waehler.setAttribute('aria-label', 'Farbwähler für ' + (feld.labels?.[0]?.textContent.trim() || 'diese Farbe'));
// Der Wähler kennt nur Hexwerte. Steht im Feld ein Palettenschlüssel, zeigt er die Farbe,
// für die der Schlüssel steht - sonst bliebe er auf Schwarz stehen und sähe falsch aus.
const alsHex = wert => {
if (/^#[0-9a-f]{6}$/i.test(wert)) {
return wert;
}
const treffer = palette.find(eintrag => '@' + eintrag.key === wert);
return treffer ? treffer.value : '#000000';
};
waehler.value = alsHex(feld.value || feld.placeholder || '#000000');
waehler.addEventListener('input', () => {
feld.value = waehler.value;
feld.dispatchEvent(new Event('input', { bubbles: true }));
});
reihe.append(waehler, feld);
if (palette.length > 0) {
const auswahl = document.createElement('select');
auswahl.className = 'color-field-palette';
auswahl.setAttribute('aria-label', 'Farbe aus der Palette');
auswahl.append(new Option('Aus der Palette …', ''));
for (const eintrag of palette) {
auswahl.append(new Option(`${eintrag.name} (${eintrag.value})`, '@' + eintrag.key));
}
auswahl.value = palette.some(eintrag => '@' + eintrag.key === feld.value) ? feld.value : '';
auswahl.addEventListener('change', () => {
if ('' === auswahl.value) {
return;
}
feld.value = auswahl.value;
waehler.value = alsHex(auswahl.value);
feld.dispatchEvent(new Event('input', { bubbles: true }));
});
reihe.append(auswahl);
}
// Tippt jemand von Hand, folgt der Wähler mit - solange der Wert einer ist, den er kennt.
feld.addEventListener('input', () => {
const hex = alsHex(feld.value);
if (waehler.value !== hex) {
waehler.value = hex;
}
});
}
})();
+33 -2
View File
@@ -14,15 +14,17 @@ export const GROUPS = {
spacing: 'Abstand',
size: 'Größe',
border: 'Rahmen',
image: 'Bild',
layout: 'Layout',
effect: 'Wirkung',
};
const UNITS = ['px', 'rem', 'em', '%', 'vw', 'vh', 'ch'];
// dvh before vh: on a phone 100vh is taller than the screen, 100dvh is what people mean.
const UNITS = ['px', 'rem', 'em', '%', 'vw', 'dvh', 'vh', 'svh', 'lvh', 'dvw', 'ch'];
/** Splits "18.5rem" into its number and its unit; an empty value yields empty parts. */
function splitLength(value) {
const match = /^(-?\d+(?:\.\d+)?)(px|rem|em|%|vw|vh|ch)$/.exec(value ?? '');
const match = /^(-?\d+(?:\.\d+)?)(px|rem|em|%|vw|vh|ch|dvh|svh|lvh|dvw)$/.exec(value ?? '');
if (match) return { number: match[1], unit: match[2] };
return { number: value === '0' ? '0' : '', unit: 'px' };
}
@@ -264,6 +266,35 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
panel.append(scope);
panel.append(element('p', 'field-help', 'Leere Felder erben vom größeren Gerät und zuletzt vom Theme. Werte mit eigenem Wert sind hervorgehoben und lassen sich einzeln zurücksetzen.'));
// A filter over the properties. With more than ninety of them across eight collapsed groups,
// "where is the border thickness" is a fair question - so it can be typed instead of hunted.
const search = element('input', 'design-search');
search.type = 'search';
search.id = 'design-search';
search.placeholder = 'Eigenschaft suchen, z. B. Rahmen …';
const searchLabel = element('label', 'search-label', 'Eigenschaft suchen');
searchLabel.htmlFor = search.id;
const found = element('p', 'field-help');
search.addEventListener('input', () => {
const term = search.value.trim().toLowerCase();
let matches = 0;
for (const groupElement of panel.querySelectorAll('.design-group')) {
let visible = 0;
for (const entry of groupElement.querySelectorAll('.design-row, .side-wrap, .design-group-label')) {
// A per-side control is a heading plus a block of fields; they travel together.
const text = (entry.classList.contains('side-wrap') ? entry.previousElementSibling?.textContent : entry.textContent) ?? '';
const hit = '' === term || text.toLowerCase().includes(term);
entry.hidden = !hit;
if (hit) visible++;
}
groupElement.hidden = '' !== term && 0 === visible;
if (!groupElement.hidden && '' !== term) groupElement.open = true;
matches += visible;
}
found.textContent = '' === term ? '' : (0 === matches ? 'Nichts gefunden. Unter „Erweitert“ gibt es außerdem eigenes CSS für dieses Element.' : `${matches} Treffer.`);
});
panel.append(searchLabel, search, found);
for (const [group, groupLabel] of Object.entries(GROUPS)) {
const properties = Object.entries(schema.properties).filter(([, p]) => p.group === group);
if (properties.length === 0) continue;
+81 -7
View File
@@ -1,12 +1,12 @@
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', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt' };
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: '⟩', menu: '☰', partialref: '⧉', embed: '⬓' };
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', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider' , legal: 'Rechtszeile'};
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: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄' };
// A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the
// document, stay selectable and movable, but the editor never edits or fills them.
export const PLUGIN_TYPE = /^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/;
export const isPluginBlock = block => typeof block?.type === 'string' && PLUGIN_TYPE.test(block.type);
export const blockLabel = block => labels[block?.type] ?? (isPluginBlock(block) ? `Baustein ${block.type}` : 'Unbekannt');
export const blockIcon = block => icons[block?.type] ?? '❔';
export const isContainer = block => ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem'].includes(block?.type);
export const isContainer = block => ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'].includes(block?.type);
// Nothing may be dropped into a plugin block: the core does not know its layout contract.
// Containers that accept only one kind of child, mirrored by the server side validator.
export const ONLY_CHILD = { gallery: 'image', logos: 'image', accordion: 'accordionitem', tabs: 'tabitem' };
@@ -36,7 +36,9 @@ export function createBlock(type) {
if (type === 'heading') block.props = { text: 'Deine neue Überschrift', level: 2 };
if (type === 'text') block.props = { text: 'Hier beginnt dein neuer Inhalt.' };
if (type === 'button') block.props = { text: 'Mehr erfahren', href: '/' };
if (type === 'postlist') block.props = { count: 3, category: '', tag: '' };
if (type === 'postlist') block.props = { count: 3, category: '', tag: '', layout: 'karten' };
if (type === 'postnav') block.props = { previousLabel: 'Vorheriger Beitrag', nextLabel: 'Nächster Beitrag' };
if (type === 'legal') block.props = { label: 'Rechtliches' };
if (type === 'iconcard') block.props = { icon: 'check', title: 'Deine Leistung', text: 'Beschreibe hier kurz, worum es geht.', href: '' };
if (type === 'stat') block.props = { value: '15+', label: 'Jahre Erfahrung' };
if (type === 'quote') block.props = { text: 'Ein Satz, den eine Kundin oder ein Kunde über dich gesagt hat.', author: 'Name', role: '', rating: 5 };
@@ -46,10 +48,13 @@ export function createBlock(type) {
if (type === 'accordionitem') block.props = { title: 'Eine Frage', open: false };
if (type === 'tabitem') block.props = { title: 'Neuer Tab' };
if (type === 'form') block.props = { form: '' };
if (type === 'menu') block.props = { slot: 'header' };
if (type === 'menu') block.props = { slot: 'header', layout: 'horizontal' };
if (type === 'partialref') block.props = { partial: '' };
// An embed starts empty and blocked: no address, so nothing loads before someone enters one.
if (type === 'embed') block.props = { group: 'external', title: '', provider: '', url: '', hint: '', height: 400 };
if (type === 'icon') block.props = { icon: 'star', size: 48, decorative: true, label: '', href: '' };
if (type === 'progress') block.props = { label: 'Fortschritt', value: 70, showValue: true };
if (type === 'slider') block.props = { desktopVisible: 3, tabletVisible: 2, mobileVisible: 1, snap: true, dots: true, autoplay: 0 };
if (type === 'image') block.props = { src: '/assets/image-placeholder.svg', alt: 'Abstrakte Landschaft als Platzhalter', decorative: false, width: 1000, height: 600 };
return block;
}
@@ -58,12 +63,15 @@ export function insertBlock(document, type, selected) {
const block = createBlock(type);
if (type === 'gallery') upgradeDocument(document, 3);
if (type === 'postlist') upgradeDocument(document, 5);
if (type === 'postnav') upgradeDocument(document, 18);
if (type === 'legal') upgradeDocument(document, 19);
if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6);
if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7);
if (type === 'form') upgradeDocument(document, 8);
if (type === 'breadcrumb') upgradeDocument(document, 9);
if (type === 'menu' || type === 'partialref') upgradeDocument(document, 11);
if (type === 'embed') upgradeDocument(document, 14);
if (['icon', 'progress', 'slider'].includes(type)) upgradeDocument(document, 15);
let target = locate(document.blocks, selected);
if (target?.block.type === 'gallery' && type !== 'image') { target.siblings.splice(target.index + 1, 0, block); return block.id; }
if (target?.parent?.type === 'gallery' && type !== 'image') target = locate(document.blocks, target.parent.id);
@@ -104,8 +112,15 @@ export function moveBlock(document, selected, targetId, inside = false) {
export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' };
// Called only inside a history transaction: all other fields and IDs stay untouched.
// Die höchste Fassung, die dieser Editor kennt. Sie stand bisher als Liste an zwei Stellen und
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
// nirgends. Eine Zahl, an einer Stelle.
export const SCHEMA_VERSION = 19;
const known = version => Number.isInteger(version) && version >= 1 && version <= SCHEMA_VERSION;
export function upgradeDocument(document, version = 2) {
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].includes(document.schemaVersion) || ![2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].includes(version)) throw new Error('Unbekanntes Dokumentschema.');
if (!known(document.schemaVersion) || !known(version) || version < 2) throw new Error('Unbekanntes Dokumentschema.');
document.schemaVersion = Math.max(document.schemaVersion, version);
return document;
}
@@ -122,7 +137,7 @@ export function effectiveDesign(block, device, key) {
}
export function setDesign(document, id, device, key, value) {
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
if (!known(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.');
const block = locate(document.blocks, id)?.block;
if (!block) return;
@@ -168,7 +183,66 @@ export function insertMedia(document, selected, items) {
return parent.id;
}
/**
* A row with a given number of columns, inserted where the selection is.
*
* A row on its own looks like nothing, which is why "add a row" was hard to find: what people
* want is a row that already has columns in it.
*/
/**
* A full screen hero: edge to edge, as tall as the screen, content centred, with room for a
* background image and a dark overlay so text stays readable on any photo.
*
* The parts all exist on their own - width "full", min-height in dvh, display flex, overlay - but
* finding that combination is a research project. This is the one click.
*/
export function insertHero(document, selected) {
const section = createBlock('section');
section.layout = { tone: 'ink', width: 'full' };
section.style = {
desktop: {
// dvh, not vh: on a phone 100vh is taller than the screen.
minHeight: '100dvh', display: 'flex', alignItems: 'center',
backgroundSize: 'cover', backgroundPosition: 'center', backgroundRepeat: 'no-repeat',
overlayColor: '#000000', overlayOpacity: 0.4,
padding: { top: '80px', right: '32px', bottom: '80px', left: '32px' },
textAlign: 'center',
},
mobile: { minHeight: '80dvh', padding: { top: '56px', right: '20px', bottom: '56px', left: '20px' } },
};
// A hero at the top of a page carries its main heading - but a page has exactly one, and
// inserting a second would produce a document the editor refuses to save.
const hasMainHeading = blocks => blocks.some(block =>
(block.type === 'heading' && block.props?.level === 1) || hasMainHeading(block.children ?? []));
const heading = createBlock('heading');
heading.props = { text: 'Deine Botschaft, ganzflächig.', level: hasMainHeading(document.blocks) ? 2 : 1 };
const paragraph = createBlock('text');
paragraph.props = { text: 'Ein Satz, der sagt, worum es geht. Wähle im Designbereich unter Hintergrund ein Bild und stelle die Deckkraft der Überlagerung so ein, dass der Text lesbar bleibt.' };
const button = createBlock('button');
section.children = [heading, paragraph, button];
upgradeDocument(document, 10);
const top = blockPath(document.blocks, selected)[0];
const index = top ? document.blocks.findIndex(block => block.id === top.id) + 1 : document.blocks.length;
document.blocks.splice(index, 0, section);
return section.id;
}
export function insertRow(document, columns, selected) {
const count = Math.max(1, Math.min(6, Number(columns) || 2));
const names = { 1: 'one', 2: 'two', 3: 'three', 4: 'four' };
const row = createBlock('row');
row.layout = { design: { desktop: { columns: names[count] ?? 'auto', gap: 'medium' }, mobile: { columns: 'one' } } };
row.children = Array.from({ length: count }, () => createBlock('column'));
upgradeDocument(document);
const target = locate(document.blocks, selected);
if (!target) document.blocks.push(row);
else if (canContain(target.block, row)) (target.block.children ??= []).push(row);
else target.siblings.splice(target.index + 1, 0, row);
return row.id;
}
export function insertPreset(document, name, selected) {
if (name === 'hero') return insertHero(document, selected);
if (!['intro', 'columns', 'cta'].includes(name)) throw new Error('Unbekannte Vorlage.');
const section = createBlock('section');
const heading = text => { const block = createBlock('heading'); block.props.text = text; return block; };
+5
View File
@@ -204,3 +204,8 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.canvas-panel{background:#e7ebe5}
.canvas-toolbar,.breadcrumbs,.preview-status{background:#f5f7f2}
.icon-button{border-radius:8px}
.row-library{display:grid;gap:6px;margin-bottom:14px}
.row-library button{display:flex;align-items:center;gap:10px;min-height:38px;padding:8px 12px;border:1px solid var(--line);border-radius:7px;background:#f7f9f2;color:var(--ink);font-size:11px}
.row-library button:hover:not(:disabled){border-color:var(--accent)}
.row-library span{letter-spacing:-2px;font-size:13px}
#design-search{font-size:12px;min-height:36px;padding:9px}
+101 -9
View File
@@ -1,10 +1,11 @@
import { EditorHistory, labels, icons, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, setEffect, insertMedia, upgradeDocument } from './editor-model.js';
import { EditorHistory, labels, icons, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, setEffect, insertMedia, upgradeDocument } from './editor-model.js';
import { openMedia } from './media.js';
import { createDesignPanel } from './design-panel.js';
import { createRichText } from './rich-text.js';
// Mirrors DocumentValidator::ICONS; the server rejects anything outside this list.
const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check'];
const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240];
const root = document.querySelector('#editor');
const $ = selector => document.querySelector(selector);
@@ -61,6 +62,7 @@ function controls() {
$('#outdent').disabled ||= !current.parent;
}
$('#library').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
$('#rows').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
$('#presets').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
$('#properties').querySelectorAll('input,textarea,select,button').forEach(input => input.disabled = busy);
document.querySelectorAll('[data-panel],[data-viewport],#tree-search,#expand-all,#collapse-all').forEach(input => input.disabled = !ready || busy);
@@ -161,9 +163,14 @@ function renderProperties() {
const current = selected ? locate(doc.blocks, selected)?.block : doc;
if (!current) return;
if (options.layout) (current.layout ??= {})[key] = next;
else if (selected) { (current.props ??= {})[key] = next; if (key === 'decorative' && next) current.props.alt = ''; }
else if (selected) {
(current.props ??= {})[key] = next;
// Decorative means "no text": an image loses its alternative text, an icon its label.
if (key === 'decorative' && next) { current.props.alt = ''; if (current.type === 'icon') current.props.label = ''; }
}
else current[key] = next;
if (key === 'caption') upgradeDocument(doc, 3);
if (key === 'click' || key === 'lightbox') upgradeDocument(doc, 19);
if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
}, `${selected || 'page'}:${key}`);
if (key === 'decorative') renderProperties();
@@ -235,8 +242,32 @@ function renderProperties() {
field('Anschrift', 'address', block.props.address || '', { multiline: true, maxLength: 200 });
}
if (block.type === 'spacer') field('Höhe', 'size', block.props.size, { choices: { small: 'Klein', medium: 'Mittel', large: 'Groß', huge: 'Sehr groß' } });
if (block.type === 'menu') field('Menüplatz', 'slot', block.props.slot, { choices: { header: 'Hauptnavigation', footer: 'Fußzeile' }, help: 'Die Einträge werden unter Menüs gepflegt.' });
if (block.type === 'menu') {
field('Menüplatz', 'slot', block.props.slot, { choices: { header: 'Hauptnavigation', secondary: 'Zweite Navigation', footer: 'Fußzeile', legal: 'Rechtliches' }, help: 'Welche Einträge gezeigt werden. Gepflegt werden sie unter Menüs.' });
field('Menüart', 'layout', block.props.layout || 'horizontal', { choices: { horizontal: 'Waagerecht', vertical: 'Untereinander', burger: 'Aufklappbar (Burger)' }, help: 'Aufklappbar funktioniert ohne JavaScript. Untermenüs klappen in allen Arten per Klick auf, nicht beim Überfahren – das ist auf einem Touchgerät die einzige Art, die funktioniert.' });
}
if (block.type === 'partialref') field('Adresse der Sektion', 'partial', block.props.partial, { help: 'Die Adresse aus der Bereichsverwaltung. Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden.' });
if (block.type === 'icon') {
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) });
// number, so the value is stored as a number: the server wants an int, not "48".
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Feste Stufen, weil die Größe eine Klasse ist – ein freier Wert hätte keine und das Symbol wäre unsichtbar.' });
field('Nur Dekoration', 'decorative', block.props.decorative, { checkbox: true, help: 'Dekorative Symbole werden Screenreadern verschwiegen. Trägt das Symbol eine Aussage, nimm das Häkchen weg und beschrifte es.' });
if (!block.props.decorative) field('Beschriftung', 'label', block.props.label, { maxLength: 120 });
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse.' });
}
if (block.type === 'progress') {
field('Beschriftung', 'label', block.props.label, { maxLength: 120 });
field('Wert (%)', 'value', block.props.value, { number: true, help: 'Zwischen 0 und 100. Der Wert steht auch im Markup, nicht nur in der Balkenbreite.' });
field('Wert anzeigen', 'showValue', block.props.showValue, { checkbox: true });
}
if (block.type === 'slider') {
field('Automatisch weiterblättern (Sekunden, 0 = aus)', 'autoplay', block.props.autoplay || 0, { number: true, help: 'Ab 3 Sekunden. Lädt ein kleines Skript, nur auf Seiten mit einem solchen Slider. Bei „weniger Bewegung“ im Betriebssystem bleibt er stehen, und die erste Berührung stoppt ihn endgültig.' });
field('Punkte und Pfeile', 'dots', block.props.dots === true, { checkbox: true, help: 'Zeichnet der Browser selbst, ohne Skript. Wo er es noch nicht kann, bleibt ein Schiebefeld.' });
for (const [key, label] of [['desktopVisible', 'Sichtbar auf Desktop'], ['tabletVisible', 'Sichtbar auf Tablet'], ['mobileVisible', 'Sichtbar auf Mobil']]) {
field(label, key, block.props[key], { number: true, help: key === 'desktopVisible' ? 'Wie viele Folien gleichzeitig zu sehen sind, 1 bis 6.' : undefined });
}
field('Einrasten', 'snap', block.props.snap, { checkbox: true, help: 'Der Slider ist ein Scrollbereich – ohne JavaScript, ohne Autoplay. Blättern geht mit Finger, Trackpad, Mausrad und Tastatur.' });
}
if (block.type === 'embed') {
field('Gruppe', 'group', block.props.group, { choices: { external: 'Externe Inhalte', statistics: 'Statistik' }, help: 'Bestimmt, welche Einwilligung dieser Inhalt braucht. Vor der Einwilligung wird nichts geladen – kein Bild, kein Skript, keine Anfrage an den Anbieter.' });
field('Titel', 'title', block.props.title, { maxLength: 160, help: 'Was hier eingebettet ist, in einem Satz. Screenreader lesen das als Namen des Rahmens vor, deshalb nicht leer lassen.' });
@@ -251,13 +282,27 @@ function renderProperties() {
field('Titel', 'title', block.props.title, { maxLength: 160 });
field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true });
}
if (block.type === 'legal') {
field('Beschriftung für Vorlesesoftware', 'label', block.props?.label || 'Rechtliches', { maxLength: 60, help: 'Der Baustein zeigt Impressum, Datenschutz und – sobald jemand gefragt wurde – den Weg zurück zur Einwilligung. Die Adressen stehen in den Einstellungen. Steht er im Fußbereich, entfällt die eingebaute Zeile am Seitenende.' });
}
if (block.type === 'postnav') {
field('Beschriftung zurück', 'previousLabel', block.props.previousLabel, { maxLength: 60 });
field('Beschriftung weiter', 'nextLabel', block.props.nextLabel, { maxLength: 60, help: 'Die Beiträge selbst sucht die Website beim Ausliefern - innerhalb derselben Kategorie.' });
}
if (block.type === 'postlist') {
field('Anzahl Beiträge', 'count', block.props.count, { number: true, help: 'Zwischen 1 und 24. Die Beiträge selbst kommen beim Ausliefern dazu, nicht aus dem gespeicherten Inhalt.' });
field('Nur Kategorie', 'category', block.props.category || '', { help: 'Adresse der Kategorie, zum Beispiel projekte. Leer lassen für alle Beiträge.' });
field('Nur Schlagwort', 'tag', block.props.tag || '', { help: 'Adresse des Schlagworts. Leer lassen für alle Beiträge.' });
field('Darstellung', 'layout', block.props.layout || 'karten', { choices: { karten: 'Karten (Raster mit Bild oben)', liste: 'Liste (Bild links, Text rechts)', kompakt: 'Kompakt (nur Titel und Datum)', wechsel: 'Wechselseitig (Bild mal links, mal rechts)' }, help: 'Dieselben Beiträge, drei Darstellungen. Karten für eine Übersicht, Liste für ein Archiv, Kompakt für eine Randspalte.' });
field('Blättern statt abschneiden', 'paginate', block.props.paginate === true, { checkbox: true, help: 'Für eine Übersichtsseite. Eine Auswahl auf der Startseite lässt man aus.' });
field('Datum und Autor zeigen', 'showMeta', block.props.showMeta !== false, { checkbox: true });
field('Auszug zeigen', 'showExcerpt', block.props.showExcerpt !== false, { checkbox: true });
field('Kategorie zeigen', 'showTerms', block.props.showTerms !== false, { checkbox: true, help: 'Alle drei aus: ein Raster aus Bild und Titel, wie es zu Projekten passt.' });
}
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
if (block.type === 'image') {
field('Beim Klick', 'click', block.props?.click || (block.props?.href ? 'link' : 'keine'), { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, help: 'Vergrößern öffnet das Bild über der Seite. Ohne JavaScript öffnet derselbe Klick die große Bilddatei – die Seite bleibt also in jedem Fall benutzbar.' });
if ((block.props?.click || (block.props?.href ? 'link' : 'keine')) === 'link') field('Linkziel', 'href', block.props.href || '', { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse. Für ein Logo im Kopfbereich meist /.' });
const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen';
picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker);
field('Bildadresse', 'src', block.props.src, { help: 'Vorhandene lokale Datei oder HTTPS-Adresse. Eine Mediathek folgt später.' });
@@ -268,13 +313,14 @@ function renderProperties() {
field('Bildunterschrift', 'caption', block.props.caption || '', { multiline: true, maxLength: 500 });
}
if (block.type === 'gallery') {
field('Bilder vergrößerbar', 'lightbox', block.props?.lightbox === true, { checkbox: true, help: 'Ein Klick öffnet das Bild über der Seite, mit Blättern durch die Galerie. Die einzelnen Bilder brauchen dafür „Beim Klick: Bild vergrößern".' });
const hint = document.createElement('p'); hint.className = 'panel-hint'; hint.textContent = `${block.children?.length || 0} Bilder · Bilder im Strukturbaum auswählen, beschriften und mit den Pfeilen sortieren. Spalten und Abstände stehen unter Design.`;
const picker = document.createElement('button'); picker.className = 'primary'; picker.textContent = 'Galeriebilder auswählen';
picker.addEventListener('click', () => chooseMedia(true)); panel.append(hint, picker);
}
if (block.type === 'section') {
field('Farbwelt', 'tone', block.layout?.tone || 'paper', { layout: true, choices: { paper: 'Hell · Papier', mint: 'Hellgrün · Akzent', ink: 'Dunkel · Tinte' } });
field('Inhaltsbreite', 'width', block.layout?.width || 'normal', { layout: true, choices: { normal: 'Normal', narrow: 'Schmal' } });
field('Inhaltsbreite', 'width', block.layout?.width || 'normal', { layout: true, choices: { normal: 'Normal (1180 px)', narrow: 'Schmal (800 px)', wide: 'Breit (1600 px)', full: 'Randlos (volle Breite)' }, help: 'Randlos heißt ohne Rand und ohne Begrenzung – für ein Bild oder eine Farbfläche von Kante zu Kante.' });
}
}
@@ -377,8 +423,15 @@ function renderLegacyTokens(block, panel) {
function renderLegacyEffects(block, panel) {
for (const [key, label, choices] of [
['shadow', 'Schatten', { none: 'Keiner', soft: 'Weich', strong: 'Kräftig' }],
['hover', 'Hover-Effekt', { none: 'Keiner', lift: 'Leicht anheben', zoom: 'Bild vergrößern' }],
['entrance', 'Einblendung beim Laden', { none: 'Keine', fade: 'Sanft einblenden', rise: 'Von unten einblenden' }],
['hover', 'Hover-Effekt', {
none: 'Keiner', lift: 'Leicht anheben', sink: 'Leicht absenken', zoom: 'Bild vergrößern',
glow: 'Leuchten', tilt: 'Leicht kippen', 'grow-border': 'Rahmen einfahren',
}],
['entrance', 'Einblendung beim Laden', {
none: 'Keine', fade: 'Sanft einblenden', rise: 'Von unten', sink: 'Von oben', left: 'Von links',
right: 'Von rechts', zoom: 'Heranzoomen', flip: 'Kippen', blur: 'Aus der Unschärfe',
}],
['delay', 'Verzögerung der Einblendung', { none: 'Keine', short: '0,15 s', medium: '0,3 s', long: '0,6 s' }],
]) {
const labelEl = document.createElement('label'); labelEl.textContent = label; labelEl.htmlFor = `effect-${key}`;
const select = document.createElement('select'); select.id = labelEl.htmlFor;
@@ -387,7 +440,7 @@ function renderLegacyEffects(block, panel) {
select.addEventListener('change', () => { history.change(doc => setEffect(doc, selected, key, select.value)); controls(); schedulePreview(); });
panel.append(labelEl, select);
}
const note = document.createElement('p'); note.className = 'field-help'; note.textContent = 'Bewegungseffekte werden bei „Bewegung reduzieren“ ausgeschaltet. Zoom wirkt auf enthaltene Bilder.'; panel.append(note);
const note = document.createElement('p'); note.className = 'field-help'; note.textContent = 'Bewegungseffekte werden bei „Bewegung reduzieren“ ausgeschaltet. Zoom wirkt auf enthaltene Bilder. Einblendungen laufen beim Laden der Seite, nicht beim Scrollen: eine öffentliche Seite führt kein JavaScript aus.'; panel.append(note);
}
function chooseMedia(multiple) {
@@ -516,9 +569,20 @@ async function updatePreview() {
if (sequence !== previewSequence) return;
const frame = $('#preview');
const top = frame.contentWindow?.scrollY || 0;
frame.onload = () => {
if (sequence !== previewSequence) return;
let angewandt = false;
// Die Gestaltung muss sitzen, sobald der Kopf des Dokuments geparst ist. Vorher hing das an
// frame.onload - und das feuert erst, wenn das letzte Bild geladen ist. Auf einer Seite mit
// sechzehn Bildern stand die Vorschau dadurch sekundenlang ohne jede Gestaltung da: schwarze
// Knöpfe statt roter, kein Hero, keine Farben. Genau das hat Umut im Bild gesehen.
// Welches Dokument gerade im Rahmen steht. Ohne diesen Vergleich wird beim Warten auf das
// neue Dokument das alte gestaltet - es hat dieselben Kennzeichen -, der Rahmen wird sichtbar
// geschaltet, und gleich darauf ersetzt srcdoc den Inhalt durch den ungestalteten neuen.
const altesDokument = frame.contentDocument;
const anwenden = () => {
if (angewandt || sequence !== previewSequence) return;
const doc = frame.contentDocument;
if (!doc || doc === altesDokument || !doc.getElementById('kant-styles')) return;
angewandt = true;
const style = doc.createElement('link'); style.rel = 'stylesheet'; style.href = '/assets/preview.css'; doc.head.append(style);
// The preview delivers the generated stylesheet as inert template text (see page.html.twig).
// Applying it as a constructed stylesheet is not a source in the sense of the policy, so the
@@ -543,9 +607,31 @@ async function updatePreview() {
});
doc.addEventListener('submit', event => event.preventDefault());
frame.contentWindow.scrollTo(0, top); highlight();
// Erst jetzt ansehen lassen: bis hierher zeigte das Fenster eine Seite ohne Gestaltung.
frame.style.visibility = '';
$('#preview-status').textContent = 'Entwurf · Zum Auswählen direkt auf ein Element klicken.';
};
// Solange die Gestaltung fehlt, bleibt die Fläche leer statt falsch.
frame.style.visibility = 'hidden';
frame.srcdoc = html;
// Sobald der Kopf da ist, wird gestaltet - geprüft Bild für Bild statt auf ein Ereignis zu
// warten, das es für srcdoc nicht zuverlässig gibt.
const warten = () => {
if (angewandt || sequence !== previewSequence) return;
anwenden();
if (!angewandt) requestAnimationFrame(warten);
};
requestAnimationFrame(warten);
// Das Netz darunter ist das Ladeereignis - und zwar ohne Uhr: bringt auch das fertig geladene
// Dokument keine Gestaltung mit, stimmt etwas anderes nicht, und dann ist eine unfertige
// Vorschau immer noch besser als eine unsichtbare.
frame.onload = () => {
anwenden();
if (!angewandt) {
frame.style.visibility = '';
$('#preview-status').textContent = 'Vorschau ohne Gestaltung - bitte die Seite neu laden.';
}
};
} catch (error) {
if (error.name === 'AbortError' || sequence !== previewSequence) return;
$('#preview-status').textContent = `Letzte gültige Vorschau: ${error.message}`;
@@ -611,6 +697,12 @@ $('#library').addEventListener('click', event => {
if (!type || busy || !history) return;
selected = history.change(doc => insertBlock(doc, type, selected)); changed();
});
// A row with columns, in one click: the piece people actually look for when they want a row.
$('#rows').addEventListener('click', event => {
const columns = event.target.closest('[data-row]')?.dataset.row;
if (!columns || busy || !history) return;
selected = history.change(doc => insertRow(doc, columns, selected)); propertyPanel = 'design'; changed();
});
$('#presets').addEventListener('click', event => {
const name = event.target.closest('[data-preset]')?.dataset.preset;
if (!name || busy || !history) return;
+103
View File
@@ -3,3 +3,106 @@
@media(max-width:650px){.block-gallery{grid-template-columns:minmax(0,1fr);gap:16px}}
@media(prefers-reduced-motion:no-preference){.fx-hover-lift,.fx-hover-zoom img{transition:transform .25s ease}.fx-hover-lift:hover,.fx-hover-lift:focus-within{transform:translateY(-4px)}.fx-hover-zoom:hover img,.fx-hover-zoom:focus-within img{transform:scale(1.035)}.fx-entrance-fade{animation:kant-fade .55s ease-out backwards}.fx-entrance-rise{animation:kant-rise .55s ease-out backwards}@keyframes kant-fade{from{opacity:.35}to{opacity:1}}@keyframes kant-rise{from{opacity:.35;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}}
/* Entrance animations and hover effects, added 12.09.2026.
Every animation stays inside the reduced-motion guard: whoever asked their system for less
movement gets the finished state, not a softened animation. They play on load, because a public
page runs no script and there is nothing to observe a scroll position with - that is a
limitation, not an oversight, and it is written in the editor. */
@media (prefers-reduced-motion: no-preference) {
.fx-entrance-sink { animation: kant-sink .55s ease-out backwards }
.fx-entrance-left { animation: kant-left .55s ease-out backwards }
.fx-entrance-right { animation: kant-right .55s ease-out backwards }
.fx-entrance-zoom { animation: kant-zoom .55s ease-out backwards }
.fx-entrance-flip { animation: kant-flip .7s ease-out backwards }
.fx-entrance-blur { animation: kant-unblur .6s ease-out backwards }
.fx-delay-short { animation-delay: .15s }
.fx-delay-medium { animation-delay: .3s }
.fx-delay-long { animation-delay: .6s }
.fx-hover-sink { transition: transform .2s ease }
.fx-hover-sink:hover { transform: translateY(3px) }
.fx-hover-glow { transition: box-shadow .2s ease }
.fx-hover-glow:hover { box-shadow: 0 0 0 4px color-mix(in srgb,var(--accent) 18%,transparent), 0 10px 30px color-mix(in srgb,var(--ink) 16%,transparent) }
.fx-hover-tilt { transition: transform .2s ease }
.fx-hover-tilt:hover { transform: rotate(-1.2deg) scale(1.01) }
.fx-hover-grow-border { transition: box-shadow .2s ease }
.fx-hover-grow-border:hover { box-shadow: inset 0 0 0 3px #2c6b4f }
}
@keyframes kant-sink { from { opacity: 0; transform: translateY(-18px) } to { opacity: 1; transform: none } }
@keyframes kant-left { from { opacity: 0; transform: translateX(-24px) } to { opacity: 1; transform: none } }
@keyframes kant-right { from { opacity: 0; transform: translateX(24px) } to { opacity: 1; transform: none } }
@keyframes kant-zoom { from { opacity: 0; transform: scale(.92) } to { opacity: 1; transform: none } }
@keyframes kant-flip { from { opacity: 0; transform: perspective(800px) rotateX(-12deg) } to { opacity: 1; transform: none } }
@keyframes kant-unblur { from { opacity: 0; filter: blur(8px) } to { opacity: 1; filter: none } }
/* Beitragskarten und Knöpfe, ergänzt am 13.09.2026.
Umut hat gefragt, wo der Zeigereffekt bleibt: eine Karte, die sich beim Zeigen nicht rührt,
wirkt tot. Angehoben wird die ganze Karte, das Bild zieht leicht auf, und der Hinweis darüber
blendet ein. Alles innerhalb der Bewegungsschranke - wer weniger Bewegung eingestellt hat,
bekommt den Hinweis trotzdem, nur ohne Fahrt. */
@media (prefers-reduced-motion: no-preference) {
.post-card { transition: transform .28s ease, box-shadow .28s ease, border-color .28s ease }
/* Eine Zeile in der kompakten Liste hebt sich nicht - sie ist keine Karte. */
.post-list-kompakt .post-card:hover, .post-list-kompakt .post-card:focus-within { transform: none; box-shadow: none; border-color: var(--accent) }
/* Wechselseitig: die Karte selbst bleibt stehen, nur das Bild zieht auf. */
.post-list-wechsel .post-card:hover, .post-list-wechsel .post-card:focus-within { transform: none; box-shadow: none }
.post-list-wechsel .post-card h3 a { transition: color .2s ease }
.post-list-wechsel .post-card:hover h3 a, .post-list-wechsel .post-card:focus-within h3 a { color: var(--accent) }
.post-card:hover, .post-card:focus-within {
transform: translateY(-8px);
box-shadow: 0 18px 50px -16px color-mix(in srgb, var(--ink) 28%, transparent);
border-color: transparent;
}
.post-image img { transition: transform .5s ease }
.post-card:hover .post-image img, .post-card:focus-within .post-image img { transform: scale(1.06) }
.post-view { transition: opacity .3s ease }
.post-nav-link { transition: transform .2s ease, border-color .2s ease }
.post-nav-link:hover, .post-nav-link:focus-visible { transform: translateY(-3px); border-color: var(--accent) }
/* Der Knopf hebt sich beim Zeigen um zwei Pixel - dieselbe Geste wie auf der Karte. */
.button { transition: transform .2s ease, background-color .2s ease, border-color .2s ease }
.button:hover { transform: translateY(-2px) }
}
/* Einblenden beim Hineinscrollen, ergänzt am 13.09.2026.
Bisher liefen die Einblendungen beim Laden der Seite - was unterhalb der Falz stand, war
fertig animiert, bevor es jemand gesehen hat. Mit einer Scroll-Zeitachse läuft dieselbe
Animation genau dann, wenn das Element in den Blick kommt, und zwar ohne eine Zeile
JavaScript: `animation-timeline: view()` bindet den Ablauf an die Sichtbarkeit.
Drei Bedingungen, alle bewusst:
- Hinter der Bewegungsschranke: wer weniger Bewegung eingestellt hat, bekommt keine.
- Hinter `@supports`: Browser ohne Scroll-Zeitachse (heute Firefox und Safari) behalten die
bisherige Fassung - die Animation läuft beim Laden. Niemand sieht deshalb weniger Inhalt.
- `animation-range` endet früh (`cover 26%`): die Bewegung ist vorbei, bevor das Element in
der Mitte des Bildschirms steht. Eine Einblendung, die beim Lesen noch läuft, stört. */
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
/* Eigene Kurven für den Scrollweg: **nur Bewegung, keine Deckkraft**.
Eine Scroll-Zeitachse ist umkehrbar - ein Abschnitt außerhalb des Blicks steht wieder am
Anfang. Alles unterhalb der Falz wäre also dauerhaft blass, und blasser Text ist nicht
weniger Effekt, sondern schlechterer Kontrast: #3d4d5c bei 85 Prozent ergibt 3,86:1
statt 7,1:1 - axe meldet das zu Recht. Der Weg trägt den Effekt allein. */
@keyframes kant-scroll-rise { from { transform: translateY(20px) } to { transform: none } }
@keyframes kant-scroll-fade { from { transform: translateY(10px) } to { transform: none } }
.fx-entrance-fade, .fx-entrance-rise, .fx-entrance-sink, .fx-entrance-left,
.fx-entrance-right, .fx-entrance-zoom, .fx-entrance-flip, .fx-entrance-blur {
animation-name: kant-scroll-rise;
animation-timeline: view();
animation-range: entry 10% cover 28%;
animation-duration: 1ms; /* Die Zeitachse bestimmt den Ablauf, nicht die Uhr. */
animation-fill-mode: both;
}
.fx-entrance-fade { animation-name: kant-scroll-fade }
/* Die Verzögerungsstufen sind auf einer Scroll-Zeitachse eine Verschiebung im Weg,
nicht in der Zeit - sonst würden sie gar nicht wirken. */
.fx-delay-short { animation-range: entry 16% cover 32% }
.fx-delay-medium { animation-range: entry 22% cover 36% }
.fx-delay-long { animation-range: entry 28% cover 40% }
}
}
+25
View File
@@ -0,0 +1,25 @@
# Schriften in diesem Ordner
Hier liegen die Schriftdateien, die diese Website **selbst ausliefert**. Nichts davon wird zur
Laufzeit von einem fremden Server geholt: keine Verbindung zu `fonts.googleapis.com`, keine
IP-Adresse eines Besuchers, die dabei an einen Dritten geht.
## Woher sie kommen
`php bin/console cms:fonts:fetch` (oder der Knopf „Schriften auf diesen Server holen" im
Designbereich) lädt die im Design gewählten Schnitte einmalig von Google Fonts herunter, behält
nur die lateinischen Zeichenbereiche und schreibt `fonts.css` mit den passenden
`@font-face`-Regeln daneben.
## Lizenz
**Lato** steht unter der SIL Open Font License 1.1 – der vollständige Text liegt als
`lato-OFL.txt` daneben, wie die Lizenz es verlangt. Copyright (c) 2010–2014 tyPoland Łukasz
Dziedzic, Reserved Font Name „Lato". Die Lizenz erlaubt ausdrücklich, die Dateien mitzuliefern und
von einem eigenen Server auszuliefern; verboten ist der Verkauf der Schrift für sich genommen und
die Verwendung des reservierten Namens für eine geänderte Fassung.
Wer hier andere Schriften ablegt, prüft deren Lizenz selbst: nicht jede Schrift, die Google Fonts
ausliefert, steht unter der OFL – einige stehen unter der Apache-Lizenz 2.0, und eine gekaufte
Schrift eines Schriftenhauses darf meist gar nicht weitergegeben werden. Die Lizenzdatei gehört in
jedem Fall daneben.
+12
View File
@@ -0,0 +1,12 @@
/* latin-ext */
@font-face{font-family:'Lato';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/lato-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Lato';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/lato-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
/* latin-ext */
@font-face{font-family:'Lato';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/lato-700-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Lato';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/lato-700-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
/* latin-ext */
@font-face{font-family:'Lato';font-style:normal;font-weight:900;font-display:swap;src:url(/assets/fonts/lato-900-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Lato';font-style:normal;font-weight:900;font-display:swap;src:url(/assets/fonts/lato-900-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright (c) 2010-2014 by tyPoland Lukasz Dziedzic (team@latofonts.com) with Reserved Font Name "Lato"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+101
View File
@@ -0,0 +1,101 @@
/*
* Der Bildbetrachter.
*
* Ohne dieses Skript ist ein vergrößerbares Bild schlicht ein Verweis auf die große Fassung: ein
* Klick öffnet sie, zurück führt die Zurück-Taste. Das ist die Grundlage, und sie funktioniert
* immer. Dieses Skript fängt den Klick ab und zeigt das Bild stattdessen über der Seite - mit
* Blättern, wenn es zu einer Galerie gehört.
*
* Benutzt wird <dialog>: der Browser bringt damit das Fangen des Fokus, das Schließen mit Escape
* und die Auszeichnung als Dialog schon mit. Eine nachgebaute Fassung aus <div> und Tastatur-
* abfragen wäre mehr Code und weniger zugänglich.
*/
(() => {
const zoomable = [...document.querySelectorAll('a[data-zoom]')];
if (0 === zoomable.length) {
return;
}
const dialog = document.createElement('dialog');
dialog.className = 'zoom-dialog';
dialog.innerHTML = `
<figure class="zoom-figure">
<img class="zoom-image" alt="">
<figcaption class="zoom-caption"></figcaption>
</figure>
<button class="zoom-close" type="button" aria-label="Schließen">×</button>
<button class="zoom-prev" type="button" aria-label="Vorheriges Bild">‹</button>
<button class="zoom-next" type="button" aria-label="Nächstes Bild">›</button>
<p class="zoom-counter" aria-live="polite"></p>
`;
document.body.append(dialog);
const bild = dialog.querySelector('.zoom-image');
const beschriftung = dialog.querySelector('.zoom-caption');
const zaehler = dialog.querySelector('.zoom-counter');
const zurueck = dialog.querySelector('.zoom-prev');
const vor = dialog.querySelector('.zoom-next');
/** Die Bilder, zwischen denen geblättert wird: die der Galerie, sonst nur dieses eine. */
const gruppeVon = (link) => {
const galerie = link.closest('[data-zoom-group]');
return galerie ? [...galerie.querySelectorAll('a[data-zoom]')] : [link];
};
let gruppe = [];
let stelle = 0;
const zeige = (index) => {
stelle = (index + gruppe.length) % gruppe.length;
const link = gruppe[stelle];
const inneres = link.querySelector('img');
bild.src = link.href;
bild.alt = inneres ? inneres.alt : '';
const text = link.dataset.caption || '';
beschriftung.textContent = text;
beschriftung.hidden = '' === text;
const mehrere = gruppe.length > 1;
zurueck.hidden = !mehrere;
vor.hidden = !mehrere;
zaehler.hidden = !mehrere;
zaehler.textContent = mehrere ? `${stelle + 1} von ${gruppe.length}` : '';
};
for (const link of zoomable) {
link.addEventListener('click', event => {
// Mit gedrückter Zusatztaste oder mittlerer Maustaste bleibt es ein normaler Verweis:
// wer ein Bild in einem neuen Reiter öffnen will, soll das können.
if (event.metaKey || event.ctrlKey || event.shiftKey || 1 === event.button) {
return;
}
event.preventDefault();
gruppe = gruppeVon(link);
zeige(gruppe.indexOf(link));
dialog.showModal();
});
}
zurueck.addEventListener('click', () => zeige(stelle - 1));
vor.addEventListener('click', () => zeige(stelle + 1));
dialog.querySelector('.zoom-close').addEventListener('click', () => dialog.close());
// Ein Klick neben das Bild schließt - erwartet, und der Dialog selbst ist die Fläche daneben.
dialog.addEventListener('click', event => {
if (event.target === dialog) {
dialog.close();
}
});
dialog.addEventListener('keydown', event => {
if (1 === gruppe.length) {
return;
}
if ('ArrowLeft' === event.key) {
event.preventDefault();
zeige(stelle - 1);
}
if ('ArrowRight' === event.key) {
event.preventDefault();
zeige(stelle + 1);
}
});
})();
+253 -39
View File
@@ -1,15 +1,19 @@
@charset "UTF-8";
h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
.block-image{margin:0;max-width:100%}.block-image img{max-width:100%;height:auto}
/* Lange Wörter dürfen umbrechen, damit nichts aus der Spalte läuft. Getrennt wird nur im
Fließtext: eine Überschrift mit Trennstrich („di-gitale") sieht nach Fehler aus. */
h1,h2,h3,h4,h5,h6,p,a{overflow-wrap:break-word}
h1,h2,h3,h4,h5,h6{hyphens:manual}
p{hyphens:auto}
.visually-hidden{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}.block-image{margin:0;max-width:100%}.block-image img{max-width:100%;height:auto}
:root{color-scheme:light;--paper:#f7f8f4;--ink:#182d2b;--muted:#50625d;--line:#d7dfd7;--mint:#e5f0b6;--accent:#315b49;font-family:Arial,Helvetica,sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid #b85c12;outline-offset:6px}main:focus{outline:none}.shell{width:min(1180px,calc(100% - 64px));margin-inline:auto}.site-header{border-bottom:1px solid var(--line)}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:underline}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}.section-inner>.block-text:first-child{font-size:12px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:var(--accent);margin:0 0 30px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500;max-width:900px}h2{font-size:clamp(32px,4vw,49px);font-weight:500;max-width:760px}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;max-width:690px;margin:0 0 23px;color:var(--muted);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:#294b41}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{border:1px solid var(--line);border-radius:12px;padding:30px;min-width:0}.block-column .block-text{font-size:15px}.block-column>.block-text:first-child{font-size:12px;font-weight:700;letter-spacing:1px;color:var(--accent);margin-bottom:34px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:#dae3da}.tone-ink .section-inner>.block-text:first-child{color:var(--mint)}.tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
@media(max-width:650px){.shell{width:calc(100% - 36px)}.site-header nav{width:100%;justify-content:space-between;gap:12px;font-size:12px}.site-header nav .nav-link{padding:10px}.nav-link span{margin-left:6px}.block-section,.block-section:first-child{padding-block:48px}h1{font-size:49px}.block-text{font-size:16px}.block-column{padding:26px}.site-footer{align-items:flex-start;flex-direction:column;gap:10px}.site-footer p{margin:0}.block-row{gap:16px}}
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid var(--accent);outline-offset:6px}main:focus{outline:none}.shell{width:min(var(--k-container,1180px),calc(100% - 64px));margin-inline:auto}.site-header{border-bottom:1px solid var(--line)}.site-header.is-sticky{position:sticky;top:0;z-index:40;background:var(--paper);box-shadow:0 1px 0 var(--line),0 10px 30px color-mix(in srgb,var(--ink) 8%,transparent)}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:none;font-weight:800}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500;max-width:900px}h2{font-size:clamp(32px,4vw,49px);font-weight:500;max-width:760px}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;max-width:690px;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
@media(max-width:650px){.shell{width:calc(100% - 36px)}.site-header nav{width:100%;justify-content:space-between;gap:12px;font-size:12px}.site-header nav .nav-link{padding:10px}.nav-link span{margin-left:6px}.block-section,.block-section:first-child{padding-block:48px}h1{font-size:49px}.block-text{font-size:16px}.site-footer{align-items:flex-start;flex-direction:column;gap:10px}.site-footer p{margin:0}.block-row{gap:16px}}
/* Menus. Submenus open with details/summary, so the navigation works without JavaScript. */
.menu-list{list-style:none;display:flex;flex-wrap:wrap;align-items:center;gap:4px 22px;margin:0;padding:0}
.menu-list a{text-decoration:none;padding:6px 2px;display:inline-block}
.menu-list a:hover,.menu-list summary:hover{text-decoration:underline}
.menu-list [aria-current=page]{font-weight:700;text-decoration:underline;text-underline-offset:6px}
.menu-list [aria-current=page]{font-weight:700;text-decoration:none}
.menu-branch>summary{cursor:pointer;padding:6px 2px;list-style:none}
.menu-branch>summary::after{content:' ▾'}
.menu-branch[open]>summary::after{content:' ▴'}
@@ -17,16 +21,19 @@ h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
.menu-level-2{flex-direction:column;align-items:flex-start;gap:2px}
/* Posts */
.post-list{list-style:none;margin:0;padding:0;display:grid;gap:26px;grid-template-columns:repeat(auto-fill,minmax(300px,1fr))}
.post-card{display:flex;flex-direction:column;gap:14px}
.post-image img{width:100%;height:210px;object-fit:cover;border-radius:10px;display:block}
.post-card h3{margin:0;font-size:19px;line-height:1.35}
.post-list{list-style:none;margin:0;padding:0;display:grid;gap:26px}.post-list-karten{grid-template-columns:repeat(auto-fill,minmax(260px,1fr))}.post-list-liste{gap:18px}.post-list-liste .post-card{flex-direction:row;align-items:stretch}.post-list-liste .post-image{flex:0 0 34%;max-width:300px}.post-list-liste .post-image img{height:100%;aspect-ratio:auto;min-height:170px}.post-list-liste .post-body{flex:1 1 auto;align-content:center;padding:22px 26px}.post-list-wechsel{gap:0;counter-reset:beitrag}.post-list-wechsel .post-card{position:relative;counter-increment:beitrag;display:grid;grid-template-columns:1fr 1fr;align-items:center;gap:0 56px;padding:38px 0;background:transparent;border:0;border-radius:0;overflow:visible}.post-list-wechsel .post-card + .post-card{border-top:1px solid var(--line)}.post-list-wechsel .post-image{border-radius:20px;overflow:hidden;box-shadow:0 18px 46px -22px color-mix(in srgb,var(--ink) 55%,transparent)}.post-list-wechsel .post-image img{aspect-ratio:16/10;border-radius:20px}.post-list-wechsel .post-body{padding:0;display:grid;gap:12px;align-content:center}.post-list-wechsel .post-body::before{content:counter(beitrag,decimal-leading-zero);font-size:13px;font-weight:800;letter-spacing:2px;color:var(--accent)}.post-list-wechsel .post-card h3{font-size:28px;line-height:1.18;letter-spacing:-.5px}.post-list-wechsel .post-excerpt{margin:0;max-width:46ch}.post-list-wechsel .post-meta,.post-list-wechsel .post-terms{margin:0}.post-list-wechsel .post-more{display:inline-flex;align-items:center;gap:8px;margin-top:4px;font-size:15px;font-weight:700;color:var(--accent);text-decoration:none;width:fit-content}
.post-more::after{content:'→';transition:transform .2s}
.post-more:hover::after{transform:translateX(4px)}.post-list-wechsel .post-more::after{content:'\2192'}.post-list-wechsel .post-card:nth-child(even) .post-image{order:2}.post-list-wechsel .post-card:nth-child(even) .post-body{order:1}@media(max-width:760px){.post-list-wechsel .post-card{grid-template-columns:1fr;gap:18px;padding:26px 0}.post-list-wechsel .post-card:nth-child(even) .post-image{order:0}.post-list-wechsel .post-card:nth-child(even) .post-body{order:0}.post-list-wechsel .post-card h3{font-size:22px}}.post-list-kompakt{gap:0}.post-list-kompakt .post-card{flex-direction:row;align-items:baseline;justify-content:space-between;gap:18px;background:transparent;border:0;border-bottom:1px solid var(--line);border-radius:0;overflow:visible}.post-list-kompakt .post-body{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 18px;padding:16px 2px}.post-list-kompakt .post-meta,.post-list-kompakt .post-terms{margin:0}.post-list-kompakt .post-excerpt{flex:1 1 100%;margin:0}
.post-card{display:flex;flex-direction:column;background:var(--paper);border:1px solid var(--line);border-radius:18px;overflow:hidden}.post-card>.post-body{display:grid;gap:10px;padding:20px 22px 22px}
.post-image{display:block;position:relative;overflow:hidden;text-decoration:none}.post-view{position:absolute;inset:0;display:grid;place-items:center;background:color-mix(in srgb,var(--ink) 55%,transparent);color:#fff;font-weight:700;font-size:15px;letter-spacing:.4px;opacity:0}.post-card:hover .post-view,.post-card:focus-within .post-view{opacity:1}.post-image img{width:100%;height:auto;aspect-ratio:16/10;object-fit:cover;border-radius:0;display:block;background:var(--ink)}
.kant h1 mark,.kant h2 mark,.kant h3 mark{background:transparent;color:var(--accent);padding:0}
.kant .post-card h3{margin:0;font-size:19px;font-weight:800;line-height:1.12;color:var(--ink);letter-spacing:-.2px}
.post-card h3 a{text-decoration:none}
.post-card h3 a:hover{text-decoration:underline}
.post-meta,.post-header{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:baseline;font-size:13px;margin:0}
.post-header{padding-block:22px 0}
.post-excerpt{margin:0;line-height:1.65}
.post-terms{display:flex;flex-wrap:wrap;gap:8px;margin:0;font-size:13px}
.block-postnav{margin-top:10px}.post-nav{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:18px}.post-nav-link{display:grid;gap:8px;padding:22px 24px;border:1px solid var(--line);border-radius:18px;text-decoration:none;background:var(--paper)}.post-nav-next{text-align:right}.post-nav-label{font-size:12px;font-weight:700;letter-spacing:1.4px;text-transform:uppercase;color:var(--accent)}.post-nav-title{font-size:18px;font-weight:800;line-height:1.25;color:var(--ink)}.post-pager{display:flex;flex-wrap:wrap;gap:10px 22px;align-items:center;justify-content:center;margin-top:34px;font-size:14px}.post-pager span{color:var(--muted)}.post-terms{display:flex;flex-wrap:wrap;gap:8px;margin:0;font-size:13px}
.post-empty{padding:26px;border:1px dashed currentColor;border-radius:10px}
.pagination{display:flex;flex-wrap:wrap;gap:12px 24px;align-items:center;margin-top:38px;padding-top:22px;border-top:1px solid currentColor;font-size:14px}
.post-terms-label{font-size:11px;font-weight:700;letter-spacing:1.6px;text-transform:uppercase;margin-bottom:10px}
@@ -34,26 +41,30 @@ h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
/* Blocks introduced with schema v6. No JavaScript: the accordion is details/summary. */
.block-icon{flex-shrink:0;display:block}
.block-iconcard{display:grid;gap:12px;padding:26px;border:1px solid currentColor;border-radius:12px}
.block-iconcard h3{margin:0;font-size:19px;line-height:1.3}
.block-iconcard + .block-iconcard,.block-quote + .block-quote,.block-cta + .block-cta,.block-iconcard + .block-quote,.block-quote + .block-iconcard{margin-top:18px}.kant .block-iconcard h3{margin:0;font-size:19px;line-height:1.3}
.block-iconcard h3 a{text-decoration:none}
.block-iconcard h3 a:hover{text-decoration:underline}
.block-iconcard p{margin:0;line-height:1.65}
.block-stat{display:grid;gap:4px;margin:0}
.block-stat strong{font-size:44px;font-weight:600;line-height:1.05;letter-spacing:-1.5px}
.block-stat{display:grid;gap:4px;margin:0;font-size:44px;font-weight:600}
.block-stat strong{font-size:inherit;font-weight:inherit;line-height:1.05;letter-spacing:-1.5px}
.block-stat span{font-size:14px;line-height:1.5}
.block-quote{margin:0;display:grid;gap:14px;padding:28px;border:1px solid currentColor;border-radius:12px}
.block-quote{margin:0;display:grid;gap:14px;padding:28px;border:1px solid currentColor;border-radius:12px;font-size:18px}
.block-quote blockquote{margin:0}
.block-quote blockquote p{margin:0;font-size:18px;line-height:1.65}
.block-quote blockquote p{margin:0;font-size:inherit;line-height:1.65}
.block-quote figcaption{font-size:14px;font-weight:600}
.block-quote figcaption span{font-weight:400;opacity:.75}
.block-quote figcaption span{font-weight:400;opacity:.85}
.quote-rating{margin:0;letter-spacing:3px}
.block-logos{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));align-items:center;gap:24px}
.block-logos img{max-height:52px;width:auto;margin-inline:auto;display:block}
.block-cta{display:flex;flex-wrap:wrap;gap:20px 34px;align-items:center;justify-content:space-between;padding:32px;border:1px solid currentColor;border-radius:14px}
.block-cta h2{margin:0 0 6px;font-size:26px;letter-spacing:-.6px}
.kant .block-form button[type=submit]{background:var(--accent);color:#fff;border-color:var(--accent)}.kant .block-form button[type=submit]:hover{background:color-mix(in srgb,var(--accent) 86%,#000)}.kant .block-cta .button{background:var(--accent);color:#fff;border-color:var(--accent)}.kant .block-cta .button:hover{background:color-mix(in srgb,var(--accent) 86%,#000)}.block-cta{display:flex;flex-wrap:wrap;gap:20px 34px;align-items:center;justify-content:space-between;padding:32px;border:1px solid currentColor;border-radius:14px}
.kant .block-cta h2{margin:0 0 6px;font-size:26px;letter-spacing:-.6px}
.block-cta p{margin:0;line-height:1.6}
.block-contact{display:grid;gap:14px;font-style:normal}
.block-contact span{display:flex;align-items:center;gap:12px;line-height:1.5}
/* Telefonnummer und E-Mail stehen neben ihrem Symbol - das sagt deutlicher als ein Strich,
dass man darauf tippen kann. Beim Überfahren erscheint er trotzdem. */
.block-contact a{text-decoration:none}
.block-contact a:hover{text-decoration:underline}
.block-divider{border:0;border-top:1px solid currentColor;opacity:.35;margin:0}
.block-spacer{display:block}
.spacer-small{height:18px}.spacer-medium{height:38px}.spacer-large{height:68px}.spacer-huge{height:110px}
@@ -71,7 +82,7 @@ h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
.tabs-nav [aria-selected=true]{border-color:currentColor;background:#fff}
.tabs-ready .tabs-nav a{cursor:pointer}
.block-tabitem{padding-block:4px}
.block-tabitem:focus-visible{outline:3px solid #b55b12;outline-offset:4px}
.block-tabitem:focus-visible{outline:3px solid var(--accent);outline-offset:4px}
.tabs-heading{margin:0 0 12px;font-size:17px}
/* Forms. Server rendered, no JavaScript; every message sits at its field. */
@@ -84,7 +95,7 @@ h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
.field-error{margin:0;font-size:13px;font-weight:600;color:#8b2f22}
.field-note{margin:0;font-size:13px;opacity:.8}
.form-error-summary{margin:0;padding:14px 18px;border-radius:8px;background:#fff0eb;color:#8b2f22;border:1px solid #e2b2a7;font-weight:600}
.form-success{margin:0;padding:16px 20px;border-radius:8px;background:#edf3dc;color:#315b49;border:1px solid #c5d89a;font-weight:600}
.form-success{margin:0;padding:16px 20px;border-radius:8px;background:color-mix(in srgb,var(--accent) 10%,var(--paper));color:var(--accent);border:1px solid color-mix(in srgb,var(--accent) 35%,var(--paper));font-weight:600}
.form-missing{margin:0;padding:14px 18px;border:1px dashed currentColor;border-radius:8px}
.check-row{display:flex;align-items:flex-start;gap:11px}
.check-row input{width:20px;min-height:20px;flex-shrink:0;margin-top:3px}
@@ -102,28 +113,27 @@ h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
for light backgrounds. Inside a dark tone they won on specificity, so text in a dark column was
unreadable - 1.87:1 in the seeded footer. These two selectors match that specificity and come
later, so the dark tone wins where it has to. */
.tone-ink .block-column .block-text,
.tone-ink .block-column>.block-text:first-child{color:#dae3da}
.tone-ink .block-column .block-text{color:#dae3da}
/* The consent question. Fixed to the bottom, but it never covers the whole page: the content
stays readable while the decision is open, because a wall would make "accept" the only way
forward. Accepting and refusing are the same button in the same row. */
.consent-bar{position:fixed;inset-inline:12px;bottom:12px;z-index:50;max-width:620px;margin-inline:auto;background:#fff;color:#203b34;border:1px solid #cfd8d1;border-radius:14px;padding:20px 22px;box-shadow:0 18px 50px #1b2a2426;font-size:14px;line-height:1.6}
.consent-bar h2{font-size:16px;margin:0 0 8px}
.consent-bar{position:fixed;inset-inline:12px;bottom:12px;z-index:50;max-width:620px;margin-inline:auto;background:var(--paper);color:var(--ink);border:1px solid var(--line);border-radius:14px;padding:20px 22px;box-shadow:0 18px 50px color-mix(in srgb,var(--ink) 15%,transparent);font-size:14px;line-height:1.6}
.kant .consent-bar h2{font-size:16px;margin:0 0 8px}
.consent-bar p{margin:0 0 14px}
.consent-actions{display:flex;flex-wrap:wrap;gap:10px}
.consent-button{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:11px 18px;border-radius:8px;border:1px solid #2c6b4f;background:#2c6b4f;color:#fff;font:inherit;font-weight:600;cursor:pointer}
.consent-actions .consent-button:last-child{background:#fff;color:#1b2a24;border-color:#b3c0b7}
.consent-button{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:11px 18px;border-radius:8px;border:1px solid var(--k-consent,var(--accent));background:var(--k-consent,var(--accent));color:#fff;font:inherit;font-weight:600;cursor:pointer}
.consent-actions .consent-button:last-child{background:var(--paper);color:var(--ink);border-color:var(--line)}
.consent-button:hover{filter:brightness(.94)}
.consent-button:focus-visible,.consent-bar a:focus-visible,.consent-bar summary:focus-visible,.consent-bar input:focus-visible{outline:3px solid #b55b12;outline-offset:2px}
.consent-button:focus-visible,.consent-bar a:focus-visible,.consent-bar summary:focus-visible,.consent-bar input:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
.consent-details{margin-top:14px}
.consent-details>summary{cursor:pointer;font-size:13px;font-weight:600;padding:4px 0}
.consent-groups{list-style:none;margin:10px 0 0;padding:0;display:grid;gap:10px}
.consent-groups label{display:flex;gap:10px;align-items:flex-start;font-size:13px;line-height:1.5}
.consent-groups input{margin-top:3px;width:18px;height:18px;flex-shrink:0}
.consent-choice{margin-top:14px;background:#fff;color:#1b2a24;border-color:#b3c0b7}
.consent-choice{margin-top:14px;background:var(--paper);color:var(--ink);border-color:var(--line)}
.consent-links{margin:14px 0 0;font-size:12px}
.consent-links a{color:#a4510f}
.consent-links a{color:var(--accent)}
/* While the question is open the bar is fixed at the bottom, so the page has to end above it -
otherwise it covers the last element, and an embed's own consent button sat right underneath.
Found by the browser test, which could not click it. */
@@ -134,11 +144,11 @@ body.consent-open{padding-bottom:260px}
@media (max-width:620px){.consent-bar{inset-inline:0;bottom:0;border-radius:14px 14px 0 0;padding:16px}.consent-actions .consent-button{flex:1 1 140px}}
/* An embedded third party, and what stands in its place until it may load. */
.block-embed iframe{display:block;width:100%;border:0;border-radius:10px;background:#eef1ed}
.embed-blocked{border:1px dashed #b3c0b7;border-radius:10px;padding:24px;background:#f6f7f4;text-align:center}
.block-embed iframe{display:block;width:100%;border:0;border-radius:10px;background:color-mix(in srgb,var(--ink) 8%,var(--paper))}
.embed-blocked{border:1px dashed var(--line);border-radius:10px;padding:24px;background:color-mix(in srgb,var(--ink) 4%,var(--paper));text-align:center}
.embed-title{margin:0 0 8px;font-weight:600}
.embed-hint{margin:0 0 10px;font-size:14px;line-height:1.6;color:#4a5751}
.embed-provider{margin:0 0 16px;font-size:13px;color:#4a5751}
.embed-hint{margin:0 0 10px;font-size:14px;line-height:1.6;color:var(--muted)}
.embed-provider{margin:0 0 16px;font-size:13px;color:var(--muted)}
/* Header and footer hold block documents of their own (ADR 026). They are therefore ordinary
block containers - as flex containers they shrank the section inside them until a single word
@@ -152,11 +162,215 @@ body.consent-open{padding-bottom:260px}
.site-footer .block-column{border:0;border-radius:0;padding:0}
.site-header .block-text,
.site-footer .block-text{margin:0}
/* Size and weight only, never colour: the tone of the area decides that, and a colour here won
against .tone-ink and put 2.24:1 grey text on the dark footer. The browser check caught it. */
.site-header .block-column>.block-text:first-child{font-size:24px;font-weight:800;letter-spacing:-1px;text-transform:none;margin:0}
.site-footer .block-column>.block-text:first-child{font-size:14px;font-weight:400;letter-spacing:0;text-transform:none;margin:0}
/* One landmark for the three links that must be on every page. Loose text outside a landmark is
also what axe objects to, and rightly: a screen reader then has no way to skip to it. */
.legal-bar{padding:16px 20px;text-align:center;font-size:12px;color:var(--muted);background:#f1f3ef}
.legal-bar a{color:#a4510f}
.legal-bar{padding:16px 20px;text-align:center;font-size:12px;color:var(--muted);background:color-mix(in srgb,var(--ink) 6%,var(--paper))}
.legal-bar a{color:var(--accent);text-decoration:none}
.legal-bar a:hover{text-decoration:underline}
/* An image takes the rounding of its figure. Without this a radius on an image block rounded the
figure and left the picture square - which looked like the setting did nothing. */
.block-image img, .block-gallery img, .block-logos img { border-radius: inherit }
/* A single icon, sized by class because inline styles are forbidden by the policy. */
.block-icon{display:inline-flex;align-items:center;justify-content:center;color:var(--accent);line-height:0}
.block-icon svg{width:100%;height:100%;display:block}
.icon-size-12{width:12px;height:12px}.icon-size-16{width:16px;height:16px}.icon-size-20{width:20px;height:20px}
.icon-size-24{width:24px;height:24px}.icon-size-32{width:32px;height:32px}.icon-size-40{width:40px;height:40px}
.icon-size-48{width:48px;height:48px}.icon-size-56{width:56px;height:56px}.icon-size-64{width:64px;height:64px}
.icon-size-80{width:80px;height:80px}.icon-size-96{width:96px;height:96px}.icon-size-120{width:120px;height:120px}
.icon-size-160{width:160px;height:160px}.icon-size-200{width:200px;height:200px}.icon-size-240{width:240px;height:240px}
/* A progress bar. The value is a class, not an inline style, for the same reason. */
.block-progress{margin:0 0 22px}
.progress-label{display:flex;justify-content:space-between;gap:16px;margin:0 0 8px;font-size:14px;font-weight:600}
.progress-value{color:var(--muted);font-weight:400}
.progress-track{height:10px;border-radius:999px;background:color-mix(in srgb,var(--ink) 12%,var(--paper));overflow:hidden}
.progress-fill{height:100%;border-radius:999px;background:var(--accent)}
.tone-ink .progress-track{background:#ffffff26}
.tone-ink .progress-fill{background:var(--mint)}
/* A slider without a script: a scroll container with snapping. It keeps working when snapping is
unsupported - then it is a strip you can scroll. No autoplay, ever. */
.block-slider{display:grid;grid-auto-flow:column;gap:20px;overflow-x:auto;overscroll-behavior-x:contain;padding-bottom:12px;scrollbar-gutter:stable}
.block-slider.slider-snap{scroll-snap-type:x mandatory}
.slider-snap>.slider-slide{scroll-snap-align:start}
.slider-slide{min-width:0}
.block-slider:focus-visible{outline:3px solid var(--accent);outline-offset:6px}/* Mit Punkten braucht es die Rollleiste nicht mehr - sie stand quer unter dem Bild. */
.slider-dots{scroll-marker-group:after;scroll-behavior:smooth;position:relative;padding-bottom:0;scrollbar-width:none;scrollbar-gutter:auto}.slider-dots::-webkit-scrollbar{display:none}.slider-dots>*::scroll-marker{content:'';width:12px;height:12px;border-radius:50%;background:color-mix(in srgb,currentColor 30%,transparent);margin:0 6px;cursor:pointer}.slider-dots>*::scroll-marker:target-current{background:var(--accent)}.slider-dots::scroll-marker-group{display:flex;justify-content:center;align-items:center;padding:14px 0 2px}
.slide-desktop-1{grid-auto-columns:100%}.slide-desktop-2{grid-auto-columns:calc(50% - 10px)}
.slide-desktop-3{grid-auto-columns:calc(33.333% - 14px)}.slide-desktop-4{grid-auto-columns:calc(25% - 15px)}
.slide-desktop-5{grid-auto-columns:calc(20% - 16px)}.slide-desktop-6{grid-auto-columns:calc(16.666% - 17px)}
@media (max-width:1024px){
.slide-tablet-1{grid-auto-columns:100%}.slide-tablet-2{grid-auto-columns:calc(50% - 10px)}
.slide-tablet-3{grid-auto-columns:calc(33.333% - 14px)}.slide-tablet-4{grid-auto-columns:calc(25% - 15px)}
.slide-tablet-5{grid-auto-columns:calc(20% - 16px)}.slide-tablet-6{grid-auto-columns:calc(16.666% - 17px)}
}
@media (max-width:650px){
.slide-mobile-1{grid-auto-columns:100%}.slide-mobile-2{grid-auto-columns:calc(50% - 10px)}
.slide-mobile-3{grid-auto-columns:calc(33.333% - 14px)}.slide-mobile-4{grid-auto-columns:calc(25% - 15px)}
.slide-mobile-5{grid-auto-columns:calc(20% - 16px)}.slide-mobile-6{grid-auto-columns:calc(16.666% - 17px)}
}
.progress-fill-0{width:0%}
.progress-fill-1{width:1%}
.progress-fill-2{width:2%}
.progress-fill-3{width:3%}
.progress-fill-4{width:4%}
.progress-fill-5{width:5%}
.progress-fill-6{width:6%}
.progress-fill-7{width:7%}
.progress-fill-8{width:8%}
.progress-fill-9{width:9%}
.progress-fill-10{width:10%}
.progress-fill-11{width:11%}
.progress-fill-12{width:12%}
.progress-fill-13{width:13%}
.progress-fill-14{width:14%}
.progress-fill-15{width:15%}
.progress-fill-16{width:16%}
.progress-fill-17{width:17%}
.progress-fill-18{width:18%}
.progress-fill-19{width:19%}
.progress-fill-20{width:20%}
.progress-fill-21{width:21%}
.progress-fill-22{width:22%}
.progress-fill-23{width:23%}
.progress-fill-24{width:24%}
.progress-fill-25{width:25%}
.progress-fill-26{width:26%}
.progress-fill-27{width:27%}
.progress-fill-28{width:28%}
.progress-fill-29{width:29%}
.progress-fill-30{width:30%}
.progress-fill-31{width:31%}
.progress-fill-32{width:32%}
.progress-fill-33{width:33%}
.progress-fill-34{width:34%}
.progress-fill-35{width:35%}
.progress-fill-36{width:36%}
.progress-fill-37{width:37%}
.progress-fill-38{width:38%}
.progress-fill-39{width:39%}
.progress-fill-40{width:40%}
.progress-fill-41{width:41%}
.progress-fill-42{width:42%}
.progress-fill-43{width:43%}
.progress-fill-44{width:44%}
.progress-fill-45{width:45%}
.progress-fill-46{width:46%}
.progress-fill-47{width:47%}
.progress-fill-48{width:48%}
.progress-fill-49{width:49%}
.progress-fill-50{width:50%}
.progress-fill-51{width:51%}
.progress-fill-52{width:52%}
.progress-fill-53{width:53%}
.progress-fill-54{width:54%}
.progress-fill-55{width:55%}
.progress-fill-56{width:56%}
.progress-fill-57{width:57%}
.progress-fill-58{width:58%}
.progress-fill-59{width:59%}
.progress-fill-60{width:60%}
.progress-fill-61{width:61%}
.progress-fill-62{width:62%}
.progress-fill-63{width:63%}
.progress-fill-64{width:64%}
.progress-fill-65{width:65%}
.progress-fill-66{width:66%}
.progress-fill-67{width:67%}
.progress-fill-68{width:68%}
.progress-fill-69{width:69%}
.progress-fill-70{width:70%}
.progress-fill-71{width:71%}
.progress-fill-72{width:72%}
.progress-fill-73{width:73%}
.progress-fill-74{width:74%}
.progress-fill-75{width:75%}
.progress-fill-76{width:76%}
.progress-fill-77{width:77%}
.progress-fill-78{width:78%}
.progress-fill-79{width:79%}
.progress-fill-80{width:80%}
.progress-fill-81{width:81%}
.progress-fill-82{width:82%}
.progress-fill-83{width:83%}
.progress-fill-84{width:84%}
.progress-fill-85{width:85%}
.progress-fill-86{width:86%}
.progress-fill-87{width:87%}
.progress-fill-88{width:88%}
.progress-fill-89{width:89%}
.progress-fill-90{width:90%}
.progress-fill-91{width:91%}
.progress-fill-92{width:92%}
.progress-fill-93{width:93%}
.progress-fill-94{width:94%}
.progress-fill-95{width:95%}
.progress-fill-96{width:96%}
.progress-fill-97{width:97%}
.progress-fill-98{width:98%}
.progress-fill-99{width:99%}
.progress-fill-100{width:100%}
/* Section widths. "wide" only widens the content, "full" removes the container entirely: that is
what a picture or a colour field from edge to edge needs. A full width section drops its own
padding as well, otherwise "edge to edge" would still have 32 pixels of air on each side. */
.width-wide>.section-inner{width:min(1600px,calc(100% - 64px))}
.width-full>.section-inner{width:100%;max-width:none;margin-inline:0}
@media (max-width:650px){.width-wide>.section-inner{width:calc(100% - 36px)}}
/* A section that is a flex container centres its content box; the inner container must then be
allowed to grow, or a full height section would show a strip in the middle. */
.block-section>.section-inner{flex:1 1 auto}
/* Menu kinds. Horizontal is the default; vertical stacks; burger is a details element, so it
opens without a script - a menu that needs one would not open on a page that runs none. */
.menu-vertical .menu-list{flex-direction:column;align-items:flex-start;gap:6px}
.menu-burger>summary{display:inline-flex;align-items:center;gap:10px;cursor:pointer;padding:10px 14px;border:1px solid currentColor;border-radius:8px;font-size:14px;font-weight:600;list-style:none}
.menu-burger>summary::-webkit-details-marker{display:none}
.burger-lines{position:relative;width:18px;height:2px;background:currentColor;box-shadow:0 -6px 0 currentColor,0 6px 0 currentColor}
.menu-burger[open]>summary{border-radius:8px 8px 0 0}
.menu-burger-body{padding:14px 0 4px}
.menu-burger-body .menu-list{flex-direction:column;align-items:flex-start;gap:8px}
/* Der Knopf „nach oben". Ein Anker, kein Skript: er springt an den Anfang der Seite, und das
kann ein Browser seit jeher allein. Eingeblendet wird er beim Scrollen über eine Scroll-
Zeitachse - dieselbe Technik wie bei den Einblendungen. Wo es die nicht gibt (heute noch
Safari), steht er von Anfang an da: sichtbar und nutzbar ist besser als unsichtbar.
Aussehen, Größe, Form, Seite und Schwelle kommen aus den Einstellungen. */
.scroll-top{position:fixed;bottom:22px;right:22px;z-index:40;display:grid;place-items:center;width:var(--kant-top-size,48px);height:var(--kant-top-size,48px);border-radius:var(--kant-top-radius,999px);background:var(--kant-top-bg,var(--accent));color:var(--kant-top-ink,#fff);text-decoration:none;box-shadow:0 8px 24px color-mix(in srgb,var(--ink) 28%,transparent);font-size:calc(var(--kant-top-size,48px) / 2.4);line-height:1}
.scroll-top-links{right:auto;left:22px}
.scroll-top:hover{filter:brightness(1.08)}
.scroll-top:focus-visible{outline:3px solid var(--accent);outline-offset:3px}
@media (max-width:767px){.scroll-top{bottom:16px;right:16px}.scroll-top-links{left:16px;right:auto}}
/* Solange der Einwilligungsdialog unten steht, würde der Knopf darauf liegen. */
body.consent-open .scroll-top{display:none}
@supports (animation-timeline: scroll()){
@media (prefers-reduced-motion: no-preference){
.scroll-top{animation:kant-scroll-top linear both;animation-timeline:scroll(root);animation-range:var(--kant-top-after,400px) calc(var(--kant-top-after,400px) + 160px)}
@keyframes kant-scroll-top{from{opacity:0;visibility:hidden;transform:translateY(10px)}to{opacity:1;visibility:visible;transform:none}}
}
}
/* Der Bildbetrachter. Die Fläche daneben schließt, das Bild bleibt in seinen Grenzen, und die
Bedienknöpfe sind groß genug für einen Daumen. Ohne Skript wird nichts davon gebraucht - dann
öffnet der Verweis einfach das große Bild. */
.block-legal{display:flex;flex-wrap:wrap;gap:6px;align-items:center;font-size:13px}
.block-legal a{color:inherit;text-decoration:none}
.block-legal a:hover{text-decoration:underline}
.image-zoom{cursor:zoom-in}
.zoom-dialog{border:0;padding:0;max-width:min(96vw,1400px);max-height:94vh;background:transparent;color:#fff;overflow:visible}
.zoom-dialog::backdrop{background:rgba(8,12,20,.88)}
.zoom-figure{margin:0;display:grid;gap:12px;justify-items:center}
.zoom-image{max-width:100%;max-height:82vh;width:auto;height:auto;border-radius:10px;background:#0c1220}
.zoom-caption{margin:0;font-size:14px;text-align:center;max-width:70ch;color:#e7ecf3}
.zoom-close,.zoom-prev,.zoom-next{position:fixed;display:grid;place-items:center;width:52px;height:52px;border-radius:999px;border:0;background:rgba(255,255,255,.14);color:#fff;font-size:26px;line-height:1;cursor:pointer}
.zoom-close:hover,.zoom-prev:hover,.zoom-next:hover{background:rgba(255,255,255,.26)}
.zoom-close:focus-visible,.zoom-prev:focus-visible,.zoom-next:focus-visible{outline:3px solid #fff;outline-offset:3px}
.zoom-close{top:18px;right:18px}
.zoom-prev{left:18px;top:50%;transform:translateY(-50%)}
.zoom-next{right:18px;top:50%;transform:translateY(-50%)}
.zoom-counter{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);margin:0;font-size:13px;color:#e7ecf3}
@media (max-width:767px){
.zoom-close,.zoom-prev,.zoom-next{width:46px;height:46px}
.zoom-prev{left:8px}.zoom-next{right:8px}
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Lets a slider advance by itself - and stop as soon as somebody takes over.
*
* Without this script a slider is a scroll container with snap points and, where the browser can
* draw them, dots: complete, swipeable and keyboard operable. The script only adds the automatic
* step, and it is loaded exclusively on pages that carry a slider which asked for it.
*
* What it refuses to do:
* - it does not run when the system asks for reduced motion,
* - it stops for good at the first touch, click, key press or manual scroll,
* - it pauses while the pointer is inside, while something in it has focus, and while the tab is
* in the background or the slider is off screen.
*
* A carousel that keeps moving while somebody reads it is a nuisance, and one that cannot be
* stopped is a barrier.
*/
(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const setup = slider => {
const seconds = Number(slider.dataset.autoplay);
if (!Number.isFinite(seconds) || seconds < 3) return;
let paused = false;
let stopped = false;
let visible = true;
let ownScroll = 0;
const slides = [...slider.children];
if (slides.length < 2) return;
const step = () => {
if (stopped || paused || !visible || document.hidden) return;
const last = slider.scrollLeft + slider.clientWidth >= slider.scrollWidth - 2;
// Measured, not computed from offsetLeft: whether a slide's offset parent is the slider or
// something further up decides the number, and getting that wrong means scrolling nowhere.
const left = slide => slide.getBoundingClientRect().left - slider.getBoundingClientRect().left + slider.scrollLeft;
const next = last ? slides[0] : slides.find(slide => left(slide) > slider.scrollLeft + 2);
if (!next) return;
// Remember that the next scroll event is ours, so it does not look like a person.
ownScroll = Date.now();
slider.scrollTo({ left: last ? 0 : left(next), behavior: 'smooth' });
};
const stop = () => { stopped = true; clearInterval(timer); };
const timer = setInterval(step, seconds * 1000);
slider.addEventListener('pointerenter', () => { paused = true; });
slider.addEventListener('pointerleave', () => { paused = false; });
slider.addEventListener('focusin', () => { paused = true; });
slider.addEventListener('focusout', () => { paused = false; });
for (const event of ['pointerdown', 'keydown', 'touchstart', 'wheel']) {
slider.addEventListener(event, stop, { passive: true });
}
slider.addEventListener('scroll', () => {
// A scroll that is not the one we just started belongs to a person; then we are done.
if (Date.now() - ownScroll > 1200) stop();
}, { passive: true });
if ('IntersectionObserver' in window) {
new IntersectionObserver(entries => { visible = entries[0].isIntersecting; }, { threshold: 0.35 })
.observe(slider);
}
};
document.querySelectorAll('.block-slider[data-autoplay]').forEach(setup);
})();
+5
View File
@@ -3,6 +3,11 @@
use App\Kernel;
use Symfony\Component\HttpFoundation\Request;
// PHP nennt in jeder Antwort seine genaue Fassung („X-Powered-By: PHP/8.4.22"). Das nützt
// niemandem ausser dem, der nach einer Luecke genau dieser Fassung sucht. Auf gewoehnlichem
// Hosting laesst sich expose_php nicht umstellen, dieser Aufruf wirkt trotzdem.
header_remove('X-Powered-By');
require dirname(__DIR__).'/config/bootstrap.php';
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
$request = Request::createFromGlobals();
+13 -4
View File
@@ -5,6 +5,9 @@ import { mkdir, writeFile } from 'node:fs/promises';
import net from 'node:net';
const origin = process.env.CMS_URL || 'http://127.0.0.1:8080';
// Eine Startseite ist nicht die Website. Geprüft wird, was CMS_PATHS nennt - mehrere Adressen mit
// Komma getrennt; ohne Angabe die Startseite.
const paths = (process.env.CMS_PATHS || '/').split(',').map(p => p.trim()).filter(Boolean);
const output = process.env.CMS_AUDIT_DIR || 'reports/lighthouse';
const preflight = await fetch(origin);
if (!preflight.ok || /noindex/i.test(preflight.headers.get('x-robots-tag') || '')) {
@@ -18,10 +21,12 @@ await new Promise(resolve => portProbe.close(resolve));
const chrome = await chromium.launch({ args: [`--remote-debugging-port=${port}`] });
const summary = [];
try {
for (const path of paths) {
const label = '/' === path ? 'home' : path.replace(/^\//, '').replace(/\//g, '-');
for (const profile of ['mobile', 'desktop']) {
const runs = [];
for (let run = 1; run <= 3; run++) {
const result = await lighthouse(origin, {
const result = await lighthouse(origin + ('/' === path ? '' : path), {
port,
output: 'json',
logLevel: 'error',
@@ -30,18 +35,22 @@ try {
if (lhr.configSettings.formFactor !== profile) {
throw new Error(`Requested ${profile}, measured ${lhr.configSettings.formFactor}`);
}
await writeFile(`${output}/${profile}-${run}.json`, result.report);
await writeFile(`${output}/${label}-${profile}-${run}.json`, result.report);
runs.push(lhr);
console.log(profile, run, Object.fromEntries(Object.entries(lhr.categories).map(([key, value]) => [key, value.score])));
console.log(label, profile, run, Object.fromEntries(Object.entries(lhr.categories).map(([key, value]) => [key, value.score])));
}
const scores = {};
for (const category of Object.keys(runs[0].categories)) {
const values = runs.map(r => r.categories[category].score).filter(v => v !== null).sort((a, b) => a - b);
scores[category] = values.length ? values[Math.floor(values.length / 2)] : null;
}
summary.push({ profile, scores, lighthouseVersion: runs[0].lighthouseVersion, settings: runs[0].configSettings, environment: runs[0].environment, note: 'Local prototype only. No customer-site acceptance or field-data claim.' });
summary.push({ path, profile, scores, lighthouseVersion: runs[0].lighthouseVersion, settings: runs[0].configSettings, environment: runs[0].environment, note: 'Local prototype only. No customer-site acceptance or field-data claim.' });
}
}
await writeFile(`${output}/summary.json`, JSON.stringify(summary, null, 2));
for (const row of summary) {
console.log(`${row.path} ${row.profile}: ${Object.entries(row.scores).map(([k, v]) => k + ' ' + Math.round(v * 100)).join(', ')}`);
}
const passed = summary.every(r => r.scores.performance >= .95 && ['accessibility', 'best-practices', 'seo'].every(c => r.scores[c] === 1) && (r.scores['agentic-browsing'] === undefined || r.scores['agentic-browsing'] === 1));
if (!passed) process.exitCode = 1;
} finally {
+116 -13
View File
@@ -14,24 +14,106 @@ try {
// itself. Without bin/dev-router.php they never reach the front controller - exactly the gap
// that let them stay broken locally while every kernel test passed.
const context = await browser.newContext();
for (const [path, type] of [['/sitemap.xml', 'application/xml'], ['/robots.txt', 'text/plain'], ['/feed.xml', 'application/xml']]) {
const sitemap = await context.request.get(origin + '/sitemap.xml');
assert.equal(sitemap.status(), 200, '/sitemap.xml');
assert.match(sitemap.headers()['content-type'] ?? '', /application\/xml/, '/sitemap.xml');
// Eine Website ohne Beiträge kennt weder Archiv noch Feed: dann ist 404 die richtige Antwort,
// und die Sitemap führt die Adresse gar nicht erst auf.
const sitemapText = await sitemap.text();
const hasPosts = sitemapText.includes('/beitraege');
for (const [path, type] of [['/robots.txt', 'text/plain'], ['/feed.xml', 'application/xml']]) {
const response = await context.request.get(origin + path);
assert.equal(response.status(), 200, path);
assert.match(response.headers()['content-type'] ?? '', new RegExp(type), path);
const expected = '/feed.xml' === path && !hasPosts ? 404 : 200;
assert.equal(response.status(), expected, path);
// Auch die 404 muss aus der Anwendung kommen - der eingebaute PHP-Server beantwortet Dateien
// mit Endung sonst selbst, und genau das soll dieser Test aufdecken.
assert.ok('x-kant-queries' in response.headers(), `${path}: nicht von der Anwendung beantwortet`);
if (200 === expected) assert.match(response.headers()['content-type'] ?? '', new RegExp(type), path);
}
results.push({ name: 'machine readable routes reachable', paths: 3, hasPosts });
// Die Schutzkopfzeilen einer öffentlichen Seite. Sie fehlen lautlos, wenn jemand eine Antwort
// an der Stelle vorbeibaut, die sie setzt - deshalb werden sie gemessen und nicht angenommen.
{
const seite = await context.request.get(origin + '/');
const kopf = seite.headers();
const csp = kopf['content-security-policy'] ?? '';
assert.match(csp, /default-src 'self'/, 'Content-Security-Policy fehlt');
assert.match(csp, /object-src 'none'/, 'Plugins müssen verboten bleiben');
assert.match(csp, /frame-ancestors 'none'/, 'Die Seite darf nicht in einen fremden Rahmen');
assert.match(csp, /base-uri 'none'/, 'Ein eingeschleustes <base> würde jeden Verweis umlenken');
assert.equal(kopf['x-content-type-options'], 'nosniff');
assert.match(kopf['referrer-policy'] ?? '', /strict-origin/);
assert.match(kopf['permissions-policy'] ?? '', /camera=\(\)/);
// Und was nicht dastehen darf: die Serverkennung verrät, wonach ein Angreifer suchen kann.
assert.ok(!('x-powered-by' in kopf) || !/php/i.test(kopf['x-powered-by']), 'X-Powered-By nennt die PHP-Fassung');
results.push({ name: 'security headers', csp: csp.length });
}
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 hasPosts ? ['/', '/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;
// Bilder gehören zum Inhalt: eine bebilderte Startseite lädt zu Recht mehr Dateien als eine
// Textseite. Gedeckelt wird deshalb das Beiwerk (Stile, Schriften, Skripte) und das
// übertragene Gewicht - nicht die Zahl der Bilder.
const beiwerk = [...transferred.values()].filter(entry => !entry.type.startsWith('image/')).length;
const gewicht = [...transferred.values()].reduce((sum, entry) => sum + entry.length, 0);
assert.ok(html < 150000, `${path}: HTML ${html} Bytes, Obergrenze 150000`);
assert.ok(styles < 120000, `${path}: CSS ${styles} Bytes, Obergrenze 120000`);
// Eine öffentliche Seite lädt kein Skript, solange sie keines braucht - und das steht in
// ihrem eigenen Kopf: nur wenn die Seite Skripte erlaubt (Reiter, Slider mit Autolauf,
// eigener Code), darf überhaupt eines kommen, dann höchstens eines.
const erlaubt = !/script-src 'none'/.test(response.headers()['content-security-policy'] ?? '');
assert.ok(erlaubt ? scripts <= 1 : 0 === scripts, `${path}: ${scripts} Skript(e) geladen, erlaubt ${erlaubt ? 1 : 0}`);
assert.ok(beiwerk <= 12, `${path}: ${beiwerk} Anfragen ohne Bilder, Obergrenze 12`);
assert.ok(gewicht < 1500000, `${path}: ${gewicht} Bytes übertragen, Obergrenze 1500000`);
if (queries >= 0) assert.ok(queries <= 20, `${path}: ${queries} Datenbankabfragen, Obergrenze 20`);
results.push({ name: `load budget ${path}`, html, styles, requests, beiwerk, gewicht, queries });
console.log(`Budget ${path}: HTML ${html} B, CSS ${styles} B, ${requests} Anfragen (${beiwerk} ohne Bilder), ${gewicht} B gesamt, ${queries} Abfragen, ${scripts} Skripte`);
}
await budget.close();
}
// Die Seiten, die geprüft werden, stehen in der Sitemap - vorher waren es drei fest
// geschriebene Adressen der Demo, und gegen eine andere Installation lief der Check ins Leere.
const seiten = ['/', ...[...sitemapText.matchAll(/<loc>([^<]+)<\/loc>/g)]
.map(treffer => new URL(treffer[1]).pathname)
.filter(pfad => '/' !== pfad && !pfad.startsWith('/beitraege') && !pfad.startsWith('/kategorie/') && !pfad.startsWith('/schlagwort/'))
.slice(0, 2)];
for (const width of [1440, 390]) {
const context = await browser.newContext({ viewport: { width, height: 1000 } });
const page = await context.newPage();
const errors = [];
page.on('pageerror', error => errors.push(error.message));
for (const path of ['/', '/grundlagen', '/projektstand']) {
for (const path of seiten) {
const response = await page.goto(origin + path);
assert.equal(response.status(), 200);
assert.equal(await page.locator('h1').count(), 1);
assert.equal(await page.locator('script').count(), 0);
// Strukturierte Daten stehen zwar in einem script-Element, führen aber nichts aus. Gezählt
// wird, was laufen kann - und das nur, wenn die Seite es in ihrer eigenen Richtlinie erlaubt.
const laufend = await page.locator('script:not([type="application/ld+json"])').count();
const darfSkripte = !/script-src 'none'/.test(response.headers()['content-security-policy'] ?? '');
assert.ok(darfSkripte ? laufend <= 1 : 0 === laufend, `${path}: ${laufend} Skript(e), erlaubt ${darfSkripte ? 1 : 0}`);
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'Horizontal overflow');
const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'best-practice']).analyze();
@@ -67,22 +149,43 @@ try {
await page.screenshot({ path: `reports/${width}-${path === '/' ? 'home' : path.slice(1)}.png`, fullPage: true });
}
await page.goto(origin);
// Steht der Einwilligungshinweis offen, liegt er über dem Inhalt - ein Besucher entscheidet
// sich zuerst, und danach ist die Seite frei. Genau so geht dieser Test auch vor.
const hinweis = page.locator('.consent-bar button[value="none"]');
if (await hinweis.count()) {
await hinweis.click();
await page.waitForLoadState('domcontentloaded');
}
await page.keyboard.press('Tab');
assert.equal(await page.locator(':focus').innerText(), 'Zum Inhalt springen');
await page.keyboard.press('Enter');
assert.equal(await page.locator(':focus').getAttribute('id'), 'main');
await page.getByRole('link', { name: 'Die Grundlage entdecken' }).click();
assert.equal(new URL(page.url()).pathname, '/grundlagen');
await page.getByRole('link', { name: 'Projektstand' }).click();
assert.equal(new URL(page.url()).pathname, '/projektstand');
// Weiter mit der Tastatur: der erste Verweis im Inhalt muss anklickbar sein und tatsächlich
// woandershin führen. Vorher standen hier die Linktexte der Demo - gegen jede andere
// Installation lief die Prüfung damit ins Leere statt etwas zu prüfen.
const ersterLink = page.locator('main a[href^="/"]').first();
const ziel = await ersterLink.getAttribute('href');
await ersterLink.click();
assert.equal(new URL(page.url()).pathname, ziel.split('#')[0] || '/');
assert.equal(await page.locator('h1').count(), 1, ziel + ': keine Überschrift');
assert.deepEqual(errors, []);
await context.close();
}
// Ohne JavaScript muss dieselbe Website benutzbar sein - eine öffentliche Seite lädt ohnehin
// keines, aber das soll gemessen und nicht geglaubt werden.
const noJs = await browser.newPage({ javaScriptEnabled: false });
await noJs.goto(origin);
assert.match(await noJs.locator('h1').innerText(), /Deine Inhalte/);
await noJs.getByRole('link', { name: 'Die Grundlage entdecken' }).click();
assert.equal(new URL(noJs.url()).pathname, '/grundlagen');
const hinweisOhneJs = noJs.locator('.consent-bar button[value="none"]');
if (await hinweisOhneJs.count()) {
await hinweisOhneJs.click();
await noJs.waitForLoadState('domcontentloaded');
}
assert.equal(await noJs.locator('h1').count(), 1);
const ohneJs = noJs.locator('main a[href^="/"]').first();
const zielOhneJs = await ohneJs.getAttribute('href');
await ohneJs.click();
assert.equal(new URL(noJs.url()).pathname, zielOhneJs.split('#')[0] || '/');
assert.equal(await noJs.locator('h1').count(), 1);
await writeFile('reports/browser.json', JSON.stringify({ origin, results, editorialFindings: findings, keyboard: 'passed', noJavaScript: 'passed' }, null, 2));
console.log('Passed: 6 page/viewport accessibility checks, keyboard navigation, no-JS navigation.');
if (findings.length > 0) {
+166
View File
@@ -0,0 +1,166 @@
<?php
/**
* Builds a release archive that runs on hosting without Composer.
*
* Umut is right: shared hosting usually has no Composer. A release therefore has to carry
* `vendor/` with it, and that folder has to be built by somebody - here, on a machine that does
* have Composer. The archive is what `install.php` downloads and what a host without Composer
* unpacks; the Git repository stays free of `vendor/`.
*
* Run: php scripts/build-release.php [--out=var/releases]
*
* The script refuses to build rather than produce something misleading: without Composer there is
* no way to get a dependency tree without development packages, and shipping the development one
* would mean an archive with PHPUnit in it.
*/
$root = dirname(__DIR__);
$options = getopt('', ['out::', 'keep-dev', 'url::']);
// Wohin das Archiv beim Veröffentlichen gelegt wird. Steht fest in install.php, deshalb muss
// sie hier bekannt sein - eine Adresse, die erst im Browser abgefragt wird, wäre eine Lücke.
$releaseBase = rtrim($options['url'] ?? 'https://git.kant-it.de/kantarci/KantSites/releases/download', '/');
$outDirectory = $root.'/'.trim($options['out'] ?? 'var/releases', '/');
$version = trim((string) file_get_contents($root.'/VERSION'));
if (1 !== preg_match('/^\d+\.\d+\.\d+$/D', $version)) {
fwrite(STDERR, "VERSION enthält keine Fassung der Form 1.2.3.\n");
exit(1);
}
/** Runs a command and returns [ok, output]; the shell is used because this is a build tool. */
$run = static function (string $command) use ($root): array {
$output = [];
$status = 0;
exec('cd '.escapeshellarg($root).' && '.$command.' 2>&1', $output, $status);
return [0 === $status, implode("\n", $output)];
};
// Composer, in the order that works on a developer machine.
$composer = null;
foreach (['php '.escapeshellarg($root.'/composer.phar'), 'composer'] as $candidate) {
[$ok] = $run($candidate.' --version');
if ($ok) {
$composer = $candidate;
break;
}
}
if (null === $composer) {
fwrite(STDERR, "Composer ist auf diesem Rechner nicht aufrufbar.\n");
fwrite(STDERR, "Ein Release wird auf einem Build-Rechner gebaut, nicht auf dem Hosting - genau deshalb gibt es dieses Skript.\n");
exit(1);
}
$staging = $outDirectory.'/kantsites-'.$version;
$archive = $outDirectory.'/kantsites-'.$version.'.zip';
if (is_dir($staging) || is_file($archive)) {
fwrite(STDERR, sprintf("Für Fassung %s gibt es schon ein Release. Erst entfernen oder VERSION erhöhen.\n", $version));
exit(1);
}
if (!is_dir($outDirectory) && !mkdir($outDirectory, 0o775, true)) {
fwrite(STDERR, sprintf("%s ließ sich nicht anlegen.\n", $outDirectory));
exit(1);
}
echo "Fassung {$version}\n";
// What belongs into a release: the application, and nothing that only a developer needs.
// migrations.php und cli-config.php gehoeren dazu: ohne die erste findet Doctrine beim
// Einrichten keine Migrationsliste und bricht mit "configuration file does not exist" ab - genau
// dort, wo eine frische Installation ihre Tabellen anlegen will.
$include = ['bin', 'config', 'migrations', 'public', 'src', 'templates', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION', 'README.md', 'LICENSE', '.env.example'];
$skip = ['.git', '.github', '.claude', '.gitattributes', '.gitignore', '.php-cs-fixer.cache', 'node_modules', 'var', 'tests', 'scripts', 'reports', 'phpunit.xml.dist', '.php-cs-fixer.dist.php', 'package.json', 'package-lock.json', 'AGENTS.md', 'ENTWICKLUNGSPLAN.md', 'docs', 'composer.phar', '.venv', 'vendor', 'phpunit.cache', 'migrations.php.bak'];
$copy = static function (string $from, string $to) use (&$copy, $skip): int {
$count = 0;
if (is_file($from)) {
@mkdir(dirname($to), 0o775, true);
return @copy($from, $to) ? 1 : 0;
}
foreach (scandir($from) ?: [] as $entry) {
if ('.' === $entry || '..' === $entry || in_array($entry, $skip, true)) {
continue;
}
$count += $copy($from.'/'.$entry, $to.'/'.$entry);
}
return $count;
};
$files = 0;
foreach ($include as $entry) {
if (file_exists($root.'/'.$entry)) {
$files += $copy($root.'/'.$entry, $staging.'/'.$entry);
}
}
// Die .env des Entwicklungsrechners gehört nicht in ein Release; gebraucht wird trotzdem eine,
// denn ohne sie bootet die Anwendung nicht einmal so weit, dass der Installer erscheint. Also
// wird die Vorlage zur .env des Releases - mit Produktionswerten und ohne Geheimnis. Das
// Geheimnis erzeugt der erste Aufruf selbst, die Zugangsdaten schreibt der Installer nach
// .env.local.
copy($root.'/.env.example', $staging.'/.env');
++$files;
echo "Quelldateien: {$files}\n";
// The dependencies, without the development ones - that is the whole point of building here.
$flags = isset($options['keep-dev']) ? '' : ' --no-dev';
[$ok, $output] = $run(sprintf(
'%s install%s --optimize-autoloader --no-interaction --no-progress --working-dir=%s',
$composer,
$flags,
escapeshellarg($staging),
));
if (!$ok) {
fwrite(STDERR, "composer install ist gescheitert:\n".$output."\n");
exit(1);
}
echo "vendor/ gebaut".($flags ? ' ohne Entwicklungspakete' : ' MIT Entwicklungspaketen')."\n";
// The environment file is not part of a release: it holds the secret of one installation.
foreach (['.env.local', '.env.prod.local'] as $secret) {
if (is_file($staging.'/'.$secret)) {
unlink($staging.'/'.$secret);
}
}
if (!class_exists(ZipArchive::class)) {
fwrite(STDERR, "Die PHP-Erweiterung zip fehlt; ohne sie lässt sich kein Archiv schreiben.\n");
exit(1);
}
$zip = new ZipArchive();
if (true !== $zip->open($archive, ZipArchive::CREATE | ZipArchive::OVERWRITE)) {
fwrite(STDERR, "Archiv ließ sich nicht anlegen.\n");
exit(1);
}
$entries = 0;
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($staging, FilesystemIterator::SKIP_DOTS));
foreach ($iterator as $file) {
if ($file->isFile()) {
// Paths inside the archive start with the version folder, so unpacking never scatters
// files across an existing installation.
$zip->addFile($file->getPathname(), 'kantsites-'.$version.'/'.str_replace('\\', '/', substr($file->getPathname(), strlen($staging) + 1)));
++$entries;
}
}
$zip->close();
$hash = hash_file('sha256', $archive);
file_put_contents($archive.'.sha256', $hash.' '.basename($archive)."\n");
printf("\nArchiv: %s\n", $archive);
printf("Dateien: %d, Größe: %.1f MB\n", $entries, filesize($archive) / 1048576);
printf("SHA-256: %s\n", $hash);
// Und der einzelne Dateidownload: dieselbe install.php, nur mit eingetragener Adresse und
// Prüfsumme. Ohne beides verweigert sie die Arbeit, deshalb entsteht sie erst hier.
$installer = strtr((string) file_get_contents($root.'/scripts/install.php'), [
"const KANT_ARCHIVE_URL = '';" => sprintf("const KANT_ARCHIVE_URL = '%s';", $releaseBase.'/v'.$version.'/'.basename($archive)),
"const KANT_ARCHIVE_SHA256 = '';" => sprintf("const KANT_ARCHIVE_SHA256 = '%s';", $hash),
"const KANT_VERSION = '';" => sprintf("const KANT_VERSION = '%s';", $version),
]);
file_put_contents($outDirectory.'/install.php', $installer);
printf("Installer: %s (lädt %s)\n", $outDirectory.'/install.php', $releaseBase.'/v'.$version.'/'.basename($archive));
echo "\nBeides gehört auf die Releaseseite: das Archiv unter genau dieser Adresse, und install.php zum Herunterladen.\n";
echo "Die Prüfsumme steckt jetzt in install.php, damit ein manipuliertes Archiv nicht entpackt wird.\n";
+36
View File
@@ -75,6 +75,42 @@ try {
await page.getByRole('button', { name: 'Wiederholen', exact: true }).click();
await frame.getByRole('heading', { name: 'Meine bearbeitete Website', exact: true }).waitFor();
checks.push({ name: 'preview selection, property edit, undo and redo', passed: true });
// Die Vorschau darf nie sichtbar sein, solange ihre Gestaltung fehlt. Vorher hing das Anwenden
// am Ladeereignis des Rahmens - und das feuert erst, wenn das letzte Bild geladen ist. Auf der
// nachgebauten Startseite von kant-it.de stand die Vorschau dadurch 1440 ms lang mit falschen
// Farben da: schwarze Knöpfe statt roter.
const beobachtung = page.evaluate(async () => {
const rahmen = document.getElementById('preview');
const verlauf = [];
let ungestylt = 0;
// Zweierlei wird festgehalten: dass der Rahmen ueberhaupt verdeckt wird, waehrend das neue
// Dokument entsteht - und ob er je sichtbar war, ohne seine Gestaltung zu haben.
new MutationObserver(() => verlauf.push(rahmen.style.visibility || 'sichtbar'))
.observe(rahmen, { attributes: true, attributeFilter: ['style'] });
const bis = performance.now() + 5000;
while (performance.now() < bis) {
const doc = rahmen.contentDocument;
if ('visible' === getComputedStyle(rahmen).visibility
&& doc?.getElementById('kant-styles')
&& 0 === (doc.adoptedStyleSheets ?? []).length) {
ungestylt++;
}
await new Promise(r => setTimeout(r, 30));
}
return { verlauf, ungestylt };
});
await page.click('#duplicate');
const { verlauf, ungestylt } = await beobachtung;
assert.equal(ungestylt, 0, 'Die Vorschau war sichtbar, bevor ihre Gestaltung angewandt war.');
// Der Rahmen muss verdeckt worden und danach wieder sichtbar geworden sein. Ohne diese Prüfung
// hinge der Nachweis am Zufall: auf einer kleinen Seite ist das Fenster zu kurz, um es zu
// treffen - auf der nachgebauten Startseite von kant-it.de war es 1440 ms lang.
assert.ok(verlauf.includes('hidden'), 'Die Vorschau wird beim Aufbau nicht verdeckt.');
assert.equal(verlauf[verlauf.length - 1], 'sichtbar', 'Die Vorschau bleibt verdeckt.');
await page.click('#undo');
await page.locator('#preview-status').filter({ hasText: 'Zum Auswählen' }).waitFor();
checks.push({ name: 'preview never shows unstyled content', passed: true });
console.log('Preview edits and history passed.');
await page.getByRole('button', { name: 'Einklappen', exact: true }).click();
+283
View File
@@ -0,0 +1,283 @@
<?php
/**
* KantSites in einer Datei installieren.
*
* Diese Datei per FTP in das Verzeichnis legen, in dem KantSites liegen soll, und im Browser
* aufrufen. Sie lädt das Releasearchiv, prüft dessen Prüfsumme, entpackt es und löscht sich
* danach selbst. Anschließend übernimmt der Installer unter /install.
*
* Die Regeln, nach denen sie gebaut ist - und warum:
*
* - Die Adresse und die Prüfsumme stehen HIER, nicht in einem Formular. Ein Installer, der eine
* URL entgegennimmt und entpackt, ist eine Einladung, fremden Code auf den Server zu legen.
* - Ohne passende Prüfsumme wird nicht entpackt. Es gibt kein Häkchen, das daran vorbeiführt.
* - HTTPS mit Zertifikatsprüfung, kein Rückfall auf HTTP.
* - Jeder Archiveintrag wird gegen das Zielverzeichnis geprüft, bevor er geschrieben wird. Ein
* Eintrag wie ../../.env landet nirgends.
* - Sie arbeitet nur in ein leeres Verzeichnis. Eine bestehende Installation wird nicht
* überschrieben, auch nicht teilweise.
*/
// Vom Buildskript gesetzt: `php scripts/build-release.php` schreibt Adresse und Prüfsumme des
// gebauten Archivs hier hinein. Ohne beides verweigert die Datei die Arbeit - eine unfertige
// install.php soll nicht irgendetwas herunterladen.
const KANT_ARCHIVE_URL = '';
const KANT_ARCHIVE_SHA256 = '';
const KANT_VERSION = '';
/**
* Wohin ein Archiveintrag gehört - oder null, wenn er nirgendwohin gehört.
*
* Der Namensbestandteil der obersten Ebene (kantsites-1.2.0/) fällt weg, damit die Dateien direkt
* im Zielverzeichnis landen. Danach wird der Pfad geprüft, und zwar nicht durch Suchen nach „..“,
* sondern indem der aufgelöste Zielpfad innerhalb des Zielverzeichnisses liegen muss.
*/
function kant_target_path(string $entry, string $target): ?string
{
$entry = str_replace('\\', '/', $entry);
if ('' === $entry || str_ends_with($entry, '/')) {
return null;
}
$parts = explode('/', $entry);
if (count($parts) > 1 && str_starts_with($parts[0], 'kantsites-')) {
array_shift($parts);
}
foreach ($parts as $part) {
if ('' === $part || '.' === $part || '..' === $part || str_contains($part, "\0") || str_contains($part, ':')) {
return null;
}
}
if ([] === $parts) {
return null;
}
$base = rtrim(str_replace('\\', '/', $target), '/');
$path = $base.'/'.implode('/', $parts);
// Doppelt geprüft: der zusammengesetzte Pfad muss unterhalb des Ziels beginnen.
return str_starts_with($path, $base.'/') ? $path : null;
}
/** Lädt eine Adresse über HTTPS mit Zertifikatsprüfung in eine Datei. Gibt eine Meldung oder null. */
function kant_download(string $url, string $file): ?string
{
if (!str_starts_with($url, 'https://')) {
return 'Die Adresse des Archivs ist keine HTTPS-Adresse.';
}
$handle = fopen($file, 'wb');
if (false === $handle) {
return 'Die heruntergeladene Datei ließ sich nicht anlegen: '.$file;
}
try {
if (function_exists('curl_init')) {
$curl = curl_init($url);
curl_setopt_array($curl, [
CURLOPT_FILE => $handle,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_CONNECTTIMEOUT => 20,
CURLOPT_TIMEOUT => 900,
CURLOPT_USERAGENT => 'KantSites-Installer',
]);
$ok = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$error = curl_error($curl);
curl_close($curl);
if (!$ok) {
return 'Der Download ist gescheitert: '.$error;
}
if (200 !== $status) {
return 'Der Server hat mit Status '.$status.' geantwortet statt mit 200.';
}
return null;
}
if (!filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL)) {
return 'Weder curl noch allow_url_fopen ist verfügbar. Ohne eines von beidem kann diese Datei nichts herunterladen; dann bleibt der Weg über FTP mit dem entpackten Archiv.';
}
$context = stream_context_create(['http' => ['timeout' => 900, 'follow_location' => 1, 'max_redirects' => 3], 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]);
$source = @fopen($url, 'rb', false, $context);
if (false === $source) {
return 'Das Archiv ließ sich nicht öffnen. Prüfe, ob dieser Server nach außen verbinden darf.';
}
stream_copy_to_stream($source, $handle);
fclose($source);
return null;
} finally {
if (is_resource($handle)) {
fclose($handle);
}
}
}
/** Entpackt das Archiv; gibt die Anzahl der Dateien zurück oder wirft mit einer lesbaren Meldung. */
function kant_unpack(string $archive, string $target): int
{
if (!class_exists('ZipArchive')) {
throw new RuntimeException('Die PHP-Erweiterung zip fehlt. Ohne sie lässt sich das Archiv nicht entpacken.');
}
$zip = new ZipArchive();
if (true !== $zip->open($archive)) {
throw new RuntimeException('Das Archiv ließ sich nicht öffnen. Der Download ist wahrscheinlich unvollständig.');
}
$written = 0;
try {
for ($index = 0; $index < $zip->numFiles; ++$index) {
$name = (string) $zip->getNameIndex($index);
$path = kant_target_path($name, $target);
if (null === $path) {
if (str_ends_with($name, '/')) {
continue;
}
throw new RuntimeException('Das Archiv enthält einen Eintrag, der außerhalb des Zielverzeichnisses landen würde: '.$name);
}
if (!is_dir(dirname($path)) && !mkdir(dirname($path), 0o775, true) && !is_dir(dirname($path))) {
throw new RuntimeException('Verzeichnis ließ sich nicht anlegen: '.dirname($path));
}
$stream = $zip->getStream($name);
if (false === $stream) {
throw new RuntimeException('Eintrag ließ sich nicht lesen: '.$name);
}
$out = fopen($path, 'wb');
if (false === $out) {
fclose($stream);
throw new RuntimeException('Datei ließ sich nicht schreiben: '.$path);
}
stream_copy_to_stream($stream, $out);
fclose($stream);
fclose($out);
++$written;
}
} finally {
$zip->close();
}
return $written;
}
/** Die Vorprüfung: alles, was fehlschlagen kann, bevor irgendetwas geschrieben wird. */
function kant_checks(string $target): array
{
$checks = [];
$add = static function (string $label, bool $ok, string $hint) use (&$checks): void {
$checks[] = ['label' => $label, 'ok' => $ok, 'hint' => $hint];
};
$add('PHP-Fassung', PHP_VERSION_ID >= 80200, 'Gefunden: '.PHP_VERSION.'. Gebraucht wird mindestens 8.2 – beim Hoster umstellen.');
$add('Schreibrecht im Zielverzeichnis', is_writable($target), $target.' ist nicht beschreibbar. Im FTP-Programm die Rechte auf 0775 setzen.');
$add('ZIP-Erweiterung', class_exists('ZipArchive'), 'Ohne die Erweiterung zip lässt sich das Archiv nicht entpacken.');
$add('Verbindung nach außen', function_exists('curl_init') || filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL), 'Weder curl noch allow_url_fopen ist verfügbar; dann bleibt nur der Weg über FTP.');
$add('Adresse und Prüfsumme eingetragen', '' !== KANT_ARCHIVE_URL && 64 === strlen(KANT_ARCHIVE_SHA256), 'Diese Datei ist unfertig: sie stammt nicht aus einem Release. Lade install.php von der Releaseseite herunter.');
$free = @disk_free_space($target);
$add('Freier Platz', false === $free || $free > 80 * 1024 * 1024, 'Es sind weniger als 80 MB frei. Das Archiv braucht Platz zum Herunterladen und zum Entpacken.');
$add('Verzeichnis ist leer genug', !is_file($target.'/public/index.php') && !is_file($target.'/.env.local'), 'Hier liegt schon eine Installation. Diese Datei überschreibt nichts – bei einem Update ist der Weg das Releasearchiv von Hand oder der Updater in der Verwaltung.');
return $checks;
}
/** Der Ablauf, wenn der Knopf gedrückt wurde. Gibt [Erfolg, Meldung] zurück. */
function kant_run(string $target): array
{
$archive = $target.'/kantsites-download-'.bin2hex(random_bytes(4)).'.zip';
try {
$error = kant_download(KANT_ARCHIVE_URL, $archive);
if (null !== $error) {
return [false, $error];
}
$hash = hash_file('sha256', $archive);
if (!hash_equals(KANT_ARCHIVE_SHA256, (string) $hash)) {
// Nicht entpacken, nicht behalten: eine Datei, die nicht das ist, was sie sein soll,
// hat auf dem Server nichts verloren.
return [false, 'Die Prüfsumme des heruntergeladenen Archivs stimmt nicht. Erwartet: '.KANT_ARCHIVE_SHA256.', erhalten: '.$hash.'. Es wurde nichts entpackt.'];
}
$files = kant_unpack($archive, $target);
return [true, 'Entpackt: '.$files.' Dateien.'];
} catch (Throwable $e) {
return [false, $e->getMessage()];
} finally {
@unlink($archive);
}
}
// Im Test wird diese Datei eingebunden, um die Pfadprüfung und das Entpacken zu prüfen, ohne dass
// etwas heruntergeladen wird.
if ('cli' === PHP_SAPI) {
return;
}
$target = __DIR__;
$checks = kant_checks($target);
$ready = [] === array_filter($checks, static fn (array $check): bool => !$check['ok']);
$done = false;
$message = null;
if ('POST' === ($_SERVER['REQUEST_METHOD'] ?? 'GET') && $ready) {
@set_time_limit(0);
[$done, $message] = kant_run($target);
if ($done) {
// Erst jetzt: die Datei hat ihre Arbeit getan und ist ab hier nur noch ein offener Weg,
// Code auf den Server zu legen.
@unlink(__FILE__);
}
}
$e = static fn (?string $value): string => htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
header('Content-Type: text/html; charset=UTF-8');
header('X-Robots-Tag: noindex, nofollow');
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>KantSites installieren</title>
<style>
:root { color-scheme: light }
body { margin: 0; padding: 40px 20px; background: #f6f7f4; color: #1d2320; font: 15px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif }
main { max-width: 620px; margin: 0 auto; background: #fff; border: 1px solid #e2e7de; border-radius: 14px; padding: 32px }
h1 { font-size: 23px; margin: 0 0 6px }
p.lead { color: #5d6b62; margin-top: 0 }
ul { list-style: none; margin: 22px 0; padding: 0 }
li { display: flex; gap: 10px; align-items: flex-start; margin-bottom: 10px; font-size: 14px }
.mark { width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; font-size: 12px; font-weight: 700; flex-shrink: 0 }
.ok .mark { background: #e7f0d6; color: #2c5340 }
.bad .mark { background: #fff0eb; color: #a3341f }
.bad { color: #a3341f }
button { font: inherit; font-weight: 600; padding: 11px 20px; border-radius: 8px; border: 1px solid #2c5340; background: #2c5340; color: #fff; cursor: pointer }
button[disabled] { opacity: .5; cursor: not-allowed }
.notice { padding: 14px 16px; border-radius: 10px; background: #fff0eb; color: #a3341f; font-size: 14px }
.done { background: #e7f0d6; color: #2c5340 }
code { font-size: 13px; background: #f1f4ee; padding: 1px 5px; border-radius: 4px }
a { color: #2c5340 }
</style>
</head>
<body>
<main>
<?php if ($done) { ?>
<h1>Die Dateien liegen.</h1>
<p class="lead">Diese Datei hat sich selbst gelöscht. Weiter geht es mit der Einrichtung: Datenbank, Konto, Website.</p>
<p class="notice done"><?= $e($message) ?></p>
<p><a href="install">Zur Einrichtung →</a></p>
<p style="font-size:13px;color:#5d6b62">Führt der Verweis ins Leere, zeigt die Domain nicht auf <code>public/</code>. Das muss beim Hoster eingestellt werden – ohne das wären auch <code>.env</code> und <code>var/</code> über das Netz erreichbar.</p>
<?php } else { ?>
<h1>KantSites installieren</h1>
<p class="lead">Diese Datei lädt Fassung <?= $e(KANT_VERSION ?: 'unbekannt') ?> herunter, prüft die Prüfsumme und entpackt sie hierher: <code><?= $e($target) ?></code></p>
<?php if (null !== $message) { ?><p class="notice"><?= $e($message) ?></p><?php } ?>
<ul>
<?php foreach ($checks as $check) { ?>
<li class="<?= $check['ok'] ? 'ok' : 'bad' ?>">
<span class="mark" aria-hidden="true"><?= $check['ok'] ? '&check;' : '!' ?></span>
<span><strong><?= $e($check['label']) ?></strong><?= $check['ok'] ? '' : ' – '.$e($check['hint']) ?></span>
</li>
<?php } ?>
</ul>
<form method="post">
<button type="submit" <?= $ready ? '' : 'disabled' ?>>Herunterladen und entpacken</button>
</form>
<p style="font-size:13px;color:#5d6b62">Das dauert je nach Anbindung eine halbe bis mehrere Minuten. Adresse und Prüfsumme stehen fest in dieser Datei; ein Archiv mit abweichender Prüfsumme wird nicht entpackt.</p>
<?php } ?>
</main>
</body>
</html>
+102
View File
@@ -0,0 +1,102 @@
/*
* Prüft jeden internen Verweis einer Website auf seine Antwort.
*
* Ein Link, der ins Leere zeigt, ist der Fehler, den niemand bemerkt: Tests prüfen, was der Code
* erzeugt, nicht ob die Adresse dahinter existiert. Im Nachbau von kant-it.de zeigten vier
* Verweise im Hero-Slider auf Adressen, die es nie gab - gefunden hat das kein Test, sondern ein
* Durchlauf wie dieser.
*
* Ausgangspunkt ist die Sitemap, danach wird jeder Seite gefolgt, die von dort erreichbar ist.
* Geprüft werden Seiten, Bilder, Stylesheets und Skripte; externe Adressen werden gezählt, aber
* nicht abgerufen - ob fremde Server antworten, ist nicht die Frage dieser Prüfung.
*
* CMS_URL=http://127.0.0.1:8080 npm run test:links
*/
import { chromium } from 'playwright';
import assert from 'node:assert/strict';
import { mkdir, writeFile } from 'node:fs/promises';
const origin = (process.env.CMS_URL || 'http://127.0.0.1:8080').replace(/\/$/, '');
const browser = await chromium.launch();
const geprueft = new Map();
const extern = new Set();
const kaputt = [];
const quellen = new Map();
try {
const context = await browser.newContext();
const page = await context.newPage();
const antwort = async (url) => {
if (!geprueft.has(url)) {
const response = await context.request.get(url, { maxRedirects: 5 });
geprueft.set(url, response.status());
}
return geprueft.get(url);
};
const sitemap = await context.request.get(origin + '/sitemap.xml');
assert.equal(sitemap.status(), 200, 'Ohne Sitemap fehlt der Ausgangspunkt.');
const seiten = [...(await sitemap.text()).matchAll(/<loc>([^<]+)<\/loc>/g)]
.map(treffer => new URL(treffer[1]).pathname);
console.log(`Sitemap: ${seiten.length} Adresse(n).`);
const besucht = new Set();
const warteschlange = ['/', ...seiten];
while (warteschlange.length > 0) {
const pfad = warteschlange.shift();
if (besucht.has(pfad)) continue;
besucht.add(pfad);
const response = await page.goto(origin + pfad, { waitUntil: 'domcontentloaded' });
if (200 !== response.status()) {
kaputt.push({ status: response.status(), url: pfad, von: 'Sitemap' });
continue;
}
// Verweise und eingebundene Dateien derselben Website.
const verweise = await page.evaluate(() => ({
links: [...document.querySelectorAll('a[href]')].map(a => a.getAttribute('href')),
dateien: [
...[...document.querySelectorAll('img[src]')].map(i => i.getAttribute('src')),
...[...document.querySelectorAll('img[srcset]')].flatMap(i => i.getAttribute('srcset').split(',').map(teil => teil.trim().split(/\s+/)[0])),
...[...document.querySelectorAll('link[rel="stylesheet"], link[rel="preload"]')].map(l => l.getAttribute('href')),
...[...document.querySelectorAll('script[src]')].map(s => s.getAttribute('src')),
...[...document.querySelectorAll('[style*="url("]')].flatMap(e => [...e.getAttribute('style').matchAll(/url\(["']?([^"')]+)/g)].map(m => m[1])),
],
stile: [...document.querySelectorAll('style')].flatMap(s => [...s.textContent.matchAll(/url\(["']?([^"')]+)/g)].map(m => m[1])),
}));
for (const roh of [...verweise.links, ...verweise.dateien, ...verweise.stile]) {
if (!roh || roh.startsWith('data:') || roh.startsWith('#') || roh.startsWith('mailto:') || roh.startsWith('tel:')) continue;
const ziel = new URL(roh, origin + pfad);
if (ziel.origin !== origin) {
extern.add(ziel.origin);
continue;
}
const adresse = ziel.pathname + ziel.search;
const status = await antwort(origin + adresse);
if (status >= 400) {
kaputt.push({ status, url: adresse, von: pfad });
}
// Eigenen Seiten folgen wir weiter; Dateien nicht.
if (verweise.links.includes(roh) && !/\.[a-z0-9]{2,5}(\?|$)/i.test(ziel.pathname) && !besucht.has(ziel.pathname)) {
warteschlange.push(ziel.pathname);
quellen.set(ziel.pathname, pfad);
}
}
}
await mkdir('reports', { recursive: true });
await writeFile('reports/links.json', JSON.stringify({ origin, geprueft: geprueft.size, seiten: besucht.size, extern: [...extern], kaputt }, null, 2));
console.log(`${besucht.size} Seite(n), ${geprueft.size} Adresse(n) geprüft, ${extern.size} fremde Quelle(n): ${[...extern].join(', ') || 'keine'}`);
if (kaputt.length > 0) {
for (const eintrag of kaputt) {
console.error(` ${eintrag.status} ${eintrag.url} (verlinkt auf ${eintrag.von})`);
}
throw new Error(`${kaputt.length} Verweis(e) zeigen ins Leere.`);
}
console.log('Kein toter Verweis.');
} finally {
await browser.close();
}
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace App\Command;
use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Ändert die Adresse der Verwaltung.
*
* `/admin` ist die erste Adresse, die ein Scanner probiert, und jeder Anmeldeversuch, der dort
* ankommt, kostet Rechenzeit und Logzeilen. Heißt die Verwaltung anders, findet sie niemand, der
* sie nicht kennt. Das ersetzt kein gutes Passwort - es nimmt der Maschinerie nur das Ziel.
*
* Der Wert steht in `.env.local` und nicht in der Datenbank: die Routen stehen fest, bevor die
* erste Datenbankabfrage läuft. Deshalb dieser Befehl - er schreibt die Datei, prüft vorher, dass
* die Adresse benutzbar ist, und räumt den Zwischenspeicher auf, damit die neue Adresse sofort
* gilt.
*/
#[AsCommand(name: 'cms:admin:path', description: 'Zeigt die Adresse der Verwaltung oder ändert sie.')]
final class AdminPathCommand extends Command
{
/** Adressen, die schon vergeben sind oder es werden könnten. */
private const TABU = ['assets', 'media', 'install', 'einwilligung', 'formular', 'kategorie', 'schlagwort', 'beitraege', 'feed', 'sitemap', 'robots', 'llms', 'favicon'];
public function __construct(private Connection $db, private string $projectDir, private string $adminPath, private string $environment)
{
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('pfad', InputArgument::OPTIONAL, 'Die neue Adresse, ohne Schrägstrich. Ohne Angabe wird die aktuelle gezeigt.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$neu = trim((string) $input->getArgument('pfad'), " \t/");
if ('' === $neu) {
$io->writeln(sprintf('Die Verwaltung liegt unter <info>/%s</info>.', $this->adminPath));
$io->writeln('Ändern: <comment>php bin/console cms:admin:path meine-verwaltung</comment>');
return Command::SUCCESS;
}
if (1 !== preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $neu) || strlen($neu) > 40) {
$io->error('Erlaubt sind Kleinbuchstaben, Ziffern und Bindestriche, höchstens 40 Zeichen.');
return Command::FAILURE;
}
if (in_array($neu, self::TABU, true)) {
$io->error(sprintf('„%s" ist für die Website selbst reserviert.', $neu));
return Command::FAILURE;
}
// Eine Seite mit dieser Adresse wäre von der Verwaltung verdeckt - unauffindbar, ohne dass
// jemand merkt, warum.
$seite = $this->db->fetchOne('SELECT path FROM page WHERE website_id = ? AND path = ? AND deleted_at IS NULL', ['default', $neu]);
if (is_string($seite)) {
$io->error(sprintf('Es gibt bereits eine Seite unter /%s. Die Verwaltung würde sie verdecken.', $neu));
return Command::FAILURE;
}
$datei = $this->projectDir.'/.env.local';
$inhalt = is_file($datei) ? (string) file_get_contents($datei) : '';
$zeile = sprintf('ADMIN_PATH=%s', $neu);
$inhalt = 1 === preg_match('/^ADMIN_PATH=.*$/m', $inhalt)
? (string) preg_replace('/^ADMIN_PATH=.*$/m', $zeile, $inhalt)
: rtrim($inhalt)."\n".$zeile."\n";
if (false === @file_put_contents($datei, ltrim($inhalt, "\n"))) {
$io->error('.env.local ist nicht beschreibbar. Trage dort von Hand ein: '.$zeile);
return Command::FAILURE;
}
// Ohne das Aufräumen gälte die alte Adresse weiter - und die neue wäre nicht erreichbar.
// Die Umgebung, in der dieser Befehl gerade läuft, bleibt dabei unangetastet: Symfony lädt
// Dienste nach, und wer sich den Boden unter den Füßen wegräumt, endet mit einem Fehler,
// obwohl alles geklappt hat. Der nächste Aufruf baut sie ohnehin neu.
$offen = [];
foreach (glob($this->projectDir.'/var/cache/*') ?: [] as $pfad) {
if (!is_dir($pfad)) {
continue;
}
if (basename($pfad) === $this->environment) {
$offen[] = basename($pfad);
continue;
}
$this->removeDirectory($pfad);
}
$io->success(sprintf('Die Verwaltung liegt jetzt unter /%s.', $neu));
if ([] !== $offen) {
$io->writeln(sprintf('Falls die Website noch die alte Adresse kennt: <comment>php bin/console cache:clear --env=%s</comment>', implode(' --env=', $offen)));
}
if ($this->adminPath !== $neu) {
$io->writeln('Merk dir die Adresse: <comment>/'.$this->adminPath.'</comment> antwortet ab sofort mit 404, wie jede andere unbekannte Adresse auch.');
}
$io->writeln('Zurück geht es mit: <comment>php bin/console cms:admin:path admin</comment>');
return Command::SUCCESS;
}
private function removeDirectory(string $pfad): void
{
if (!is_dir($pfad)) {
return;
}
foreach (scandir($pfad) ?: [] as $eintrag) {
if ('.' === $eintrag || '..' === $eintrag) {
continue;
}
$voll = $pfad.'/'.$eintrag;
is_dir($voll) ? $this->removeDirectory($voll) : @unlink($voll);
}
@rmdir($pfad);
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Command;
use App\Domain\InvalidPage;
use App\Ops\Backup;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'cms:backup', description: 'Writes a backup of database and media, and rotates the old ones. Meant for a daily cron job.')]
final class BackupCommand extends Command
{
public function __construct(private Backup $backup)
{
parent::__construct();
}
protected function configure(): void
{
$this->addOption('keep', null, InputOption::VALUE_REQUIRED, 'Wie viele Sicherungen aufbewahrt werden.', (string) Backup::KEEP)
->addOption('list', null, InputOption::VALUE_NONE, 'Nur auflisten, was vorhanden ist.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if ($input->getOption('list')) {
$rows = array_map(
static fn (array $backup): array => [
$backup['name'],
$backup['manifest']['erstellt'] ?? '?',
$backup['manifest']['fassung'] ?? '?',
round($backup['bytes'] / 1048576, 1).' MB',
$backup['intact'] ? 'ok' : 'PRÜFSUMME FALSCH',
],
$this->backup->all(),
);
[] === $rows ? $io->writeln('Noch keine Sicherung vorhanden.') : $io->table(['Name', 'Erstellt', 'Fassung', 'Größe', 'Zustand'], $rows);
return Command::SUCCESS;
}
try {
$result = $this->backup->create('Konsole');
} catch (InvalidPage $error) {
$io->error($error->getMessage());
return Command::FAILURE;
}
$removed = $this->backup->rotate((int) $input->getOption('keep'));
$io->success(sprintf(
'%s angelegt: %d Seite(n), %d Fassung(en), %d Mediendatei(en).%s',
$result['name'],
$result['manifest']['inhalt']['seiten'],
$result['manifest']['inhalt']['fassungen'],
$result['manifest']['medien']['dateien'],
[] === $removed ? '' : ' Entfernt: '.implode(', ', $removed).'.',
));
$io->writeln('Nicht enthalten: .env, vendor/ und der Quellcode – die kommen aus dem Repository.');
return Command::SUCCESS;
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Command;
use App\Content\FontLibrary;
use App\Content\Theme;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Holt die Schriften des Designs auf den eigenen Server.
*
* Dasselbe wie der Knopf im Designbereich, für Installationen mit Konsolenzugang - und für den
* Fall, dass eine frisch aufgesetzte Website die Dateien noch nicht hat.
*/
#[AsCommand(name: 'cms:fonts:fetch', description: 'Lädt die Google-Schriften des Designs nach public/assets/fonts.')]
final class FetchFontsCommand extends Command
{
public function __construct(private Theme $theme, private FontLibrary $fonts)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
$result = $this->fonts->fetch($this->theme->load());
} catch (\RuntimeException $error) {
$io->error($error->getMessage());
return Command::FAILURE;
}
$io->success(sprintf(
'%d Datei(en) geladen, %d KB insgesamt: %s. Die Seiten laden jetzt nichts mehr von Google.',
$result['files'],
(int) round($result['bytes'] / 1024),
implode(', ', $result['families']),
));
return Command::SUCCESS;
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Command;
use App\Ops\Backup;
use App\Ops\Updater;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Restoring is deliberately console only.
*
* A button that replaces the whole database does not belong next to a button that saves a page.
* The administration shows which backups exist and whether they are intact; putting one back is a
* decision made with a shell in hand.
*/
#[AsCommand(name: 'cms:restore', description: 'Puts a backup back in place: checks, safety copy, database, media.')]
final class RestoreCommand extends Command
{
public function __construct(private Backup $backup, private Updater $updater)
{
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('name', InputArgument::REQUIRED, 'Name der Sicherung, etwa sicherung-20260912-181159.')
->addOption('force', null, InputOption::VALUE_NONE, 'Auch eine Sicherung aus einer neueren Fassung einspielen.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$name = (string) $input->getArgument('name');
if ($input->isInteractive() && !$io->confirm(sprintf('Die Datenbank wird durch %s ersetzt. Fortfahren?', $name), false)) {
$io->writeln('Abgebrochen. Es wurde nichts verändert.');
return Command::SUCCESS;
}
// The site says "come back later" while the database is being replaced.
$this->updater->maintenance(true);
$result = $this->backup->restore($name, (bool) $input->getOption('force'));
foreach ($result['steps'] as $step) {
$io->writeln(sprintf('%s %s: %s', $step['ok'] ? ' ok ' : ' FEHLER', $step['step'], $step['detail']));
}
$this->updater->maintenance(false);
if (!$result['ok']) {
$io->error('Die Wiederherstellung ist nicht gelaufen. Die Installation ist unverändert.');
return Command::FAILURE;
}
$io->success('Wiederhergestellt. Prüfe jetzt die Website und räume danach den Cache: php bin/console cache:clear --env=prod');
return Command::SUCCESS;
}
}
+35
View File
@@ -129,6 +129,41 @@ final class DemoContent
*
* @return array<string, array{name: string, document: array}>
*/
/**
* The start page of a fresh installation - not demo content.
*
* Short on purpose. Somebody who has just installed should find a page they can edit, not a
* brochure about the CMS they have to delete first. Exactly one H1, as every page requires.
*/
public static function starterPage(string $siteName): array
{
return [
'schemaVersion' => 16,
'title' => $siteName,
'description' => 'NOCH AUSZUFÜLLEN: ein Satz darüber, was es hier gibt. Er erscheint in Suchergebnissen.',
'blocks' => [
self::section('start-hero', [
self::heading('start-heading', $siteName, 1),
self::text('start-intro', 'Diese Seite ist der Anfang. Melde dich an, öffne sie im Editor und ersetze diesen Text durch deinen eigenen.'),
], 'mint'),
self::section('start-body', [
self::heading('start-next-heading', 'Die nächsten Schritte', 2),
self::text('start-next', "Impressum und Datenschutzerklärung sind bereits angelegt und müssen noch ausgefüllt werden – die Pflichtangaben sind darin markiert.\n\nUnter „Design“ legst du Farben, Schriften und die Breite fest, unter „Menüs“ die Navigation, unter „Einstellungen“ den Namen und die Angaben zum Unternehmen."),
], 'paper', 'narrow'),
],
];
}
/** Header and footer of a fresh installation, with the site name instead of a wordmark. */
public static function starterAreas(string $siteName): array
{
$areas = self::areas();
$areas['header']['document']['blocks'][0]['children'][0]['children'][0]['children'][0]['props']['text'] = $siteName;
$areas['footer']['document']['blocks'][0]['children'][0]['children'][0]['children'][0]['props']['text'] = '© '.date('Y').' '.$siteName;
return $areas;
}
public static function areas(): array
{
$row = static fn (string $id, array $children): array => [
+118 -8
View File
@@ -25,6 +25,14 @@ final class DocumentValidator
public const V11_TYPES = ['partialref', 'menu'];
/** Schema v14: content from a third party, which stays unloaded until consent is given. */
public const V14_TYPES = ['embed'];
/** The sizes an icon can have; each one has a class in site.css. */
public const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240];
/** Schema v18: die Navigation zwischen Beiträgen, vom Renderer gefüllt. */
public const V18_TYPES = ['postnav'];
/** Die Rechtszeile als Baustein: gestaltbar und dort, wo sie hingehoert. */
public const V19_TYPES = ['legal'];
/** Schema v15: a single icon, a progress bar and a slider that works without a script. */
public const V15_TYPES = ['icon', 'progress', 'slider'];
/**
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
@@ -34,7 +42,7 @@ final class DocumentValidator
public function validate(array $document, bool $requireHeading = true): void
{
$schemaVersion = $document['schemaVersion'] ?? null;
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], true), 'Unsupported document schema.');
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19], true), 'Unsupported document schema.');
$this->text($document['title'] ?? null, 160);
$this->text($document['description'] ?? null, 320);
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.');
@@ -62,7 +70,7 @@ final class DocumentValidator
// page: a deactivated plugin must never make stored content unreadable. Nothing from
// props is rendered for such a block, so unvalidated values never reach the output.
$plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type);
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_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)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
// An accordion entry only ever renders inside an accordion; at the top level or anywhere
// else it would produce a details element without its group.
$this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.');
@@ -90,7 +98,14 @@ final class DocumentValidator
foreach ($layout as $key => $value) {
if ('effects' === $key && $schemaVersion >= 3) {
$this->require(is_array($value), 'Effects must be an object.');
$effects = ['shadow' => ['none', 'soft', 'strong'], 'hover' => ['none', 'lift', 'zoom'], 'entrance' => ['none', 'fade', 'rise']];
$effects = [
'shadow' => ['none', 'soft', 'strong'],
'hover' => ['none', 'lift', 'zoom', 'sink', 'glow', 'tilt', 'grow-border'],
// Entrance animations run when the page loads, not when the element scrolls
// into view: that would need a script, and a public page runs none.
'entrance' => ['none', 'fade', 'rise', 'sink', 'left', 'right', 'zoom', 'flip', 'blur'],
'delay' => ['none', 'short', 'medium', 'long'],
];
foreach ($value as $effect => $token) {
$this->require(isset($effects[$effect]) && in_array($token, $effects[$effect], true), 'Unknown effect.');
}
@@ -101,17 +116,24 @@ final class DocumentValidator
continue;
}
$this->require(in_array($key, ['tone', 'width'], true), 'Unknown layout setting.');
$this->require(in_array($value, 'tone' === $key ? ['paper', 'ink', 'mint'] : ['normal', 'narrow'], true), 'Invalid design token.');
$this->require(in_array($value, 'tone' === $key ? ['paper', 'ink', 'mint'] : ['normal', 'narrow', 'wide', 'full'], true), 'Invalid design token.');
}
if (in_array($type, ['heading', 'text', 'button'], true)) {
// From v12 a text block may carry the markup RichText allows, which needs more room.
$limit = 'text' === $type ? (RichText::supports($schemaVersion) ? RichText::MAX_LENGTH : 10000) : 300;
// From v17 a heading may too - one highlighted word is a design need, not a luxury.
$limit = 'text' === $type ? (RichText::supports($schemaVersion) ? RichText::MAX_LENGTH : 10000) : ('heading' === $type && RichText::headings($schemaVersion) ? 2000 : 300);
$this->text($props['text'] ?? null, $limit);
}
if ('heading' === $type) {
$this->require(is_int($props['level'] ?? null) && $props['level'] >= 1 && $props['level'] <= 6, 'Invalid heading level.');
$headings += 1 === $props['level'] ? 1 : 0;
}
if ('postnav' === $type) {
// Nur die Beschriftungen sind Inhalt. Welche Beiträge es sind, entscheidet der
// Renderer beim Ausliefern - gespeicherter Inhalt kann keinen Beitrag behaupten.
$this->text($props['previousLabel'] ?? null, 60);
$this->text($props['nextLabel'] ?? null, 60);
}
if ('postlist' === $type) {
// The list is filled by the renderer, never by stored content: the block only says
// how many posts to show and which term to limit them to.
@@ -121,6 +143,30 @@ final class DocumentValidator
$this->require(is_string($props[$kind]) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props[$kind]) && strlen($props[$kind]) <= 80, 'Ungültiger Filter für die Beitragsliste.');
}
}
// Was eine Karte zeigt. Ein Projektraster besteht aus Bild und Titel; Datum, Auszug
// und Kategorie gehören in ein Blogarchiv und lassen eine Projektseite falsch
// aussehen. Voreinstellung ist der alte Zustand, damit bestehende Seiten sich nicht
// ändern, wenn niemand etwas einstellt.
if (isset($props['layout'])) {
// Drei Darstellungen für dieselben Daten: Raster aus Karten, Liste mit Bild
// daneben, und eine reine Titelliste. Welche passt, hängt vom Ort ab - ein
// Projektraster auf der Startseite, eine Liste im Archiv, eine Titelliste in
// einer Randspalte.
$this->require($schemaVersion >= 18, 'Die Darstellung der Beitragsliste braucht Schema v18.');
$this->require(in_array($props['layout'], ['karten', 'liste', 'kompakt', 'wechsel'], true), 'Unbekannte Darstellung der Beitragsliste.');
}
if (isset($props['paginate'])) {
// Eine Auswahl zeigt die ersten n und hört auf. Eine Übersicht blättert - sonst
// verschweigt sie, dass es mehr gibt. Das ist die eigene Regel aus Arbeitspaket M.
$this->require($schemaVersion >= 17, 'Das Blättern einer Beitragsliste braucht Schema v17.');
$this->require(is_bool($props['paginate']), 'Das Blättern ist ja oder nein.');
}
foreach (['showMeta', 'showExcerpt', 'showTerms'] as $switch) {
if (isset($props[$switch])) {
$this->require($schemaVersion >= 17, 'Die Anzeigeoptionen der Beitragsliste brauchen Schema v17.');
$this->require(is_bool($props[$switch]), sprintf('"%s" ist ja oder nein.', $switch));
}
}
}
if ('button' === $type) {
$this->url($props['href'] ?? null);
@@ -173,14 +219,59 @@ final class DocumentValidator
$this->text($props['title'] ?? null, 160);
}
if ('menu' === $type) {
// The entries live in the menu administration; the block only says which slot to show.
// The entries live in the menu administration; the block only says which slot to show
// and how to lay it out.
$this->require(isset(MenuRepository::SLOTS[$props['slot'] ?? '']), 'Unknown menu slot.');
if (isset($props['layout'])) {
$this->require($schemaVersion >= 16, 'Die Menüart braucht Schema v16.');
$this->require(in_array($props['layout'], ['horizontal', 'vertical', 'burger'], true), 'Unbekannte Menüart.');
}
}
if ('partialref' === $type) {
// Only the address of the section is content. Its blocks live in the partial, so one
// change reaches every page that links it and a page cannot alter a shared section.
$this->require(is_string($props['partial'] ?? null) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props['partial']) && strlen($props['partial']) <= 80, 'Invalid section address.');
}
if ('icon' === $type) {
$this->require(in_array($props['icon'] ?? null, self::ICONS, true), 'Unknown icon.');
// A fixed list, because the size becomes a class: a value without a class would leave
// an icon with no dimensions at all, and an SVG without dimensions is invisible.
$this->require(in_array($props['size'] ?? null, self::ICON_SIZES, true), sprintf('Die Symbolgröße ist eine aus %s.', implode(', ', self::ICON_SIZES)));
// A decorative icon is hidden from assistive technology; one that carries meaning
// needs a label, or a screen reader announces nothing at all.
$this->require(is_bool($props['decorative'] ?? null), 'Der Zweck des Symbols ist anzugeben.');
$this->text($props['label'] ?? null, 120, $props['decorative']);
$this->require(!$props['decorative'] || '' === $props['label'], 'Ein dekoratives Symbol hat keine Beschriftung.');
if ('' !== ($props['href'] ?? '')) {
$this->url($props['href']);
}
}
if ('progress' === $type) {
$this->text($props['label'] ?? null, 120);
$this->require(is_int($props['value'] ?? null) && $props['value'] >= 0 && $props['value'] <= 100, 'Der Wert liegt zwischen 0 und 100.');
$this->require(is_bool($props['showValue'] ?? null), 'Die Anzeige des Werts ist anzugeben.');
}
if ('slider' === $type) {
// The slider is a scroll container: how many slides are visible per device is the
// only thing it stores, the slides themselves are ordinary blocks.
foreach (['desktop', 'tablet', 'mobile'] as $device) {
$key = $device.'Visible';
$this->require(is_int($props[$key] ?? null) && $props[$key] >= 1 && $props[$key] <= 6, sprintf('"%s" liegt zwischen 1 und 6.', $key));
}
$this->require(is_bool($props['snap'] ?? null), 'Das Einrasten ist anzugeben.');
if (isset($props['autoplay'])) {
// Sekunden bis zur nächsten Folie. 0 heißt aus, und unter drei Sekunden wäre es
// keine Hilfe mehr, sondern Hektik.
$this->require($schemaVersion >= 17, 'Das automatische Weiterblättern braucht Schema v17.');
$this->require(is_int($props['autoplay']) && (0 === $props['autoplay'] || ($props['autoplay'] >= 3 && $props['autoplay'] <= 30)), 'Das Weiterblättern liegt bei 0 (aus) oder 3 bis 30 Sekunden.');
}
if (isset($props['dots'])) {
// Punkte und Pfeile zeichnet der Browser aus dem Scrollbereich selbst; das
// geht erst ab v17, weil es vorher keine Eigenschaft dafür gab.
$this->require($schemaVersion >= 17, 'Punkte am Slider brauchen Schema v17.');
$this->require(is_bool($props['dots']), 'Die Punkte sind ja oder nein.');
}
}
if ('embed' === $type) {
// Everything about the embed is validated here, because the address ends up in an
// iframe and in the content security policy of the page.
@@ -203,8 +294,24 @@ final class DocumentValidator
// so a stored document can never redirect a submission or change what is asked.
$this->require(is_string($props['form'] ?? null) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props['form']) && strlen($props['form']) <= 80, 'Invalid form address.');
}
if ('gallery' === $type && isset($props['lightbox'])) {
$this->require($schemaVersion >= 19, 'Der Bildbetrachter braucht Schema v19.');
$this->require(is_bool($props['lightbox']), 'Der Bildbetrachter ist an oder aus.');
}
if ('image' === $type) {
$this->url($props['src'] ?? null);
// Ein Bild darf verlinken. Klingt selbstverstaendlich, fehlte aber: ein Logo im
// Kopfbereich ohne Verweis auf die Startseite ist der erste Klick, den jeder sucht.
if ('' !== ($props['href'] ?? '')) {
$this->require($schemaVersion >= 17, 'Ein verlinktes Bild braucht Schema v17.');
$this->url($props['href']);
}
// Was ein Klick auf das Bild tut. Ohne Angabe: nichts - wie bisher.
if (isset($props['click'])) {
$this->require($schemaVersion >= 19, 'Die Klickwirkung eines Bildes braucht Schema v19.');
$this->require(in_array($props['click'], ['keine', 'vergroessern', 'link'], true), 'Für den Klick sind „keine", „vergroessern" und „link" vorgesehen.');
$this->require('link' !== $props['click'] || '' !== ($props['href'] ?? ''), 'Ein Bild, das auf eine Adresse zeigen soll, braucht eine.');
}
$this->require(is_bool($props['decorative'] ?? null), 'Image purpose is required.');
$this->text($props['alt'] ?? null, 300, $props['decorative']);
$this->require(!$props['decorative'] || '' === $props['alt'], 'Decorative images need empty alternative text.');
@@ -223,7 +330,10 @@ final class DocumentValidator
$this->require(is_string($props['mediaId']) && 1 === preg_match('/^[a-f0-9]{32}$/D', $props['mediaId']), 'Invalid media ID.');
}
if (isset($props['sources'])) {
$this->require(is_array($props['sources']) && array_is_list($props['sources']) && count($props['sources']) <= 3, 'Invalid image variants.');
// As many entries as the media library produces widths, not one more: the list ends
// up in a srcset, and the number has to move with the pipeline instead of standing
// here as a number nobody connects to it.
$this->require(is_array($props['sources']) && array_is_list($props['sources']) && count($props['sources']) <= count(MediaLibrary::WIDTHS), 'Invalid image variants.');
$widths = [];
foreach ($props['sources'] as $source) {
$this->require(is_array($source) && is_int($source['width'] ?? null) && $source['width'] > 0 && $source['width'] <= 8000 && !in_array($source['width'], $widths, true), 'Invalid image variant width.');
@@ -236,7 +346,7 @@ final class DocumentValidator
$children = $block['children'] ?? [];
$this->require(is_array($children) && array_is_list($children), 'Children must be a list.');
// A plugin block may nest content; keeping its children is part of preserving the page.
$container = $plugin || in_array($type, ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem'], true);
$container = $plugin || in_array($type, ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'], true);
$this->require($container || [] === $children, 'Leaf blocks cannot contain children.');
// Containers that only accept one kind of child, so the rendered markup stays valid.
$only = ['gallery' => 'image', 'logos' => 'image', 'accordion' => 'accordionitem', 'tabs' => 'tabitem'];
+272
View File
@@ -0,0 +1,272 @@
<?php
namespace App\Content;
/**
* Schriften, die die Website selbst ausliefert.
*
* Eine Schrift von fonts.googleapis.com kostet zweierlei. Technisch: der Browser muss erst das
* Stylesheet holen, darin die Adresse der Schriftdatei finden und dann diese holen - zwei fremde
* Verbindungen hintereinander, bevor die erste Zeile in der richtigen Schrift steht. Rechtlich:
* dabei erfährt ein Dritter die IP-Adresse jedes Besuchers, und dafür gibt es in Deutschland
* Abmahnungen (LG München I, 3 O 17493/20).
*
* Beides verschwindet, wenn die Dateien auf dem eigenen Server liegen. Die Lizenz erlaubt das:
* die Schriften bei Google Fonts stehen unter der SIL Open Font License oder Apache 2.0.
*
* Heruntergeladen wird einmal auf Zuruf, nicht bei einem Seitenaufruf - ein Besucher soll nie auf
* einen fremden Server warten, auch nicht mittelbar.
*/
final class FontLibrary
{
/**
* Der Browser, als den wir uns ausgeben.
*
* Google liefert je nach Browser ein anderes Stylesheet: alten Browsern TTF, neuen WOFF2. Ohne
* diese Kennung bekämen wir Dateien, die viermal so groß sind.
*/
private const AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
/** Zeichenbereiche, die eine deutschsprachige Website braucht. Kyrillisch und Vietnamesisch nicht. */
private const SUBSETS = ['latin', 'latin-ext'];
public function __construct(private string $directory)
{
}
/** Liegen für dieses Theme fertige Dateien bereit? */
public function hasLocal(array $theme): bool
{
return [] === $this->missing($theme) && is_file($this->directory.'/fonts.css');
}
/**
* Die Schriften des Themes, für die noch keine lokale Datei existiert.
*
* @return list<string> Beschriftungen wie „Lato 700“
*/
public function missing(array $theme): array
{
$missing = [];
foreach ($this->wanted($theme) as $family => $weights) {
foreach ($weights as $weight) {
if ([] === glob($this->directory.'/'.$this->slug($family).'-'.$weight.'-*.woff2')) {
$missing[] = $family.' '.$weight;
}
}
}
return $missing;
}
/**
* Holt alle Schriften des Themes und schreibt fonts.css daneben.
*
* @return array{files: int, bytes: int, families: list<string>}
*/
public function fetch(array $theme): array
{
$wanted = $this->wanted($theme);
if ([] === $wanted) {
throw new \RuntimeException('Dieses Design nutzt keine Schrift von Google Fonts.');
}
if (!is_dir($this->directory) && !mkdir($this->directory, 0775, true) && !is_dir($this->directory)) {
throw new \RuntimeException('Der Schriftenordner ist nicht beschreibbar: '.$this->directory);
}
$rules = [];
$files = 0;
$bytes = 0;
foreach ($wanted as $family => $weights) {
$css = $this->stylesheet($family, $weights);
foreach ($this->parse($css, $family) as $face) {
$name = $this->slug($family).'-'.$face['weight'].'-'.$face['subset'].'.woff2';
$path = $this->directory.'/'.$name;
if (!is_file($path)) {
file_put_contents($path, $this->download($face['url']));
++$files;
}
$bytes += (int) filesize($path);
$rules[] = sprintf(
"/* %s */\n@font-face{font-family:'%s';font-style:%s;font-weight:%d;font-display:swap;src:url(/assets/fonts/%s) format('woff2');unicode-range:%s}",
$face['subset'],
$family,
$face['style'],
$face['weight'],
$name,
$face['range'],
);
}
}
if ([] === $rules) {
throw new \RuntimeException('Google Fonts hat keine passende Schriftdatei geliefert.');
}
file_put_contents($this->directory.'/fonts.css', implode("\n", $rules)."\n");
return ['files' => $files, 'bytes' => $bytes, 'families' => array_keys($wanted)];
}
/**
* Die Dateien, die für den ersten Eindruck vorab geholt werden sollen.
*
* Nur der lateinische Grundbereich und nur die Schnitte, die im Fließtext und in
* Überschriften stehen - ein Vorabholen, das alles holt, verzögert genau das, was es
* beschleunigen soll.
*
* @return list<string>
*/
public function preload(array $theme): array
{
$preload = [];
foreach ($this->wanted($theme) as $family => $weights) {
foreach (array_slice($weights, 0, 2) as $weight) {
$name = $this->slug($family).'-'.$weight.'-latin.woff2';
if (is_file($this->directory.'/'.$name)) {
$preload[] = '/assets/fonts/'.$name;
}
}
}
return $preload;
}
/**
* Welche Familien und Schnitte das Theme von Google bezieht.
*
* @return array<string, list<int>>
*/
private function wanted(array $theme): array
{
$wanted = [];
foreach ($theme['fonts'] ?? [] as $font) {
if ('google' !== ($font['source'] ?? '') || '' === ($font['family'] ?? '')) {
continue;
}
$weights = array_map(intval(...), $font['weights'] ?? [400]);
sort($weights);
$wanted[$font['family']] = array_values(array_unique([...($wanted[$font['family']] ?? []), ...$weights]));
sort($wanted[$font['family']]);
}
return $wanted;
}
private function stylesheet(string $family, array $weights): string
{
$url = sprintf(
'https://fonts.googleapis.com/css2?family=%s:wght@%s&display=swap',
rawurlencode($family),
implode(';', $weights),
);
$css = $this->get($url);
if (!str_contains($css, '@font-face')) {
throw new \RuntimeException(sprintf('Google Fonts liefert für „%s“ keine Schrift. Schreibt sich die Familie genau so?', $family));
}
return $css;
}
private function download(string $url): string
{
if (!str_starts_with($url, 'https://fonts.gstatic.com/')) {
throw new \RuntimeException('Die Schriftdatei käme von einer fremden Adresse: '.$url);
}
$body = $this->get($url);
// Eine WOFF2-Datei beginnt mit "wOF2". Was anderes ist, wird nicht gespeichert.
if (!str_starts_with($body, 'wOF2')) {
throw new \RuntimeException('Die geladene Datei ist keine WOFF2-Schrift.');
}
return $body;
}
/**
* Liest die @font-face-Blöcke eines Google-Stylesheets.
*
* Der Zeichenbereich steht in einem Kommentar vor jedem Block („/* latin *\/“); danach wird
* ausgewählt, was eine deutschsprachige Website braucht.
*
* @return list<array{subset: string, weight: int, style: string, url: string, range: string}>
*/
public function parse(string $css, string $family): array
{
$faces = [];
preg_match_all('#/\*\s*([a-z0-9-]+)\s*\*/\s*@font-face\s*\{(.+?)\}#s', $css, $matches, PREG_SET_ORDER);
foreach ($matches as [, $subset, $block]) {
if (!in_array($subset, self::SUBSETS, true)) {
continue;
}
if (1 !== preg_match('#src:\s*url\(([^)]+)\)#', $block, $src)) {
continue;
}
preg_match('#font-weight:\s*(\d+)#', $block, $weight);
preg_match('#font-style:\s*([a-z]+)#', $block, $style);
preg_match('#unicode-range:\s*([^;}]+)#', $block, $range);
$faces[] = [
'subset' => $subset,
'weight' => (int) ($weight[1] ?? 400),
'style' => $style[1] ?? 'normal',
'url' => trim($src[1], '\'" '),
'range' => trim($range[1] ?? 'U+0000-00FF'),
'family' => $family,
];
}
return $faces;
}
/**
* Holt eine HTTPS-Adresse - erst über curl, sonst über den Dateizeiger.
*
* Dieselben zwei Wege wie im Installer, aus demselben Grund: auf einem Webhosting ist mal das
* eine da und mal das andere. Eine weitere Abhängigkeit dafür ins Projekt zu holen, würde
* jedes Update auf einem Hosting ohne Composer scheitern lassen.
*/
private function get(string $url): string
{
if (!str_starts_with($url, 'https://')) {
throw new \RuntimeException('Geladen wird nur über HTTPS.');
}
if (function_exists('curl_init')) {
$curl = curl_init($url);
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_TIMEOUT => 60,
CURLOPT_USERAGENT => self::AGENT,
]);
$body = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$error = curl_error($curl);
curl_close($curl);
if (!is_string($body) || 200 !== $status) {
throw new \RuntimeException(sprintf('Google Fonts antwortet mit %d%s.', $status, '' === $error ? '' : ' ('.$error.')'));
}
return $body;
}
if (!filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL)) {
throw new \RuntimeException('Dieser Server darf nicht nach außen verbinden (weder curl noch allow_url_fopen). Die Schriftdateien müssen dann von Hand nach public/assets/fonts/ gelegt werden.');
}
$context = stream_context_create([
'http' => ['timeout' => 60, 'follow_location' => 1, 'max_redirects' => 3, 'header' => 'User-Agent: '.self::AGENT],
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
]);
$body = @file_get_contents($url, false, $context);
if (!is_string($body)) {
throw new \RuntimeException('Google Fonts ist von diesem Server aus nicht erreichbar.');
}
return $body;
}
private function slug(string $family): string
{
return strtolower(preg_replace('/[^a-z0-9]+/i', '-', $family) ?? 'schrift');
}
}
+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']];
}
}
+126 -2
View File
@@ -11,6 +11,16 @@ final class MediaLibrary
/** Enough to fill a screen, few enough that the browser does not decode a hundred images. */
public const PER_PAGE = 24;
/** The widths every upload is rendered to, in WebP. A source narrower than a step is skipped. */
public const WIDTHS = [480, 960, 1600, 2400];
/**
* Die Bilder, die in dieser Anfrage schon gesucht wurden.
*
* @var array<string, ?array>
*/
private array $gemerkt = [];
public function __construct(private Connection $db, private string $directory)
{
}
@@ -50,11 +60,49 @@ final class MediaLibrary
];
}
/**
* Ein Bild, gemerkt fuer die Dauer dieser Anfrage.
*
* Dasselbe Bild wird waehrend einer Seitenausgabe mehrfach gesucht: Ein Hintergrundbild
* bekommt je Geraet eine Regel, also dreimal dieselbe Abfrage, und der Kopf fragt dasselbe
* Bild noch einmal, um es vorab zu holen. Gemerkt wird nur innerhalb einer Anfrage - der
* Dienst lebt nicht laenger, also kann der Merkzettel nicht veralten.
*/
public function find(string $id): ?array
{
if (array_key_exists($id, $this->gemerkt)) {
return $this->gemerkt[$id];
}
$row = $this->db->fetchAssociative('SELECT * FROM media WHERE id = ?', [$id]);
return $row ? $this->present($row) : null;
return $this->gemerkt[$id] = $row ? $this->present($row) : null;
}
/**
* Mehrere Bilder auf einmal, nach Kennung sortiert.
*
* Eine Liste mit zwölf Beiträgen soll nicht zwölfmal nach je einem Bild fragen. Fehlende
* Kennungen fehlen auch im Ergebnis - der Aufrufer prüft ohnehin auf null.
*
* @param list<string> $ids
*
* @return array<string, array>
*/
public function findMany(array $ids): array
{
$ids = array_values(array_unique(array_filter($ids)));
if ([] === $ids) {
return [];
}
$found = [];
foreach ($this->db->fetchAllAssociative(
'SELECT * FROM media WHERE id IN ('.implode(',', array_fill(0, count($ids), '?')).')',
$ids,
) as $row) {
$found[$row['id']] = $this->present($row);
}
return $found;
}
private function present(array $row): array
@@ -109,7 +157,10 @@ final class MediaLibrary
$variants = [];
$width = imagesx($source);
$height = imagesy($source);
foreach (array_unique([min(480, $width), min(960, $width), min(1600, $width)]) as $targetWidth) {
// Four widths, and never larger than the original: 480 for a phone, 960 for a tablet
// or a column, 1600 for a full width section, 2400 for a full bleed image on a large
// display. A smaller source simply produces fewer variants.
foreach (array_unique(array_map(static fn (int $step): int => min($step, $width), self::WIDTHS)) as $targetWidth) {
$targetHeight = max(1, (int) round($height * $targetWidth / $width));
$target = imagecreatetruecolor($targetWidth, $targetHeight);
try {
@@ -130,6 +181,7 @@ final class MediaLibrary
$title = mb_strcut(preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?: 'Bild', 0, 160, 'UTF-8');
$row = ['id' => $id, 'title' => $title, 'alt' => '', 'variants' => json_encode($variants, JSON_THROW_ON_ERROR), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')];
$this->db->insert('media', $row);
$this->gemerkt = [];
return $this->present($row);
} catch (\Throwable $error) {
@@ -150,6 +202,7 @@ final class MediaLibrary
throw new \InvalidArgumentException('Titel (max. 160 Bytes), Alternativtext (max. 300 Bytes) und Version prüfen.');
}
$count = $this->db->executeStatement('UPDATE media SET title = ?, alt = ?, archived = ?, version = version + 1 WHERE id = ? AND version = ?', [$data['title'], $data['alt'], (int) $data['archived'], $id, $data['version']]);
unset($this->gemerkt[$id]);
if (1 !== $count) {
throw new StaleWrite();
}
@@ -157,6 +210,77 @@ final class MediaLibrary
return [...$data, 'id' => $id, 'version' => $data['version'] + 1];
}
/** Die Größen, die ein Favicon braucht: der Reiter im Browser und das Symbol auf dem Startbildschirm. */
public const FAVICON_SIZES = [32, 180];
/**
* Erzeugt aus einem Bild der Mediathek die Symboldateien der Website.
*
* Ein Favicon ist quadratisch, winzig und wird von den Browsern bis heute am zuverlässigsten
* als PNG gelesen - deshalb hier PNG statt der WebP-Varianten und ein mittiger quadratischer
* Ausschnitt statt eines gestauchten Bildes. Erzeugt wird einmal beim Speichern der
* Einstellung, nicht bei jedem Aufruf.
*/
public function renderFavicon(string $mediaId): void
{
// Ueber die Varianten, die dieses Bild tatsaechlich hat, nicht ueber die vier Regelbreiten:
// ein 192 px breites Zeichen hat genau eine Variante mit 192 - und die Suche nach 480
// ging ins Leere. Genau so ein quadratisches Zeichen ist der haeufigste Fall.
$item = $this->find($mediaId);
$source = null;
foreach (array_reverse($item['variants'] ?? []) as $variant) {
$path = $this->file($mediaId, $variant['width']);
if (null !== $path) {
$source = @imagecreatefromwebp($path);
break;
}
}
if (!$source) {
throw new \RuntimeException('Das Bild für das Symbol konnte nicht gelesen werden.');
}
try {
// Mittiger quadratischer Ausschnitt: ein breites Logo würde sonst zur Briefmarke.
$kante = min(imagesx($source), imagesy($source));
$links = (int) ((imagesx($source) - $kante) / 2);
$oben = (int) ((imagesy($source) - $kante) / 2);
foreach (self::FAVICON_SIZES as $size) {
$target = imagecreatetruecolor($size, $size);
try {
imagealphablending($target, false);
imagesavealpha($target, true);
imagecopyresampled($target, $source, 0, 0, $links, $oben, $size, $size, $kante, $kante);
if (!imagepng($target, $this->faviconPath($size), 9)) {
throw new \RuntimeException('Das Symbol konnte nicht gespeichert werden.');
}
} finally {
imagedestroy($target);
}
}
} finally {
imagedestroy($source);
}
}
/** Der Pfad einer Symboldatei, oder null wenn sie (noch) nicht erzeugt wurde. */
public function faviconFile(int $size): ?string
{
if (!in_array($size, self::FAVICON_SIZES, true)) {
return null;
}
$path = $this->faviconPath($size);
return is_file($path) ? $path : null;
}
private function faviconPath(int $size): string
{
if (!is_dir($this->directory) && !mkdir($this->directory, 0775, true) && !is_dir($this->directory)) {
throw new \RuntimeException('Medienordner ist nicht beschreibbar.');
}
return $this->directory.'/favicon-'.$size.'.png';
}
public function file(string $id, int $width): ?string
{
if (!preg_match('/^[a-f0-9]{32}$/D', $id)) {
+27 -5
View File
@@ -14,7 +14,17 @@ use Doctrine\DBAL\Connection;
*/
final class MenuRepository
{
public const SLOTS = ['header' => 'Hauptnavigation', 'footer' => 'Fußzeile'];
/**
* The places a menu can be maintained for. They are not positions on the page - a menu block
* can stand anywhere - but separate sets of entries: a header needs other links than a
* footer, and legal links are their own short list.
*/
public const SLOTS = [
'header' => 'Hauptnavigation',
'secondary' => 'Zweite Navigation',
'footer' => 'Fußzeile',
'legal' => 'Rechtliches',
];
public const MAX_ITEMS = 60;
public const MAX_DEPTH = 2;
@@ -75,14 +85,26 @@ final class MenuRepository
*
* @return list<array{label: string, href: string, current: bool, children: list<array>}>
*/
/**
* Die Seitenliste, die jedes Menue zum Aufloesen braucht - einmal je Anfrage.
*
* Vorher holte jedes Menue sie selbst. Mit zwei Menuen fiel das nicht auf, mit vieren - Kopf,
* zwei Listen im Fuss und die Rechtszeile - waren es vier gleiche Abfragen je Seitenaufruf.
*
* @var array<string, array<string, mixed>>|null
*/
private ?array $pages = null;
public function resolve(array $items, string $currentPath, string $locale = 'de'): array
{
$pages = [];
foreach ($this->db->fetchAllAssociative('SELECT id, path, published_revision_id, draft_revision_id FROM page WHERE website_id = ? AND locale = ? AND deleted_at IS NULL', ['default', $locale]) as $page) {
$pages[$page['id']] = $page;
if (null === $this->pages) {
$this->pages = [];
foreach ($this->db->fetchAllAssociative('SELECT id, path, published_revision_id, draft_revision_id FROM page WHERE website_id = ? AND locale = ? AND deleted_at IS NULL', ['default', $locale]) as $page) {
$this->pages[$page['id']] = $page;
}
}
return $this->resolveLevel($items, $pages, $currentPath, $this->settings->homePath());
return $this->resolveLevel($items, $this->pages, $currentPath, $this->settings->homePath());
}
private function resolveLevel(array $items, array $pages, string $currentPath, string $homePath): array
+36 -5
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']];
}
/**
@@ -357,7 +368,7 @@ final class PageRepository
public function findPublished(string $path, string $locale = 'de'): ?array
{
$page = $this->db->fetchAssociative(
'SELECT p.id, p.slug, p.path, p.type, p.locale, p.author, p.excerpt, p.published_at, 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 = ? AND p.deleted_at IS NULL',
'SELECT p.id, p.slug, p.path, p.type, p.locale, p.author, p.excerpt, p.published_at, p.robots, p.seo_title, p.og_media_id, p.featured_media_id, p.header_area, p.footer_area, 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 = ? AND p.deleted_at IS NULL',
[$path, $locale, 'default'],
);
if (false === $page) {
@@ -369,6 +380,26 @@ final class PageRepository
return $page;
}
/**
* Which header and footer a page wants: null for the standard one, 'none' for no area at all,
* or the address of another one.
*/
public function saveAreas(string $pageId, ?string $header, ?string $footer, int $expectedVersion): void
{
foreach ([$header, $footer] as $choice) {
if (null !== $choice && 'none' !== $choice && 1 !== preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $choice)) {
throw new InvalidPage('Die Adresse eines Bereichs darf nur Kleinbuchstaben, Ziffern und einzelne Bindestriche enthalten.');
}
}
$changed = $this->db->executeStatement(
'UPDATE page SET header_area = ?, footer_area = ?, version = version + 1 WHERE id = ? AND version = ?',
[$header, $footer, $pageId, $expectedVersion],
);
if (1 !== $changed) {
throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Seite neu.');
}
}
/** A published page by ID, used to serve its own script file. */
public function findPublishedById(string $id, string $locale = 'de'): ?array
{
@@ -386,7 +417,7 @@ final class PageRepository
public function listForEditor(): array
{
return $this->db->fetchAllAssociative('SELECT p.id, p.slug, p.path, p.parent_id, p.type, p.locale, p.version, p.published_revision_id, p.draft_revision_id, p.excerpt, p.author, p.published_at, p.featured_media_id, p.robots, p.og_media_id, p.publish_at, 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 = ? AND p.deleted_at IS NULL 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.seo_title, p.og_media_id, p.publish_at, p.header_area, p.footer_area, 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 = ? AND p.deleted_at IS NULL ORDER BY p.path', ['default']);
}
public function findForEditor(string $id): ?array
+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,
];
}
}
+23 -4
View File
@@ -62,6 +62,24 @@ final class PartialRepository
];
}
/**
* The header or footer areas a page can choose from, by address.
*
* @return array<string, string> address => name
*/
public function choices(string $kind, string $locale = 'de'): array
{
$choices = [];
foreach ($this->db->fetchAllAssociative(
'SELECT slug, name FROM partial WHERE website_id = ? AND locale = ? AND kind = ? ORDER BY slug = ? DESC, name',
['default', $locale, $kind, 'default'],
) as $row) {
$choices[$row['slug']] = $row['name'];
}
return $choices;
}
public function create(string $kind, string $slug, string $name, array $document, string $locale = 'de'): string
{
if (!isset(self::KINDS[$kind])) {
@@ -75,9 +93,10 @@ final class PartialRepository
if ('' === $name || mb_strlen($name) > 120) {
throw new InvalidPage('Bitte gib einen Namen mit höchstens 120 Zeichen an.');
}
// Header and footer exist once; several would be ambiguous at render time.
if ('section' !== $kind && null !== $this->findByKind($kind, 'default', $locale)) {
throw new InvalidPage(sprintf('Es gibt bereits einen %s.', self::KINDS[$kind]));
// Several headers and footers are allowed since pages may choose one: the first one
// keeps the address "default" and is what a page without an own choice gets.
if ('section' !== $kind && null === $this->findByKind($kind, 'default', $locale)) {
$slug = 'default';
}
$document = $this->richText->normaliseDocument($document);
$this->validator->validate($document, false);
@@ -87,7 +106,7 @@ final class PartialRepository
$id = bin2hex(random_bytes(16));
$this->db->insert('partial', [
'id' => $id, 'website_id' => 'default', 'locale' => $locale,
'kind' => $kind, 'slug' => 'section' === $kind ? $slug : 'default', 'name' => $name,
'kind' => $kind, 'slug' => $slug, 'name' => $name,
'document' => json_encode($document, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'version' => 1,
]);
+265
View File
@@ -0,0 +1,265 @@
<?php
namespace App\Content;
/**
* Fertige Aufbauten für einen neuen Beitrag.
*
* Ein leeres Dokument ist für eine Seite richtig - dort weiß niemand vorher, was daraus wird. Ein
* Beitrag dagegen hat fast immer dieselbe Form: ein Kopf, ein Text, Bilder, und am Ende ein Weg
* weiter. Wer jedes Mal von vorn anfängt, baut jedes Mal etwas leicht anderes, und nach zwanzig
* Beiträgen sieht ein Archiv aus wie zwanzig Websites.
*
* Die drei Aufbauten hier sind Startpunkte, keine Schablonen: jeder Block lässt sich danach
* verschieben, ändern und löschen wie jeder andere Block auch. Was sie festlegen, ist die erste
* Entscheidung - und die ist die, die am schwersten nachzuholen ist.
*/
final class PostLayouts
{
public const DEFAULT = 'bildkopf';
/** @return array<string, array{name: string, description: string}> */
public static function all(): array
{
return [
'bildkopf' => [
'name' => 'Bildkopf',
'description' => 'Großes Beitragsbild über die volle Breite, Titel darauf. Für Projekte und alles, was von einem Bild lebt.',
],
'klassisch' => [
'name' => 'Klassisch',
'description' => 'Titel und Vorspann in einer schmalen Spalte, Bild darunter. Ruhig zu lesen, gut für lange Texte.',
],
'magazin' => [
'name' => 'Magazin',
'description' => 'Titel neben den Eckdaten, danach Text und Galerie. Für Beiträge, bei denen Kunde, Leistung und Jahr zählen.',
],
];
}
public static function exists(string $key): bool
{
return isset(self::all()[$key]);
}
/** Das vollständige Dokument eines neuen Beitrags in der gewählten Form. */
public static function document(string $key, string $title): array
{
$document = [
'schemaVersion' => 18,
'title' => $title,
'description' => 'Ein neuer Beitrag. Ersetze diese Beschreibung durch eine kurze Zusammenfassung – sie erscheint in Suchergebnissen und auf der Beitragskarte.',
'blocks' => match ($key) {
'klassisch' => self::klassisch($title),
'magazin' => self::magazin($title),
default => self::bildkopf($title),
},
];
return $document;
}
/**
* Bildkopf: das Beitragsbild füllt den Kopf, der Titel liegt darauf.
*
* Das Bild ist beim Anlegen noch keines - es kommt ein Platzhalter, und im Designbereich wird
* daraus mit zwei Klicks das Beitragsbild. Ein dunkler Kopf ohne Bild sieht dabei nicht kaputt
* aus, sondern schlicht, und das ist der Punkt: kein Aufbau darf auf ein Bild angewiesen sein,
* das noch niemand hochgeladen hat.
*/
private static function bildkopf(string $title): array
{
return [
self::section('kopf', [
self::badge('kopf-label', 'Beitrag'),
self::heading('kopf-titel', $title, 1, ['fontSize' => '46px', 'fontWeight' => '800', 'lineHeight' => 1.12, 'letterSpacing' => '-1px', 'maxWidth' => '900px', 'margin' => self::spacing('0px', '18px', 'auto', 'auto')]),
self::line('kopf-linie'),
], [
'backgroundColor' => '@ink', 'color' => '#ffffff', 'textAlign' => 'center',
'display' => 'flex', 'alignItems' => 'flex-end', 'justifyContent' => 'center',
'minHeight' => '58vh', 'padding' => self::spacing('110px', '52px', '0px', '0px'),
], [], ['entrance' => 'fade']),
self::narrow('text', [
self::heading('text-h', 'Über den Beitrag', 2, ['fontSize' => '28px', 'fontWeight' => '800', 'color' => '@ink']),
self::text('text-1', 'Schreibe hier den ersten Absatz. Er beantwortet am besten die Frage, warum jemand weiterlesen sollte.'),
self::text('text-2', 'Der zweite Absatz darf ins Detail gehen: was die Aufgabe war, wie der Weg aussah, was dabei herauskam.'),
]),
self::section('galerie', [
self::badge('galerie-label', 'Bildergalerie', true),
self::heading('galerie-h', 'Eindrücke', 2, ['fontSize' => '30px', 'fontWeight' => '800', 'color' => '@ink']),
self::gallery('galerie-bilder', 2),
], ['backgroundColor' => '@paper', 'padding' => self::spacing('60px', '60px', '0px', '0px')], [], ['entrance' => 'rise']),
self::section('weiter', [self::nav('weiter-nav')], ['padding' => self::spacing('50px', '50px', '0px', '0px')], [], ['entrance' => 'fade']),
self::section('aufruf', [self::cta('aufruf-band')], ['backgroundColor' => '@ink', 'color' => '#ffffff', 'padding' => self::spacing('62px', '62px', '0px', '0px')], [], ['entrance' => 'fade']),
];
}
/** Klassisch: Titel und Vorspann schmal, Bild darunter, dann der Text. */
private static function klassisch(string $title): array
{
return [
self::narrow('kopf', [
self::badge('kopf-label', 'Beitrag', true),
self::heading('kopf-titel', $title, 1, ['fontSize' => '42px', 'fontWeight' => '800', 'lineHeight' => 1.15, 'letterSpacing' => '-0.8px']),
self::text('kopf-vorspann', 'Ein Vorspann in zwei, drei Sätzen. Er steht für sich und sagt, worum es geht – wer nur ihn liest, weiß Bescheid.', ['fontSize' => '20px', 'lineHeight' => 1.6]),
], '70px', '30px'),
self::section('bild', [self::image('bild-1')], ['padding' => self::spacing('0px', '10px', '0px', '0px')], [], ['entrance' => 'zoom']),
self::narrow('text', [
self::text('text-1', 'Der erste Absatz des Haupttextes. Ab hier darf es ausführlich werden.'),
self::heading('text-h', 'Ein Zwischentitel', 2, ['fontSize' => '26px', 'fontWeight' => '800', 'color' => '@ink', 'margin' => self::spacing('34px', '12px', '0px', '0px')]),
self::text('text-2', 'Zwischentitel machen einen langen Text überhaupt erst lesbar. Setze alle drei bis fünf Absätze einen.'),
]),
self::section('weiter', [self::nav('weiter-nav')], ['backgroundColor' => '@paper', 'padding' => self::spacing('50px', '50px', '0px', '0px')], [], ['entrance' => 'fade']),
];
}
/** Magazin: Titel links, Eckdaten rechts, danach Text und Galerie. */
private static function magazin(string $title): array
{
return [
self::section('kopf', [
['id' => 'kopf-zeile', 'type' => 'row', 'style' => ['desktop' => ['gridColumns' => 2, 'gap' => '40px', 'alignItems' => 'flex-end'], 'mobile' => ['gridColumns' => 1, 'gap' => '24px']], 'children' => [
['id' => 'kopf-links', 'type' => 'column', 'children' => [
self::badge('kopf-label', 'Beitrag', true),
self::heading('kopf-titel', $title, 1, ['fontSize' => '44px', 'fontWeight' => '800', 'lineHeight' => 1.12, 'letterSpacing' => '-1px']),
]],
['id' => 'kopf-rechts', 'type' => 'column', 'style' => ['desktop' => [
'backgroundColor' => '#ffffff', 'borderRadius' => self::corners('16px'),
'borderWidth' => self::spacing('1px', '1px', '1px', '1px'), 'borderStyle' => 'solid', 'borderColor' => '@line',
'padding' => self::spacing('22px', '22px', '24px', '24px'),
]], 'children' => [
self::badge('eck-label', 'Eckdaten', true),
self::text('eck-daten', "Kunde: noch einzutragen\nLeistung: noch einzutragen\nJahr: noch einzutragen", ['fontSize' => '15px']),
]],
]],
], ['backgroundColor' => '@paper', 'padding' => self::spacing('72px', '48px', '0px', '0px')], [], ['entrance' => 'fade']),
self::narrow('text', [
self::text('text-1', 'Der Einstieg in den Beitrag. Was war die Aufgabe, und warum war sie nicht trivial?'),
self::heading('text-h', 'Der Weg', 2, ['fontSize' => '26px', 'fontWeight' => '800', 'color' => '@ink', 'margin' => self::spacing('30px', '12px', '0px', '0px')]),
self::text('text-2', 'Was getan wurde, in der Reihenfolge, in der es getan wurde.'),
self::heading('text-h2', 'Das Ergebnis', 2, ['fontSize' => '26px', 'fontWeight' => '800', 'color' => '@ink', 'margin' => self::spacing('30px', '12px', '0px', '0px')]),
self::text('text-3', 'Was sich seitdem geändert hat – am besten ohne Superlative.'),
]),
self::section('galerie', [self::gallery('galerie-bilder', 3)], ['backgroundColor' => '@paper', 'padding' => self::spacing('56px', '56px', '0px', '0px')], [], ['entrance' => 'rise']),
self::section('weiter', [self::nav('weiter-nav')], ['padding' => self::spacing('50px', '50px', '0px', '0px')], [], ['entrance' => 'fade']),
];
}
// ---------------------------------------------------------------- Bausteine
/**
* Ein Abschnitt, wahlweise mit Einblendung.
*
* Die Einblendung läuft beim Hineinscrollen, wo der Browser eine Scroll-Zeitachse kennt, und
* sonst beim Laden - beides ohne Skript, beides hinter der Bewegungsschranke. Deshalb darf
* sie auch weiter unten stehen, ohne ins Leere zu laufen.
*/
private static function section(string $id, array $children, array $desktop = [], array $mobile = [], array $effects = []): array
{
$block = ['id' => $id, 'type' => 'section', 'children' => $children];
if ([] !== $effects) {
$block['layout'] = ['effects' => $effects];
}
if ([] !== $desktop || [] !== $mobile) {
$block['style'] = array_filter(['desktop' => $desktop, 'mobile' => $mobile]);
}
return $block;
}
/** Ein schmaler Abschnitt steht in der Mitte: maximale Breite ohne automatischen Rand ist eine halbe Einstellung. */
private static function narrow(string $id, array $children, string $top = '54px', string $bottom = '54px'): array
{
return self::section($id, $children, [
'maxWidth' => '820px',
'margin' => self::spacing('0px', '0px', 'auto', 'auto'),
'padding' => self::spacing($top, $bottom, '0px', '0px'),
], [], ['entrance' => 'rise']);
}
private static function heading(string $id, string $text, int $level, array $style = []): array
{
return array_filter([
'id' => $id, 'type' => 'heading', 'props' => ['text' => $text, 'level' => $level],
'style' => [] === $style ? null : ['desktop' => $style],
], static fn ($value) => null !== $value);
}
private static function text(string $id, string $text, array $style = []): array
{
return array_filter([
'id' => $id, 'type' => 'text', 'props' => ['text' => $text],
'style' => [] === $style ? null : ['desktop' => $style],
], static fn ($value) => null !== $value);
}
/** Die kleine Beschriftung über einer Überschrift - hell auf dunklem Kopf, sonst in der Akzentfarbe. */
private static function badge(string $id, string $label, bool $quiet = false): array
{
return $quiet
? self::text($id, $label, ['fontSize' => '12px', 'fontWeight' => '700', 'letterSpacing' => '1.6px', 'textTransform' => 'uppercase', 'color' => '@link', 'margin' => self::spacing('0px', '10px', '0px', '0px')])
: self::text($id, $label, [
'display' => 'inline-block', 'backgroundColor' => '@link', 'color' => '#ffffff',
'borderRadius' => self::corners('999px'), 'padding' => self::spacing('7px', '7px', '16px', '16px'),
'fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '1px', 'textTransform' => 'uppercase',
'margin' => self::spacing('0px', '18px', 'auto', 'auto'),
]);
}
private static function line(string $id): array
{
return ['id' => $id, 'type' => 'divider', 'style' => ['desktop' => [
'width' => '60px', 'height' => '4px', 'backgroundColor' => '@link',
'borderRadius' => self::corners('2px'), 'borderWidth' => self::spacing('0px', '0px', '0px', '0px'),
'margin' => self::spacing('0px', '0px', 'auto', 'auto'),
]]];
}
private static function image(string $id): array
{
return ['id' => $id, 'type' => 'image', 'props' => [
'src' => '/assets/image-placeholder.svg', 'alt' => '', 'decorative' => true,
'width' => 1200, 'height' => 675, 'caption' => '',
], 'style' => ['desktop' => ['imageRadius' => self::corners('18px')]]];
}
private static function gallery(string $id, int $count): array
{
$children = [];
for ($index = 1; $index <= $count; ++$index) {
$children[] = ['id' => $id.'-'.$index, 'type' => 'image', 'props' => [
'src' => '/assets/image-placeholder.svg', 'alt' => '', 'decorative' => true,
'width' => 1200, 'height' => 900,
]];
}
return ['id' => $id, 'type' => 'gallery', 'children' => $children, 'style' => ['desktop' => [
'gridColumns' => $count, 'gap' => '18px', 'margin' => self::spacing('24px', '0px', '0px', '0px'),
], 'mobile' => ['gridColumns' => 1]]];
}
private static function nav(string $id): array
{
return ['id' => $id, 'type' => 'postnav', 'props' => ['previousLabel' => 'Vorheriger Beitrag', 'nextLabel' => 'Nächster Beitrag']];
}
private static function cta(string $id): array
{
return ['id' => $id, 'type' => 'cta', 'props' => [
'title' => 'Noch Fragen?',
'text' => 'Schreib uns – wir antworten in der Regel am selben Werktag.',
'buttonText' => 'Kontakt aufnehmen',
'href' => '/#kontakt',
]];
}
private static function spacing(string $top, string $bottom, string $left, string $right): array
{
return ['top' => $top, 'bottom' => $bottom, 'left' => $left, 'right' => $right];
}
private static function corners(string $value): array
{
return ['topLeft' => $value, 'topRight' => $value, 'bottomRight' => $value, 'bottomLeft' => $value];
}
}
+107 -11
View File
@@ -46,20 +46,27 @@ final class PostRepository
throw new InvalidPage(sprintf('Das gewählte %s gibt es nicht.', $label));
}
}
// Der Titel fuer die Suche. Leer heisst: der Titel der Seite gilt. 160 Zeichen sind die
// Grenze des Feldes; Google zeigt etwa 60 - der Hinweis dazu steht im Formular.
$seoTitle = trim((string) ($values['seoTitle'] ?? ''));
if (mb_strlen($seoTitle) > 160) {
throw new InvalidPage('Der Titel für Suchergebnisse darf höchstens 160 Zeichen lang sein.');
}
$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, $ogMediaId, $robots, $categories, $tags, $expectedVersion): void {
$this->db->transactional(function () use ($pageId, $excerpt, $author, $date, $mediaId, $ogMediaId, $robots, $seoTitle, $categories, $tags, $expectedVersion): void {
$changed = $this->db->executeStatement(
'UPDATE page SET excerpt = ?, author = ?, featured_media_id = ?, published_at = ?, robots = ?, og_media_id = ?, version = version + 1 WHERE id = ? AND version = ?',
'UPDATE page SET excerpt = ?, author = ?, featured_media_id = ?, published_at = ?, robots = ?, seo_title = ?, og_media_id = ?, version = version + 1 WHERE id = ? AND version = ?',
[
'' === $excerpt ? null : $excerpt,
'' === $author ? null : $author,
'' === $mediaId ? null : $mediaId,
'' === $date ? null : str_replace('T', ' ', 10 === strlen($date) ? $date.' 00:00' : $date).':00',
$robots,
'' === $seoTitle ? null : $seoTitle,
'' === $ogMediaId ? null : $ogMediaId,
$pageId,
$expectedVersion,
@@ -127,17 +134,24 @@ final class PostRepository
$params,
);
return ['posts' => array_map($this->present(...), $rows), 'total' => $total, 'pages' => $pages, 'page' => $page];
// Bilder und Begriffe für die ganze Liste in je einer Abfrage: vorher fragte jeder Beitrag
// einzeln nach seinem Bild und seinen Begriffen, eine Liste mit zwölf Einträgen kostete
// also 24 Abfragen statt zwei. Bei neighbours(), das 500 Beiträge lädt, war das absurd.
$images = $this->media->findMany(array_map(static fn (array $row) => (string) $row['featured_media_id'], $rows));
$terms = $this->termsForMany(array_column($rows, 'id'));
return [
'posts' => array_map(fn (array $row) => $this->present($row, $images, $terms[$row['id']] ?? ['category' => [], 'tag' => []]), $rows),
'total' => $total,
'pages' => $pages,
'page' => $page,
];
}
private function present(array $row): array
private function present(array $row, array $images, array $terms): array
{
$document = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR);
$image = null;
if (null !== $row['featured_media_id']) {
$item = $this->media->find($row['featured_media_id']);
$image = null === $item ? null : ['alt' => $item['alt'] ?? '', 'variants' => $item['variants']];
}
$item = $images[$row['featured_media_id']] ?? null;
return [
'title' => $document['title'] ?? $row['path'],
@@ -145,14 +159,96 @@ final class PostRepository
'excerpt' => $row['excerpt'] ?? $document['description'] ?? '',
'author' => $row['author'],
'publishedAt' => $row['published_at'],
'image' => $image,
'terms' => $this->termsFor($row['id']),
'image' => null === $item ? null : ['alt' => $item['alt'] ?? '', 'variants' => $item['variants']],
'terms' => $terms,
];
}
/**
* Die Begriffe mehrerer Seiten auf einmal.
*
* @param list<string> $pageIds
*
* @return array<string, array<string, list<array{slug: string, name: string, href: string}>>>
*/
private function termsForMany(array $pageIds): array
{
$pageIds = array_values(array_unique(array_filter($pageIds)));
if ([] === $pageIds) {
return [];
}
$terms = [];
foreach ($this->db->fetchAllAssociative(
'SELECT pt.page_id, t.kind, t.slug, t.name FROM term t JOIN page_term pt ON pt.term_id = t.id WHERE pt.page_id IN ('
.implode(',', array_fill(0, count($pageIds), '?')).') ORDER BY t.name',
$pageIds,
) as $row) {
$terms[$row['page_id']] ??= ['category' => [], 'tag' => []];
$terms[$row['page_id']][$row['kind']][] = ['slug' => $row['slug'], 'name' => $row['name'], 'href' => '/'.self::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug']];
}
return $terms;
}
/**
* @return array<string, list<array{slug: string, name: string, href: string}>>
*/
/**
* Der vorherige und der nächste Beitrag, in der Reihenfolge des Archivs.
*
* Gesucht wird innerhalb derselben Kategorie, wenn der Beitrag eine hat: von einem Projekt
* führt man zum nächsten Projekt und nicht zur nächsten Pressemitteilung. Hat er keine, gilt
* die ganze Liste. Am Rand wird nicht umgebrochen - der erste Beitrag hat keinen Vorgänger,
* und ein Ring aus zwei Beiträgen wäre eine Schleife, keine Navigation.
*
* @return array{previous: ?array, next: ?array}
*/
public function neighbours(string $pageId, string $locale = 'de'): array
{
$termId = (string) $this->db->fetchOne(
'SELECT t.id FROM term t JOIN page_term pt ON pt.term_id = t.id WHERE pt.page_id = ? AND t.kind = ? ORDER BY t.name LIMIT 1',
[$pageId, 'category'],
);
// Dieselbe Sortierung wie im Archiv, damit „vorheriges" dort dasselbe heißt wie hier.
$all = $this->published(1, '' === $termId ? null : $termId, 500, $locale)['posts'];
// present() gibt bewusst keine Kennung heraus - in eine Liste gehören Titel und Adresse.
// Verglichen wird deshalb über die Adresse, die innerhalb einer Website eindeutig ist.
$own = $this->settings->publicPath((string) $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$pageId]));
$index = null;
foreach ($all as $position => $post) {
if ($post['href'] === $own) {
$index = $position;
break;
}
}
if (null === $index) {
return ['previous' => null, 'next' => null];
}
return [
// „Vorheriges" ist im Archiv der neuere Beitrag, weil dort das Neueste oben steht.
'previous' => $all[$index - 1] ?? null,
'next' => $all[$index + 1] ?? null,
];
}
/**
* Gibt es überhaupt einen veröffentlichten Beitrag?
*
* Eine Website ohne Blog soll nirgends von Beiträgen reden: kein leeres Archiv, kein Feed mit
* null Einträgen, kein Kasten „hier erscheinen Beiträge“ auf einer Seite. Die Antwort wird für
* die Dauer einer Anfrage gemerkt, weil mehrere Stellen sie brauchen.
*/
public function anyPublished(string $locale = 'de'): bool
{
return $this->anyPublished ??= (bool) $this->db->fetchOne(
"SELECT 1 FROM page WHERE website_id = ? AND locale = ? AND type = 'post' AND published_revision_id IS NOT NULL AND deleted_at IS NULL LIMIT 1",
['default', $locale],
);
}
private ?bool $anyPublished = null;
public function termsFor(string $pageId): array
{
$terms = ['category' => [], 'tag' => []];
+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 */
+17 -1
View File
@@ -33,6 +33,12 @@ final class RichText
return is_int($schemaVersion) && $schemaVersion >= 12;
}
/** Ueberschriften duerfen erst ab v17 Auszeichnungen tragen. */
public static function headings(mixed $schemaVersion): bool
{
return is_int($schemaVersion) && $schemaVersion >= 17;
}
/**
* Returns the text with only allowed markup left. The result is safe to print unescaped.
*/
@@ -184,11 +190,21 @@ final class RichText
if (!self::supports($document['schemaVersion'] ?? null)) {
return $document;
}
$walk = function (array $blocks) use (&$walk): array {
// Die Fassung wird in die Schliessung gebunden. Ohne das war sie darin schlicht nicht
// vorhanden, der Filter fuer Ueberschriften lief nie - und ihr Inhalt wird unescaped
// ausgegeben. Der Test, der ein <script> in eine Ueberschrift schreibt, hat es gezeigt.
$headings = self::headings($document['schemaVersion'] ?? null);
$walk = function (array $blocks) use (&$walk, $headings): array {
foreach ($blocks as $index => $block) {
if ('text' === ($block['type'] ?? null) && is_string($block['props']['text'] ?? null)) {
$blocks[$index]['props']['text'] = $this->sanitise($block['props']['text']);
}
// Ab v17 darf auch eine Ueberschrift Auszeichnungen tragen. Gebraucht wird das
// fuer ein hervorgehobenes Wort - im Original von kant-it.de ist die zweite
// Zeile der Hauptueberschrift rot. Durch denselben Filter wie jeder Text.
if ($headings && 'heading' === ($block['type'] ?? null) && is_string($block['props']['text'] ?? null)) {
$blocks[$index]['props']['text'] = $this->sanitise($block['props']['text']);
}
if (isset($block['children']) && is_array($block['children'])) {
$blocks[$index]['children'] = $walk($block['children']);
}
+104 -5
View File
@@ -4,6 +4,7 @@ namespace App\Content;
use App\Domain\InvalidPage;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception\TableNotFoundException;
/**
* Company data and site wide switches. The structured data the pages emit is built from exactly
@@ -24,6 +25,31 @@ final class SiteSettings
'sameAs' => [],
'localBusiness' => false,
'llmsTxt' => false,
// Der Kopfbereich bleibt beim Scrollen oben stehen. Eine Eigenschaft der Website und
// nicht des Dokuments: kleben kann nur das Element, das den Bereich umschliesst.
'stickyHeader' => false,
// Datum, Autor und Kategorie über dem Beitrag. Aus, wenn der Beitrag seinen eigenen
// Kopf mitbringt - sonst steht die Zeile über einem Bild, das den Titel schon trägt.
'postMetaHeader' => true,
// Das Symbol der Website im Browser-Reiter: ein Bild aus der Mediathek, aus dem beim
// Speichern quadratische PNG-Dateien entstehen. Leer heißt: das mitgelieferte Zeichen.
'faviconMediaId' => '',
// Der Knopf „nach oben". Aus, weil eine kurze Seite ihn nicht braucht; alles daran ist
// einstellbar, weil er auf jeder Seite in der Ecke klebt und deshalb zur Gestaltung
// passen muss. Eingeblendet wird er beim Scrollen - ohne eine Zeile JavaScript.
'scrollTop' => false,
'scrollTopPosition' => 'rechts',
'scrollTopShape' => 'rund',
'scrollTopSize' => 48,
'scrollTopColor' => '',
'scrollTopInk' => '',
'scrollTopLabel' => 'Nach oben',
'scrollTopAfter' => 400,
// Cloudflare Turnstile fuer Formulare. Aus, weil eine Website ohne Spamwelle keinen
// Dritten einbinden soll - und weil die Pruefung JavaScript verlangt.
'turnstile' => false,
'turnstileSiteKey' => '',
'turnstileSecret' => '',
'robotsExtra' => '',
// Which stored page answers at the site root. Everything else derives from this.
'homePath' => 'home',
@@ -36,16 +62,35 @@ final class SiteSettings
'imprintPath' => '',
];
public function __construct(private Connection $db)
public function __construct(private Connection $db, private MediaLibrary $media)
{
}
/**
* 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
{
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']);
if (null !== $this->loaded) {
return $this->loaded;
}
try {
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']);
} catch (TableNotFoundException) {
// Vor der Installation gibt es die Tabelle noch nicht. Fragen tun trotzdem schon
// Seiten, die es in dem Moment geben kann - der Installer selbst und die
// Nicht-gefunden-Seite. Die Standardwerte sind dort die richtige Antwort.
return $this->loaded = self::DEFAULTS;
}
$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
@@ -81,15 +126,68 @@ final class SiteSettings
throw new InvalidPage('Ein lokales Unternehmen braucht mindestens Straße und Ort.');
}
$values['llmsTxt'] = (bool) ($input['llmsTxt'] ?? false);
$values['stickyHeader'] = (bool) ($input['stickyHeader'] ?? false);
$favicon = trim((string) ($input['faviconMediaId'] ?? ''));
if ('' !== $favicon) {
if (1 !== preg_match('/^[a-f0-9]{32}$/D', $favicon) || null === $this->media->find($favicon)) {
throw new InvalidPage('Das gewählte Symbolbild gibt es nicht (mehr).');
}
// Die Dateien entstehen hier und nicht bei jedem Seitenaufruf. Schlägt das fehl, wird
// die Einstellung gar nicht erst gespeichert - ein Verweis auf ein fehlendes Symbol
// wäre schlimmer als das alte Symbol.
try {
$this->media->renderFavicon($favicon);
} catch (\RuntimeException $error) {
throw new InvalidPage('Aus diesem Bild konnte kein Symbol erzeugt werden: '.$error->getMessage());
}
}
$values['faviconMediaId'] = $favicon;
foreach (['turnstileSiteKey' => 100, 'turnstileSecret' => 100] as $key => $max) {
$wert = trim((string) ($input[$key] ?? ''));
if ('' !== $wert && 1 !== preg_match('/^[A-Za-z0-9_:.-]{8,'.$max.'}$/D', $wert)) {
throw new InvalidPage('Die Turnstile-Schlüssel bestehen aus Buchstaben, Ziffern und den Zeichen _ : . - und sind mindestens acht Zeichen lang.');
}
$values[$key] = $wert;
}
$values['scrollTop'] = (bool) ($input['scrollTop'] ?? false);
$values['scrollTopPosition'] = in_array($input['scrollTopPosition'] ?? '', ['rechts', 'links'], true) ? $input['scrollTopPosition'] : 'rechts';
$values['scrollTopShape'] = in_array($input['scrollTopShape'] ?? '', ['rund', 'weich', 'eckig'], true) ? $input['scrollTopShape'] : 'rund';
$values['scrollTopSize'] = max(36, min(72, (int) ($input['scrollTopSize'] ?? 48)));
// Ab wie vielen Pixeln er erscheint. Null heisst: sofort.
$values['scrollTopAfter'] = max(0, min(4000, (int) ($input['scrollTopAfter'] ?? 400)));
$values['scrollTopLabel'] = mb_substr(trim((string) ($input['scrollTopLabel'] ?? '')), 0, 60) ?: 'Nach oben';
foreach (['scrollTopColor', 'scrollTopInk'] as $feld) {
$farbe = trim((string) ($input[$feld] ?? ''));
if ('' !== $farbe && 1 !== preg_match(StyleSchema::COLOR, $farbe)) {
throw new InvalidPage('Die Farben des Knopfes „nach oben" werden als #1a2b3c oder als @schlüssel aus der Palette angegeben.');
}
$values[$feld] = $farbe;
}
$values['turnstile'] = (bool) ($input['turnstile'] ?? false);
if ($values['turnstile'] && ('' === $values['turnstileSiteKey'] || '' === $values['turnstileSecret'])) {
// Eingeschaltet ohne Schluessel hiesse: jedes Formular lehnt jede Absendung ab.
throw new InvalidPage('Für die Turnstile-Prüfung werden beide Schlüssel gebraucht – der öffentliche und der geheime.');
}
$values['postMetaHeader'] = (bool) ($input['postMetaHeader'] ?? false);
// Only comment and simple directive lines; nothing that could smuggle in a second policy.
// Leerzeilen bleiben erhalten (hoechstens eine am Stueck): eine robots.txt mit eigenen
// Regeln je Bot besteht aus Bloecken, und ohne Trennung liest die niemand mehr. Das Limit
// liegt bei 200 Zeilen, weil allein die Liste der KI-Crawler laenger ist als dreissig.
$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)) {
if ('' === $line) {
if ([] !== $extra && '' !== end($extra)) {
$extra[] = '';
}
continue;
}
if (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));
$values['robotsExtra'] = trim(implode("\n", array_slice($extra, 0, 200)));
$home = trim(trim((string) ($input['homePath'] ?? self::DEFAULTS['homePath'])), '/');
if (1 !== preg_match('#^[a-z0-9]+(?:[-/][a-z0-9]+)*$#D', $home)) {
@@ -132,6 +230,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',
+4 -2
View File
@@ -22,7 +22,7 @@ use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
final class Statistics
{
/** Everything that is not a page of this site, or not a visit at all. */
private const IGNORED = '#^/(admin|media/|assets/|einwilligung$|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$|favicon)#';
private const IGNORED = '#^/(media/|assets/|einwilligung$|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$|favicon)#';
/**
* A heuristic, and nothing more: user agents lie. It keeps the obvious crawlers and monitors
@@ -40,7 +40,7 @@ final class Statistics
/** After two months the hours of a day are merged into one row. */
public const COMPACT_AFTER_DAYS = 60;
public function __construct(private Connection $db)
public function __construct(private Connection $db, private string $adminPath = 'admin')
{
}
@@ -51,6 +51,8 @@ final class Statistics
&& in_array($status, [200, 404], true)
&& str_contains($contentType, 'text/html')
&& 1 !== preg_match(self::IGNORED, $path)
// Die Verwaltung ist kein Besuch - unabhaengig davon, wie sie heisst.
&& !str_starts_with($path, '/'.$this->adminPath)
&& 1 !== preg_match(self::MACHINES, $userAgent);
}
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace App\Content;
/**
* Die Stylesheets einer öffentlichen Seite als eine Datei.
*
* Getrennte Dateien sind beim Schreiben richtig – `site.css` das Grundgerüst, `design.css` die
* Gestaltungswerte, `enhancements.css` die Bewegung, `fonts/fonts.css` die Schriften. Beim
* Ausliefern sind sie vier Anfragen, die alle vier das Zeichnen der Seite aufhalten, und auf einer
* Mobilfunkverbindung ist jede davon eine eigene Wartezeit.
*
* Zusammengelegt wird deshalb einmal in eine Datei, deren Name die Prüfsumme des Inhalts trägt:
* ändert sich eine Quelle, ändert sich der Name, und kein Browser zeigt altes Aussehen. Entfernt
* werden dabei die Kommentare – sie erklären den Quelltext, nicht den Browser.
*
* Schlägt das Schreiben fehl (kein Schreibrecht im Ordner), bleibt es bei den Einzeldateien: eine
* Website ohne Bündel ist langsamer, eine Website ohne Stylesheet ist kaputt.
*/
final class StyleBundle
{
/** Die Reihenfolge ist die Reihenfolge im Kopf der Seite - später gewinnt. */
private const SOURCES = ['fonts/fonts.css', 'site.css', 'design.css', 'enhancements.css'];
public function __construct(private string $assetDirectory)
{
}
/**
* Die Adresse des Bündels, oder null wenn es keines gibt.
*
* Erzeugt wird beim ersten Aufruf nach einer Änderung. Das kostet einen Aufruf lang ein paar
* Millisekunden und spart danach jedem Besucher drei Anfragen.
*/
public function url(bool $withFonts = true): ?string
{
$sources = $this->sources($withFonts);
if ([] === $sources) {
return null;
}
$name = 'kant-'.substr(hash('xxh128', implode('|', array_map(
static fn (string $file): string => $file.':'.filemtime($file).':'.filesize($file),
$sources,
))), 0, 16).'.css';
$path = $this->assetDirectory.'/build/'.$name;
if (!is_file($path) && !$this->write($path, $sources)) {
return null;
}
return '/assets/build/'.$name;
}
/** @return list<string> */
private function sources(bool $withFonts): array
{
$found = [];
foreach (self::SOURCES as $source) {
if ('fonts/fonts.css' === $source && !$withFonts) {
continue;
}
$path = $this->assetDirectory.'/'.$source;
if (is_file($path)) {
$found[] = $path;
}
}
// Ohne das Grundgerüst kein Bündel: dann stimmt etwas anderes nicht.
return in_array($this->assetDirectory.'/site.css', $found, true) ? $found : [];
}
/** @param list<string> $sources */
private function write(string $path, array $sources): bool
{
$directory = dirname($path);
if (!is_dir($directory) && !@mkdir($directory, 0775, true) && !is_dir($directory)) {
return false;
}
$css = '';
foreach ($sources as $source) {
$css .= $this->minify((string) file_get_contents($source))."\n";
}
// Erst daneben schreiben, dann umbenennen: ein zweiter Aufruf im selben Moment soll nie
// eine halb geschriebene Datei ausliefern.
$temporary = $path.'.'.bin2hex(random_bytes(4));
if (false === @file_put_contents($temporary, $css) || !@rename($temporary, $path)) {
@unlink($temporary);
return false;
}
$this->clean($directory, basename($path));
return true;
}
/**
* Kommentare raus, Einrückung raus - sonst nichts.
*
* Kein Umbau von Selektoren, kein Zusammenfassen von Regeln: ein Minifier, der die Bedeutung
* ändert, spart ein paar Zeichen und kostet einen Abend Fehlersuche. Leerzeichen innerhalb
* einer Zeile bleiben deshalb stehen – in `calc(100% - 20px)` und in `.a :hover` tragen sie
* Bedeutung.
*/
private function minify(string $css): string
{
$withoutComments = preg_replace('#/\*(?!!).*?\*/#s', '', $css) ?? $css;
$lines = [];
foreach (preg_split('/\R/', $withoutComments) ?: [] as $line) {
$line = trim($line);
if ('' !== $line) {
$lines[] = $line;
}
}
return implode("\n", $lines);
}
/** Ältere Bündel wegräumen; sie werden von niemandem mehr angefragt. */
private function clean(string $directory, string $keep): void
{
foreach (glob($directory.'/kant-*.css') ?: [] as $old) {
if (basename($old) !== $keep && filemtime($old) < time() - 3600) {
@unlink($old);
}
}
}
}
+161 -11
View File
@@ -16,6 +16,18 @@ final class StyleCompiler
{
private const BREAKPOINTS = ['tablet' => 1024, 'mobile' => 767];
/**
* Dieselben Grenzen, als Medienabfragen fuer einen Verweis im Seitenkopf.
*
* Genau eine trifft zu, und sie trifft dieselbe Variante wie die Regel weiter unten - sonst
* holte ein Vorabverweis ein zweites Bild.
*/
public const PRELOAD_DEVICES = [
'desktop' => '(min-width:1025px)',
'tablet' => '(min-width:768px) and (max-width:1024px)',
'mobile' => '(max-width:767px)',
];
public function __construct(private ?MediaLibrary $media = null)
{
}
@@ -101,17 +113,24 @@ final class StyleCompiler
private function compileBlock(string $id, array $style): string
{
$css = '';
$style = $this->scaleBackground($style);
foreach (['desktop', 'tablet', 'mobile'] as $device) {
$values = $style[$device] ?? null;
if (!is_array($values)) {
continue;
}
$rules = '';
$base = $this->declarations($values);
$base = $this->declarations($values, 'self', $device);
if ('' !== $base) {
$rules .= sprintf('#%s{%s}', $id, $base);
}
$hover = is_array($values['hover'] ?? null) ? $this->declarations($values['hover']) : '';
// Some properties belong on the images inside the block, not on the block: object-fit
// on a figure does nothing, and a radius on the figure leaves the image square.
$images = $this->declarations($values, 'img', $device);
if ('' !== $images) {
$rules .= sprintf('#%s img{%s}', $id, $images);
}
$hover = is_array($values['hover'] ?? null) ? $this->declarations($values['hover'], 'self', $device) : '';
if ('' !== $hover) {
$rules .= sprintf('#%s:hover,#%s:focus-within{%s}', $id, $id, $hover);
}
@@ -127,8 +146,12 @@ final class StyleCompiler
return $css;
}
/** Turns one validated value set into CSS declarations. */
private function declarations(array $values): string
/**
* Turns one validated value set into CSS declarations.
*
* @param string $target 'self' for the block, 'img' for the images inside it
*/
private function declarations(array $values, string $target = 'self', string $device = 'desktop'): string
{
$properties = StyleSchema::properties();
$out = [];
@@ -137,6 +160,9 @@ final class StyleCompiler
continue;
}
$property = $properties[$key];
if ($target !== ($property['target'] ?? 'self')) {
continue;
}
if (null === $property['css']) {
continue; // Composed below out of several values.
}
@@ -148,16 +174,29 @@ final class StyleCompiler
continue;
}
if ($property['sides'] ?? false) {
// The spelling comes from the schema: "border-top-width", not "border-width-top".
$pattern = $property['sideCss'] ?? $property['css'].'-%s';
foreach ($value as $side => $single) {
$out[] = sprintf('%s-%s:%s', $property['css'], $side, $this->css($property, (string) $single));
$out[] = sprintf('%s:%s', sprintf($pattern, $side), $this->css($property, (string) $single));
}
continue;
}
$out[] = sprintf('%s:%s', $property['css'], $this->css($property, (string) $value));
}
foreach ([$this->background($values), $this->shadow($values), $this->transform($values), $this->grid($values), $this->transition($values)] as $composed) {
if ('' !== $composed) {
$out[] = $composed;
// The composed declarations describe the block itself; none of them has an image variant.
if ('self' === $target) {
foreach ([
$this->background($values, $device),
$this->shadow($values),
$this->textShadow($values),
$this->filter($values),
$this->transform($values),
$this->grid($values),
$this->transition($values),
] as $composed) {
if ('' !== $composed) {
$out[] = $composed;
}
}
}
@@ -177,7 +216,7 @@ final class StyleCompiler
}
/** Overlay, gradient and image share one background-image declaration, layered in that order. */
private function background(array $values): string
private function background(array $values, string $device = 'desktop'): string
{
$layers = [];
$overlay = $values['overlayColor'] ?? null;
@@ -197,7 +236,9 @@ final class StyleCompiler
if (isset($values['backgroundImage']) && null !== $this->media) {
$item = $this->media->find((string) $values['backgroundImage']);
if (null !== $item && [] !== $item['variants']) {
$largest = end($item['variants']);
// A phone should not download the 2400 pixel variant of a background. CSS has no
// srcset, but the rules are already written per device, so the device decides.
$largest = self::variantFor($item['variants'], $device);
// The URL comes from the media library, never from the document.
$layers[] = sprintf('url("%s")', $largest['src']);
}
@@ -214,7 +255,8 @@ final class StyleCompiler
}
return sprintf(
'box-shadow:%s %s %s %s %s',
'box-shadow:%s%s %s %s %s %s',
'innen' === ($values['shadowInset'] ?? 'aussen') ? 'inset ' : '',
$values['shadowX'] ?? '0',
$values['shadowY'] ?? '0',
$values['shadowBlur'] ?? '0',
@@ -223,6 +265,52 @@ final class StyleCompiler
);
}
/** A text shadow, from the same kind of parts as the box shadow. */
private function textShadow(array $values): string
{
$parts = array_intersect_key($values, array_flip(['textShadowX', 'textShadowY', 'textShadowBlur', 'textShadowColor']));
if ([] === $parts) {
return '';
}
return sprintf(
'text-shadow:%s %s %s %s',
$values['textShadowX'] ?? '0',
$values['textShadowY'] ?? '0',
$values['textShadowBlur'] ?? '0',
$this->css(['type' => 'color'], (string) ($values['textShadowColor'] ?? '#00000040')),
);
}
/**
* The image filters, as one declaration in a fixed order.
*
* The order matters for the result, so it is written here rather than taken from the order in
* which someone happened to set the values.
*/
private function filter(array $values): string
{
$parts = [];
foreach ([
'blur' => 'blur(%spx)',
'brightness' => 'brightness(%s)',
'contrast' => 'contrast(%s)',
'saturate' => 'saturate(%s)',
'grayscale' => 'grayscale(%s)',
'sepia' => 'sepia(%s)',
'invert' => 'invert(%s)',
] as $key => $format) {
if (isset($values[$key])) {
$parts[] = sprintf($format, (float) $values[$key]);
}
}
if (isset($values['hueRotate'])) {
$parts[] = sprintf('hue-rotate(%ddeg)', (int) $values['hueRotate']);
}
return [] === $parts ? '' : 'filter:'.implode(' ', $parts);
}
private function transform(array $values): string
{
$parts = [];
@@ -241,6 +329,68 @@ final class StyleCompiler
return [] === $parts ? '' : 'transform:'.implode(' ', $parts);
}
/**
* The variant a device should load for a background.
*
* Mobile takes the smallest that is at least 960 wide, tablet the one around 1600, desktop the
* largest. A picture that only has small variants simply gives what it has.
*/
/**
* Ein Hintergrundbild gilt auch dann in Gerätegrößen, wenn es nur einmal gesetzt wurde.
*
* Wer im Editor ein Hintergrundbild wählt, setzt es üblicherweise am Schreibtisch - also unter
* „Desktop“. Ohne eigene Regel für die kleineren Geräte erbt ein Telefon diese Regel und lädt
* die größte Variante: gemessen 189 KB statt 40 KB für ein Bild, das nie in dieser Auflösung
* zu sehen ist. CSS kennt kein srcset, aber die Regeln stehen ohnehin je Gerät da - also
* bekommt jedes Gerät dieselbe Bildwahl, die es bekäme, wenn jemand sie einzeln gesetzt hätte.
*
* Ein ausdrücklich gesetztes Bild wird nie ersetzt; ergänzt wird nur, wo nichts steht.
*/
private function scaleBackground(array $style): array
{
$image = $style['desktop']['backgroundImage'] ?? null;
if (null === $image) {
return $style;
}
foreach (['tablet', 'mobile'] as $device) {
if (!isset($style[$device]['backgroundImage'])) {
$style[$device]['backgroundImage'] = $image;
// Verlauf und Überlagerung gehören zur selben Regel: ohne sie stünde auf dem
// Telefon plötzlich ein unabgedunkeltes Bild hinter hellem Text.
foreach (['gradientFrom', 'gradientTo', 'gradientAngle', 'overlayColor', 'overlayOpacity', 'backgroundSize', 'backgroundPosition', 'backgroundRepeat', 'backgroundAttachment'] as $begleiter) {
if (isset($style['desktop'][$begleiter]) && !isset($style[$device][$begleiter])) {
$style[$device][$begleiter] = $style['desktop'][$begleiter];
}
}
}
}
return $style;
}
/**
* Die Variante, die ein Hintergrundbild auf diesem Geraet bekommt.
*
* Oeffentlich und statisch, weil der Seitenkopf dieselbe Wahl treffen muss, wenn er das Bild
* vorab holt. Zwei Stellen mit derselben Regel waeren zwei Stellen, die auseinanderlaufen.
*
* @param list<array{src: string, width: int}> $variants
*
* @return array{src: string, width: int}
*/
public static function variantFor(array $variants, string $device): array
{
$wanted = ['mobile' => 960, 'tablet' => 1600, 'desktop' => 4000][$device] ?? 4000;
$chosen = $variants[0];
foreach ($variants as $variant) {
if ($variant['width'] <= $wanted || $chosen['width'] < $wanted) {
$chosen = $variant;
}
}
return $chosen;
}
private function grid(array $values): string
{
return isset($values['gridColumns'])
+87 -6
View File
@@ -18,14 +18,26 @@ final class StyleSchema
/** A colour is a hex value or "@name", a reference into the site palette. */
public const COLOR = '/^(#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?|@[a-z][a-z0-9-]{0,31})$/D';
/** A length is a number with a unit, plus the two keywords that behave like one. */
public const LENGTH = '/^(0|-?\d{1,5}(\.\d{1,3})?(px|rem|em|%|vw|vh|ch))$/D';
public const LENGTH_AUTO = '/^(auto|none|0|-?\d{1,5}(\.\d{1,3})?(px|rem|em|%|vw|vh|ch))$/D';
/**
* A length is a number with a unit, plus the two keywords that behave like one.
*
* dvh/svh/lvh are in the list because 100vh is wrong on a phone: it means the viewport
* without the address bar, so a "full height" section is taller than the screen and the
* bottom edge sits under the browser chrome. 100dvh is what people mean.
*/
public const LENGTH = '/^(0|-?\d{1,5}(\.\d{1,3})?(px|rem|em|%|vw|vh|ch|dvh|svh|lvh|dvw))$/D';
public const LENGTH_AUTO = '/^(auto|none|0|-?\d{1,5}(\.\d{1,3})?(px|rem|em|%|vw|vh|ch|dvh|svh|lvh|dvw))$/D';
/**
* group – where the property appears in the editor
* type – length | length-auto | color | number | choice | text
* css – the CSS property it becomes; sides/corners expand to four
* label – German label for the editor.
* label – German label for the editor
* target – 'img' applies the declaration to the images inside the block instead of the block
* itself. object-fit on a figure does nothing; it belongs on the image.
* sideCss – how one side is spelled. "padding-top" follows the property name, but a border
* width is "border-top-width" and not "border-width-top" - written out, because
* guessing it wrong produces a declaration the browser silently drops.
*
* @return array<string, array{group: string, type: string, css: string|array, label: string, values?: array<string, string>, min?: float, max?: float, step?: float, sides?: bool, hover?: bool}>
*/
@@ -77,8 +89,8 @@ final class StyleSchema
'overlayOpacity' => ['group' => 'background', 'type' => 'number', 'css' => null, 'label' => 'Deckkraft der Überlagerung', 'min' => 0, 'max' => 1, 'step' => 0.05],
// ---- Abstand ----------------------------------------------------------------
'padding' => ['group' => 'spacing', 'type' => 'length', 'css' => 'padding', 'label' => 'Innenabstand', 'sides' => true],
'margin' => ['group' => 'spacing', 'type' => 'length-auto', 'css' => 'margin', 'label' => 'Außenabstand', 'sides' => true],
'padding' => ['group' => 'spacing', 'type' => 'length', 'css' => 'padding', 'label' => 'Innenabstand', 'sides' => true, 'sideCss' => 'padding-%s'],
'margin' => ['group' => 'spacing', 'type' => 'length-auto', 'css' => 'margin', 'label' => 'Außenabstand', 'sides' => true, 'sideCss' => 'margin-%s'],
// ---- Größe ------------------------------------------------------------------
'width' => ['group' => 'size', 'type' => 'length-auto', 'css' => 'width', 'label' => 'Breite'],
@@ -92,12 +104,38 @@ final class StyleSchema
]],
// ---- Rahmen -----------------------------------------------------------------
'borderWidth' => ['group' => 'border', 'type' => 'length', 'css' => 'border-width', 'label' => 'Rahmenstärke', 'sides' => true],
'borderWidth' => ['group' => 'border', 'type' => 'length', 'css' => 'border-width', 'label' => 'Rahmenstärke', 'sides' => true, 'sideCss' => 'border-%s-width'],
'borderStyle' => ['group' => 'border', 'type' => 'choice', 'css' => 'border-style', 'label' => 'Rahmenart', 'values' => [
'solid' => 'Durchgezogen', 'dashed' => 'Gestrichelt', 'dotted' => 'Gepunktet', 'double' => 'Doppelt', 'none' => 'Keiner',
]],
'borderColor' => ['group' => 'border', 'type' => 'color', 'css' => 'border-color', 'label' => 'Rahmenfarbe', 'hover' => true],
'borderRadius' => ['group' => 'border', 'type' => 'length', 'css' => 'border-radius', 'label' => 'Ecken', 'corners' => true],
'outlineWidth' => ['group' => 'border', 'type' => 'length', 'css' => 'outline-width', 'label' => 'Zusatzrahmen Stärke'],
'outlineColor' => ['group' => 'border', 'type' => 'color', 'css' => 'outline-color', 'label' => 'Zusatzrahmen Farbe', 'hover' => true],
'outlineOffset' => ['group' => 'border', 'type' => 'length', 'css' => 'outline-offset', 'label' => 'Zusatzrahmen Abstand'],
'outlineStyle' => ['group' => 'border', 'type' => 'choice', 'css' => 'outline-style', 'label' => 'Zusatzrahmen Art', 'values' => [
'solid' => 'Durchgezogen', 'dashed' => 'Gestrichelt', 'dotted' => 'Gepunktet', 'double' => 'Doppelt', 'none' => 'Keiner',
]],
// ---- Bild: wirkt auf die Bilder im Block, nicht auf den Block ----------------
'objectFit' => ['group' => 'image', 'type' => 'choice', 'css' => 'object-fit', 'label' => 'Bildausschnitt', 'target' => 'img', 'values' => [
'cover' => 'Füllen und beschneiden', 'contain' => 'Vollständig zeigen', 'fill' => 'Verzerren', 'none' => 'Originalgröße', 'scale-down' => 'Nur verkleinern',
]],
'objectPosition' => ['group' => 'image', 'type' => 'choice', 'css' => 'object-position', 'label' => 'Bildmitte', 'target' => 'img', 'values' => [
'center' => 'Mitte', 'top' => 'Oben', 'bottom' => 'Unten', 'left' => 'Links', 'right' => 'Rechts',
'top left' => 'Oben links', 'top right' => 'Oben rechts', 'bottom left' => 'Unten links', 'bottom right' => 'Unten rechts',
]],
'imageRatio' => ['group' => 'image', 'type' => 'choice', 'css' => 'aspect-ratio', 'label' => 'Seitenverhältnis', 'target' => 'img', 'values' => [
'auto' => 'Wie das Bild', '1/1' => 'Quadrat 1:1', '4/3' => 'Klassisch 4:3', '3/2' => 'Foto 3:2', '16/9' => 'Breitbild 16:9',
'21/9' => 'Panorama 21:9', '3/4' => 'Hochkant 3:4', '2/3' => 'Hochkant 2:3', '9/16' => 'Hochkant 9:16',
]],
'imageHeight' => ['group' => 'image', 'type' => 'length-auto', 'css' => 'height', 'label' => 'Bildhöhe', 'target' => 'img'],
'imageRadius' => ['group' => 'image', 'type' => 'length', 'css' => 'border-radius', 'label' => 'Bildecken', 'target' => 'img', 'corners' => true],
'imageBorderWidth' => ['group' => 'image', 'type' => 'length', 'css' => 'border-width', 'label' => 'Bildrahmen Stärke', 'target' => 'img', 'sides' => true, 'sideCss' => 'border-%s-width'],
'imageBorderColor' => ['group' => 'image', 'type' => 'color', 'css' => 'border-color', 'label' => 'Bildrahmen Farbe', 'target' => 'img'],
'imageBorderStyle' => ['group' => 'image', 'type' => 'choice', 'css' => 'border-style', 'label' => 'Bildrahmen Art', 'target' => 'img', 'values' => [
'solid' => 'Durchgezogen', 'dashed' => 'Gestrichelt', 'dotted' => 'Gepunktet', 'double' => 'Doppelt', 'none' => 'Keiner',
]],
// ---- Layout -----------------------------------------------------------------
'display' => ['group' => 'layout', 'type' => 'choice', 'css' => 'display', 'label' => 'Anzeige', 'values' => [
@@ -116,6 +154,22 @@ final class StyleSchema
'gridColumns' => ['group' => 'layout', 'type' => 'number', 'css' => null, 'label' => 'Spalten', 'min' => 1, 'max' => 12, 'step' => 1],
'gap' => ['group' => 'layout', 'type' => 'length', 'css' => 'gap', 'label' => 'Abstand zwischen Elementen'],
'order' => ['group' => 'layout', 'type' => 'number', 'css' => 'order', 'label' => 'Reihenfolge', 'min' => -20, 'max' => 20, 'step' => 1],
'rowGap' => ['group' => 'layout', 'type' => 'length', 'css' => 'row-gap', 'label' => 'Abstand zwischen Zeilen'],
'columnGap' => ['group' => 'layout', 'type' => 'length', 'css' => 'column-gap', 'label' => 'Abstand zwischen Spalten'],
'alignSelf' => ['group' => 'layout', 'type' => 'choice', 'css' => 'align-self', 'label' => 'Eigene Ausrichtung quer', 'values' => [
'auto' => 'Wie die Gruppe', 'flex-start' => 'Anfang', 'center' => 'Mitte', 'flex-end' => 'Ende', 'stretch' => 'Strecken', 'baseline' => 'Grundlinie',
]],
'flexGrow' => ['group' => 'layout', 'type' => 'number', 'css' => 'flex-grow', 'label' => 'Wachsen', 'min' => 0, 'max' => 12, 'step' => 1],
'flexShrink' => ['group' => 'layout', 'type' => 'number', 'css' => 'flex-shrink', 'label' => 'Schrumpfen', 'min' => 0, 'max' => 12, 'step' => 1],
'flexBasis' => ['group' => 'layout', 'type' => 'length-auto', 'css' => 'flex-basis', 'label' => 'Grundbreite'],
'position' => ['group' => 'layout', 'type' => 'choice', 'css' => 'position', 'label' => 'Positionierung', 'values' => [
'static' => 'Im Fluss', 'relative' => 'Relativ verschoben', 'absolute' => 'Freigestellt', 'sticky' => 'Klebt beim Scrollen',
]],
'top' => ['group' => 'layout', 'type' => 'length-auto', 'css' => 'top', 'label' => 'Abstand von oben'],
'right' => ['group' => 'layout', 'type' => 'length-auto', 'css' => 'right', 'label' => 'Abstand von rechts'],
'bottom' => ['group' => 'layout', 'type' => 'length-auto', 'css' => 'bottom', 'label' => 'Abstand von unten'],
'left' => ['group' => 'layout', 'type' => 'length-auto', 'css' => 'left', 'label' => 'Abstand von links'],
'zIndex' => ['group' => 'layout', 'type' => 'number', 'css' => 'z-index', 'label' => 'Ebene (vorn/hinten)', 'min' => -10, 'max' => 100, 'step' => 1],
// ---- Wirkung ----------------------------------------------------------------
'opacity' => ['group' => 'effect', 'type' => 'number', 'css' => 'opacity', 'label' => 'Deckkraft', 'min' => 0, 'max' => 1, 'step' => 0.05, 'hover' => true],
@@ -128,10 +182,37 @@ final class StyleSchema
'translateY' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Verschieben senkrecht', 'hover' => true],
'scale' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Skalierung', 'min' => 0.1, 'max' => 3, 'step' => 0.01, 'hover' => true],
'rotate' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Drehung', 'min' => -360, 'max' => 360, 'step' => 1, 'hover' => true],
'shadowInset' => ['group' => 'effect', 'type' => 'choice', 'css' => null, 'label' => 'Schatten innen', 'values' => ['aussen' => 'Außen', 'innen' => 'Innen']],
'textShadowX' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Textschatten waagerecht'],
'textShadowY' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Textschatten senkrecht'],
'textShadowBlur' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Textschatten weich'],
'textShadowColor' => ['group' => 'effect', 'type' => 'color', 'css' => null, 'label' => 'Textschattenfarbe'],
'blur' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Weichzeichnen (px)', 'min' => 0, 'max' => 40, 'step' => 0.5, 'hover' => true],
'brightness' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Helligkeit', 'min' => 0, 'max' => 3, 'step' => 0.05, 'hover' => true],
'contrast' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Kontrast', 'min' => 0, 'max' => 3, 'step' => 0.05, 'hover' => true],
'saturate' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Farbsättigung', 'min' => 0, 'max' => 3, 'step' => 0.05, 'hover' => true],
'grayscale' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Graustufen', 'min' => 0, 'max' => 1, 'step' => 0.05, 'hover' => true],
'sepia' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Sepia', 'min' => 0, 'max' => 1, 'step' => 0.05, 'hover' => true],
'hueRotate' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Farbdrehung (Grad)', 'min' => -180, 'max' => 180, 'step' => 1, 'hover' => true],
'invert' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Umkehren', 'min' => 0, 'max' => 1, 'step' => 0.05],
'mixBlendMode' => ['group' => 'effect', 'type' => 'choice', 'css' => 'mix-blend-mode', 'label' => 'Mischmodus', 'values' => [
'normal' => 'Normal', 'multiply' => 'Multiplizieren', 'screen' => 'Negativ multiplizieren', 'overlay' => 'Überlagern',
'darken' => 'Abdunkeln', 'lighten' => 'Aufhellen', 'color-dodge' => 'Farbig abwedeln', 'difference' => 'Differenz',
'exclusion' => 'Ausschluss', 'hue' => 'Farbton', 'saturation' => 'Sättigung', 'color' => 'Farbe', 'luminosity' => 'Leuchtkraft',
]],
'cursor' => ['group' => 'effect', 'type' => 'choice', 'css' => 'cursor', 'label' => 'Mauszeiger', 'values' => [
'auto' => 'Automatisch', 'pointer' => 'Hand', 'default' => 'Pfeil', 'not-allowed' => 'Gesperrt', 'grab' => 'Greifen', 'zoom-in' => 'Lupe',
]],
'transition' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Übergangsdauer (ms)', 'min' => 0, 'max' => 2000, 'step' => 25],
];
}
/** Properties that are written onto the images inside a block instead of the block itself. */
public static function imageTargets(): array
{
return array_keys(array_filter(self::properties(), static fn (array $p): bool => 'img' === ($p['target'] ?? null)));
}
/** Properties that may also be given a hover value. */
public static function hoverable(): array
{
+43 -3
View File
@@ -49,6 +49,10 @@ final class Theme
'linkColor' => '@link',
'containerWidth' => '1240px',
'headingScale' => 1.25,
// Unterstrichene Links im Fließtext. An, weil ein Link, den nur die Farbe verrät,
// für Menschen mit Farbsehschwäche kein Link ist. Wer eine ruhigere Seite will,
// schaltet es hier ab - dann bleibt der Unterstrich beim Überfahren.
'linkUnderline' => true,
],
];
@@ -56,15 +60,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 +107,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
@@ -198,6 +211,7 @@ final class Theme
}
$base[$field] = $value;
}
$base['linkUnderline'] = (bool) ($input['linkUnderline'] ?? false);
foreach (['lineHeight' => [0.8, 3.0], 'headingScale' => [1.0, 2.0]] as $field => [$min, $max]) {
$value = (float) ($input[$field] ?? $base[$field]);
if ($value < $min || $value > $max) {
@@ -240,6 +254,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. */
@@ -257,6 +274,26 @@ final class Theme
$variables[] = sprintf('--k-container:%s', $base['containerWidth']);
$colour = static fn (string $v): string => str_starts_with($v, '@') ? sprintf('var(--k-%s)', substr($v, 1)) : $v;
// Die Bausteine selbst - Knopf, Symbol, Rahmen, ruhiger Text - wurden aus festen Werten in
// site.css gefaerbt. Eine Website mit eigener Palette behielt damit das Gruen der Vorlage:
// die Farben liessen sich einstellen und taten nichts. Hier bekommen diese Token ihre
// Werte aus dem Theme. Als Akzent dient die Linkfarbe und nicht ein Palettenname, weil
// dieses Token auch kleine Beschriftungen faerbt - eine helle Akzentfarbe waere dort
// nicht mehr lesbar.
$ink = $colour($base['color']);
$paper = $colour($base['backgroundColor']);
$variables[] = sprintf('--ink:%s', $ink);
$variables[] = sprintf('--paper:%s', $paper);
$variables[] = sprintf('--accent:%s', $colour($base['linkColor']));
// Abgeleitet statt erfunden: ruhiger Text und Linien sind der Textton, in den Hintergrund
// gemischt. Ein Palettenwert gleichen Namens hat Vorrang, damit sich beides setzen laesst.
// 80 Prozent, nicht weniger: bei 72 kam fuer eine Palette mit hellem Textton ein Grau
// heraus, das die 4,5:1 knapp verfehlte - gefunden beim Nachbau von kant-it.de.
$variables[] = sprintf('--muted:var(--k-muted,color-mix(in srgb,%s 80%%,%s))', $ink, $paper);
$variables[] = sprintf('--line:var(--k-line,color-mix(in srgb,%s 20%%,%s))', $ink, $paper);
$variables[] = sprintf('--mint:var(--k-mint,color-mix(in srgb,%s 14%%,%s))', $colour($base['linkColor']), $paper);
$scale = (float) $base['headingScale'];
$headings = '';
// A modular scale from the base size, so one number changes the whole hierarchy.
@@ -267,7 +304,9 @@ final class Theme
return sprintf(
':root{%s;--k-size:%s}.kant{font-family:var(--kf-%s);font-size:var(--k-size);line-height:%s;color:%s;background:%s}'
.'.kant h1,.kant h2,.kant h3,.kant h4,.kant h5,.kant h6{font-family:var(--kf-%s)}'
.'.kant .block-text a,.kant .post-excerpt a{color:%s}.kant .shell{max-width:var(--k-container)}%s',
.'.kant .block-text a,.kant .block-list a,.kant .post-excerpt a{color:%s;text-decoration:%s}'
.'.kant .block-text a:hover,.kant .block-list a:hover,.kant .post-excerpt a:hover{text-decoration:underline}'
.'.kant .shell{max-width:var(--k-container)}%s',
implode(';', $variables),
$base['fontSize'],
$base['fontFamily'],
@@ -276,6 +315,7 @@ final class Theme
$colour($base['backgroundColor']),
$base['headingFontFamily'],
$colour($base['linkColor']),
($base['linkUnderline'] ?? true) ? 'underline' : 'none',
$headings,
);
}
+127
View File
@@ -0,0 +1,127 @@
<?php
namespace App\Content;
/**
* Cloudflare Turnstile: die Prüfung „bist du ein Mensch“ ohne Bilderrätsel.
*
* Der eingebaute Spamschutz – Honigtopf, signierter Zeitstempel, fünf Absendungen je Stunde –
* hält, was automatisch ausgefüllte Formulare angeht, das meiste ab. Gegen einen Absender, der
* einen echten Browser fernsteuert, hilft er nicht. Dafür ist das hier gedacht, und zwar
* **optional**: eine Website, die keine Spamwelle hat, soll keinen Dritten einbinden.
*
* Warum Turnstile und nicht reCAPTCHA: es setzt keine Cookies, verlangt keine Anmeldung bei
* Google und wertet kein Nutzungsverhalten über Websites hinweg aus. Ein Dritter bleibt es
* trotzdem – die IP-Adresse des Absenders geht an Cloudflare, und das gehört in die
* Datenschutzerklärung.
*
* Der Preis steht in der Verwaltung dabei: die Prüfung braucht JavaScript. Ohne Skript ist das
* Formular dann nicht absendbar, und das ist genau die Art von Verschlechterung, die dieses
* Projekt sonst vermeidet.
*/
final class Turnstile
{
private const VERIFY = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
/** Die Adresse, von der das Widget sein Skript lädt - sie muss in die Sicherheitsrichtlinie. */
public const HOST = 'https://challenges.cloudflare.com';
public function __construct(private SiteSettings $settings)
{
}
/** Ist die Prüfung eingerichtet und eingeschaltet? */
public function isActive(): bool
{
$settings = $this->settings->load();
return ($settings['turnstile'] ?? false)
&& '' !== ($settings['turnstileSiteKey'] ?? '')
&& '' !== ($settings['turnstileSecret'] ?? '');
}
/** Der öffentliche Schlüssel für das Widget im Formular. */
public function siteKey(): string
{
return (string) ($this->settings->load()['turnstileSiteKey'] ?? '');
}
/**
* Prüft die Antwort des Besuchers bei Cloudflare.
*
* Ist die Prüfung nicht eingeschaltet, gilt jede Absendung als bestanden - der Aufrufer muss
* nicht wissen, ob sie eingerichtet ist.
*
* Antwortet Cloudflare gar nicht, gilt die Absendung ebenfalls als bestanden. Das ist
* beabsichtigt: ein Ausfall bei einem Dritten darf nicht dazu führen, dass ein Kunde sein
* Angebot nicht anfragen kann. Spam ist ärgerlich, eine verlorene Anfrage ist teurer.
*/
public function passed(?string $token, ?string $clientIp): bool
{
if (!$this->isActive()) {
return true;
}
if (null === $token || '' === $token || strlen($token) > 2048) {
return false;
}
$antwort = $this->ask([
'secret' => (string) ($this->settings->load()['turnstileSecret'] ?? ''),
'response' => $token,
...(null === $clientIp ? [] : ['remoteip' => $clientIp]),
]);
return null === $antwort ? true : (bool) ($antwort['success'] ?? false);
}
/**
* Fragt Cloudflare - über curl, ersatzweise über den Dateizeiger.
*
* Dieselben zwei Wege wie im Installer und beim Laden der Schriften: auf einem Webhosting ist
* mal das eine da und mal das andere, und eine weitere Abhängigkeit würde jedes Update ohne
* Composer scheitern lassen.
*
* @param array<string, string> $felder
*
* @return array<string, mixed>|null null, wenn keine Antwort zustande kam
*/
private function ask(array $felder): ?array
{
$body = http_build_query($felder);
$rohantwort = null;
if (function_exists('curl_init')) {
$curl = curl_init(self::VERIFY);
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_CONNECTTIMEOUT => 5,
// Kurz gehalten: hier wartet ein Mensch vor einem abgeschickten Formular.
CURLOPT_TIMEOUT => 10,
]);
$ergebnis = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
$rohantwort = is_string($ergebnis) && 200 === $status ? $ergebnis : null;
} elseif (filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL)) {
$context = stream_context_create([
'http' => ['method' => 'POST', 'header' => "Content-Type: application/x-www-form-urlencoded\r\n", 'content' => $body, 'timeout' => 10],
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
]);
$ergebnis = @file_get_contents(self::VERIFY, false, $context);
$rohantwort = is_string($ergebnis) ? $ergebnis : null;
}
if (null === $rohantwort) {
return null;
}
try {
$entschluesselt = json_decode($rohantwort, true, 16, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
return is_array($entschluesselt) ? $entschluesselt : null;
}
}
+39 -4
View File
@@ -8,6 +8,7 @@ use App\Content\DocumentValidator;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\PostLayouts;
use App\Content\PostRepository;
use App\Content\RedirectRepository;
use App\Content\StyleCompiler;
@@ -23,7 +24,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
#[Route('/admin')]
#[Route('/%kant.admin_path%')]
final class EditorController extends AbstractController
{
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private CsrfTokenManagerInterface $csrf)
@@ -45,6 +46,9 @@ final class EditorController extends AbstractController
'pages' => $pages,
'trash' => $this->pages->trash(),
'parents' => array_values(array_filter($pages, static fn (array $p): bool => 'page' === $p['type'] && $p['depth'] + 2 <= PageRepository::MAX_DEPTH)),
'postLayouts' => PostLayouts::all(),
// Which header and footer areas a page can choose from.
'areaChoices' => ['header' => $this->partials->choices('header'), 'footer' => $this->partials->choices('footer')],
]);
}
@@ -63,7 +67,13 @@ final class EditorController extends AbstractController
$parentId = $request->request->getString('parent_id') ?: null;
$slug = trim($request->request->getString('slug')) ?: $this->slugify($title);
try {
$id = $this->pages->createPage($slug, $parentId, $type, DemoContent::starter($title, $type));
// Ein Beitrag beginnt mit einem Aufbau, eine Seite leer: bei einer Seite weiß
// niemand vorher, was daraus wird, bei einem Beitrag fast immer.
$layout = $request->request->getString('layout', PostLayouts::DEFAULT);
$document = 'post' === $type && PostLayouts::exists($layout)
? PostLayouts::document($layout, $title)
: DemoContent::starter($title, $type);
$id = $this->pages->createPage($slug, $parentId, $type, $document);
} catch (InvalidPage|InvalidDocument $e) {
return $this->back($e->getMessage());
}
@@ -135,6 +145,7 @@ final class EditorController extends AbstractController
'publishedAt' => $request->request->getString('publishedAt'),
'featuredMediaId' => $request->request->getString('featuredMediaId'),
'ogMediaId' => $request->request->getString('ogMediaId'),
'seoTitle' => $request->request->getString('seoTitle'),
'robots' => $request->request->getString('robots', 'index'),
],
$terms($request->request->getString('categories')),
@@ -148,6 +159,30 @@ final class EditorController extends AbstractController
return $this->back('Die Beitragsdaten wurden gespeichert.', 'success');
}
/** Which header and footer this page uses, including none at all. */
#[Route('/pages/{id}/bereiche', name: 'admin_page_areas', methods: ['POST'])]
public function areas(Request $request, string $id): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
// An empty field means "the standard area"; that is stored as null, not as an address.
$choice = static fn (string $value): ?string => '' === $value ? null : $value;
try {
$this->pages->saveAreas(
$id,
$choice($request->request->getString('headerArea')),
$choice($request->request->getString('footerArea')),
$request->request->getInt('expectedVersion', -1),
);
} catch (InvalidPage|StaleWrite $e) {
return $this->back($e->getMessage());
}
return $this->back('Kopf- und Fußbereich dieser Seite wurden gespeichert.', 'success');
}
#[Route('/pages/{id}/move', name: 'admin_move', methods: ['POST'])]
public function move(Request $request, string $id): Response
{
@@ -223,14 +258,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)),
]);
}
+4 -4
View File
@@ -16,17 +16,17 @@ final class FormAdminController extends AbstractController
{
}
#[Route('/admin/forms', name: 'admin_forms', methods: ['GET'])]
public function index(): Response
#[Route('/%kant.admin_path%/forms', name: 'admin_forms', methods: ['GET'])]
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)),
]);
}
#[Route('/admin/forms/{slug}', name: 'admin_form_save', requirements: ['slug' => '[a-z0-9-]{1,80}|neu'], methods: ['POST'])]
#[Route('/%kant.admin_path%/forms/{slug}', name: 'admin_form_save', requirements: ['slug' => '[a-z0-9-]{1,80}|neu'], methods: ['POST'])]
public function save(Request $request, string $slug): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
+32 -38
View File
@@ -6,6 +6,7 @@ use App\Content\FormRepository;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\SiteSettings;
use App\Content\Turnstile;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -28,6 +29,7 @@ final class FormController extends AbstractController
private MenuRepository $menus,
private SiteSettings $settings,
private MailerInterface $mailer,
private Turnstile $turnstile,
private string $secret,
private string $siteOrigin,
) {
@@ -48,12 +50,16 @@ final class FormController extends AbstractController
// so it learns nothing, but nothing is stored and nothing is sent.
$silentlyDropped = '' !== trim($request->request->getString('website'))
|| !$this->timingIsPlausible($request)
|| $this->forms->rateLimited($senderHash);
|| $this->forms->rateLimited($senderHash)
// Die vierte, freiwillige Pruefung. Sie antwortet wie die drei anderen still mit
// Erfolg: ein Absender, der erfaehrt, woran er gescheitert ist, versucht es gezielt
// noch einmal.
|| !$this->turnstile->passed($request->request->getString('cf-turnstile-response'), $request->getClientIp());
$checked = $this->forms->check($form, $request->request->all());
if (!$silentlyDropped && [] !== $checked['errors']) {
// Re-render the page the form sits on, with the entered values and the messages.
return $this->renderPage($page, $form, $checked, new Response(status: 422));
return $this->renderPage($request, $page, $form, $checked);
}
if (!$silentlyDropped) {
$submission = $this->forms->store($form['id'], $checked['values'], $checked['consent'], $senderHash);
@@ -104,50 +110,38 @@ final class FormController extends AbstractController
}
}
private function renderPage(array $page, array $form, array $checked, Response $response): Response
/**
* Die Seite mit den Fehlermeldungen - gerendert vom Seitencontroller, nicht hier.
*
* Vorher baute diese Methode die Seite selbst zusammen und liess dabei alles weg, was sie
* nicht kannte: Theme, Blockstile, Schriften, das Stylesheet-Buendel. Wer ein Pflichtfeld
* vergass, sah eine Seite ohne Gestaltung. Jetzt geht der Weg durch dieselbe Stelle wie jeder
* andere Aufruf, und die Formularzustaende werden als Anfrageattribut mitgereicht.
*/
private function renderPage(Request $request, array $page, array $form, array $checked): Response
{
$navigation = [];
foreach ($this->menus->all() as $slot => $menu) {
$navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path']);
}
$issued = time();
$states = [];
$walk = function (array $blocks) use (&$walk, &$states, $page, $form, $checked, $issued): void {
$zustaende = [];
$walk = function (array $blocks) use (&$walk, &$zustaende, $form, $checked): void {
foreach ($blocks as $block) {
if ('form' === ($block['type'] ?? null)) {
$slug = $block['props']['form'] ?? '';
$mine = $slug === $form['slug'];
$states[$block['id']] = [
'form' => $mine ? $form : $this->forms->findBySlug($slug),
'sent' => false,
// Only the form that was submitted shows messages and keeps its input.
'errors' => $mine ? $checked['errors'] : [],
'values' => $mine ? $checked['values'] : [],
'blockId' => $block['id'], 'page' => $page,
'issued' => $issued,
'issuedSignature' => hash_hmac('sha256', (string) $issued, $this->secret),
];
// Nur das abgeschickte Formular zeigt Meldungen und behaelt seine Eingaben.
if ('form' === ($block['type'] ?? null) && ($block['props']['form'] ?? '') === $form['slug']) {
$zustaende[$block['id']] = ['errors' => $checked['errors'], 'values' => $checked['values'], 'sent' => false];
}
$walk($block['children'] ?? []);
}
};
$walk($page['document']['blocks'] ?? []);
$response = $this->render('page.html.twig', [
'page' => $page,
'canonical' => rtrim($this->siteOrigin, '/').$this->settings->publicPath($page['path']),
'navigation' => $navigation,
'postLists' => [],
'postMeta' => null,
'needsTabs' => false,
'pageStyles' => '',
'googleFonts' => null,
'forms' => $states,
], $response);
$response->headers->set('Cache-Control', 'no-store');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Content-Security-Policy', "default-src 'self'; script-src 'none'; style-src 'self'; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
// Die Startseite antwortet unter "/" - mit ihrem eigenen Pfad wuerde sie dorthin umleiten,
// und aus der Fehlermeldung wuerde eine Weiterleitung ohne Meldung.
$adresse = $page['path'] === $this->settings->homePath() ? '' : (string) $page['path'];
$antwort = $this->forward('App\Controller\PageController::page', [
'path' => $adresse,
'request' => $request->duplicate(null, null, ['kant_form_state' => $zustaende, '_route' => 'page']),
]);
// Die Eingabe war unvollstaendig - die Seite ist dieselbe, die Antwort ist es nicht.
$antwort->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
return $response;
return $antwort;
}
}
+213
View File
@@ -0,0 +1,213 @@
<?php
namespace App\Controller;
use App\Domain\InvalidPage;
use App\Ops\Installation;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
/**
* Six screens from an empty webspace to a website, without a console.
*
* Every screen answers only while the installation is unfinished, and every one of them writes its
* result straight away, so a reload continues where it stopped instead of starting over. The order
* is fixed and enforced here, not only suggested by links: a step whose requirement is missing
* sends you back to the step that produces it.
*
* After the first account exists, every route in this file answers 404 - not because a file was
* deleted, but because the database says there is an installation.
*/
final class InstallController extends AbstractController
{
public function __construct(private Installation $installation)
{
}
#[Route('/install', name: 'install', methods: ['GET'], priority: 10)]
public function check(): Response
{
$this->guard();
$checks = $this->installation->checks();
return $this->render('install/check.html.twig', [
'step' => 1,
'checks' => $checks,
'ready' => $this->installation->ready($checks),
]);
}
#[Route('/install/datenbank', name: 'install_database', methods: ['GET', 'POST'], priority: 10)]
public function database(Request $request): Response
{
$this->guard();
$error = null;
$driver = $request->request->getString('driver', 'sqlite');
if ($request->isMethod('POST') && $this->signed($request, 'datenbank')) {
$input = [
'host' => $request->request->getString('host', '127.0.0.1'),
'port' => $request->request->getString('port', '3306'),
'name' => $request->request->getString('name', 'cms'),
'user' => $request->request->getString('user'),
'password' => $request->request->getString('password'),
];
$result = $this->installation->testDatabase('mysql' === $driver ? 'mysql' : 'sqlite', $input);
$error = $result['error'];
if (null === $error) {
try {
$this->installation->writeEnvironment(['APP_ENV' => 'prod', 'APP_DEBUG' => '0', 'DATABASE_URL' => $result['url']]);
// The new connection only exists in the next request, because the environment
// file is read while booting. That is also why this is a redirect and not a
// rendered page: the migration screen has to talk to the new database.
return $this->redirectToRoute('install_migration');
} catch (\RuntimeException $e) {
$error = $e->getMessage();
}
}
}
return $this->render('install/database.html.twig', [
'step' => 2,
'driver' => $driver,
'error' => $error,
'values' => $request->request->all(),
'mysql' => \extension_loaded('pdo_mysql'),
// SQLite nur, wenn die Fassung die Tabellenfunktionen kennt, mit denen Doctrine das
// Schema liest - sonst scheitert erst der naechste Schritt, und zwar unverstaendlich.
'sqlite' => \extension_loaded('pdo_sqlite') && null !== $this->installation->sqliteVersion()
&& version_compare((string) $this->installation->sqliteVersion(), Installation::MIN_SQLITE, '>='),
'sqliteVersion' => $this->installation->sqliteVersion(),
'minSqlite' => Installation::MIN_SQLITE,
...$this->token('datenbank'),
]);
}
#[Route('/install/migration', name: 'install_migration', methods: ['GET', 'POST'], priority: 10)]
public function migration(Request $request): Response
{
$this->guard();
if (!$this->installation->environmentWritten()) {
return $this->redirectToRoute('install_database');
}
$error = null;
$applied = null;
if ($request->isMethod('POST') && $this->signed($request, 'migration')) {
@set_time_limit(0);
try {
$applied = $this->installation->migrate();
return $this->redirectToRoute('install_website');
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
return $this->render('install/migration.html.twig', [
'step' => 3,
'pending' => $error ? [] : $this->pending(),
'applied' => $applied,
'error' => $error,
...$this->token('migration'),
]);
}
#[Route('/install/website', name: 'install_website', methods: ['GET', 'POST'], priority: 10)]
public function website(Request $request): Response
{
$this->guard();
if (!$this->installation->schemaReady()) {
return $this->redirectToRoute('install_migration');
}
$error = null;
if ($request->isMethod('POST') && $this->signed($request, 'website')) {
try {
$this->installation->createSite(
$request->request->getString('name'),
$request->request->getString('origin'),
$request->request->getString('locale', 'de'),
);
return $this->redirectToRoute('install_account');
} catch (InvalidPage|\RuntimeException $e) {
$error = $e->getMessage();
}
}
return $this->render('install/website.html.twig', [
'step' => 4,
'error' => $error,
'values' => $request->request->all(),
// The address the browser is on is almost always the right one, so it is prefilled.
'origin' => $request->getSchemeAndHttpHost(),
...$this->token('website'),
]);
}
#[Route('/install/konto', name: 'install_account', methods: ['GET', 'POST'], priority: 10)]
public function account(Request $request): Response
{
$this->guard();
if (!$this->installation->siteReady()) {
return $this->redirectToRoute('install_website');
}
$error = null;
if ($request->isMethod('POST') && $this->signed($request, 'konto')) {
try {
$this->installation->createAdmin(
$request->request->getString('username'),
$request->request->getString('password'),
$request->request->getString('repeat'),
);
// The last screen is rendered here, not after a redirect: one request later this
// route is gone, because the account it just created ends the installation.
return $this->render('install/done.html.twig', [
'step' => 6,
'username' => strtolower(trim($request->request->getString('username'))),
]);
} catch (InvalidPage $e) {
$error = $e->getMessage();
}
}
return $this->render('install/account.html.twig', [
'step' => 5,
'error' => $error,
'username' => $request->request->getString('username'),
...$this->token('konto'),
]);
}
/** Reading the pending list must never be the reason the screen cannot be shown. */
private function pending(): array
{
try {
return $this->installation->pendingMigrations();
} catch (\Throwable) {
return [];
}
}
private function guard(): void
{
if ($this->installation->isInstalled()) {
throw new NotFoundHttpException('Diese Installation ist abgeschlossen.');
}
}
private function token(string $step): array
{
$issued = time();
return ['issued' => $issued, 'signature' => $this->installation->sign($step, $issued)];
}
private function signed(Request $request, string $step): bool
{
return $this->installation->verify($step, $request->request->getInt('issued'), $request->request->getString('signature'));
}
}
+22 -6
View File
@@ -18,7 +18,7 @@ final class MediaController extends AbstractController
{
}
#[Route('/admin/media', name: 'admin_media', methods: ['GET'])]
#[Route('/%kant.admin_path%/media', name: 'admin_media', methods: ['GET'])]
public function index(Request $request): Response
{
// The screen renders the images itself, page by page. The dialog stays what it is: the way
@@ -31,7 +31,7 @@ final class MediaController extends AbstractController
]);
}
#[Route('/admin/media/upload', name: 'admin_media_upload', methods: ['POST'])]
#[Route('/%kant.admin_path%/media/upload', name: 'admin_media_upload', methods: ['POST'])]
public function formUpload(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
@@ -57,7 +57,7 @@ final class MediaController extends AbstractController
return $this->back($request, sprintf('%d Bild(er) hochgeladen.', $done), 'success');
}
#[Route('/admin/media/{id}', name: 'admin_media_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
#[Route('/%kant.admin_path%/media/{id}', name: 'admin_media_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
public function formUpdate(Request $request, string $id): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
@@ -95,7 +95,7 @@ final class MediaController extends AbstractController
return $this->redirectToRoute('admin_media', $query);
}
#[Route('/admin/api/media', methods: ['GET'])]
#[Route('/%kant.admin_path%/api/media', methods: ['GET'])]
public function list(): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
@@ -103,8 +103,8 @@ final class MediaController extends AbstractController
return $this->json(['items' => $this->media->list(), 'csrfToken' => $this->csrf->getToken('media')->getValue()]);
}
#[Route('/admin/api/media/upload', methods: ['POST'])]
#[Route('/admin/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
#[Route('/%kant.admin_path%/api/media/upload', methods: ['POST'])]
#[Route('/%kant.admin_path%/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
public function write(Request $request, ?string $id = null): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
@@ -141,6 +141,22 @@ final class MediaController extends AbstractController
}
}
/**
* Das Symbol der Website, in den beiden Größen, die Browser abfragen.
*
* Ohne eingestelltes Bild gibt es die Dateien nicht - dann bleibt es beim mitgelieferten
* favicon.svg, auf das die Seiten weiterhin verweisen.
*/
#[Route('/favicon.png', name: 'favicon_png', methods: ['GET'])]
#[Route('/apple-touch-icon.png', name: 'favicon_touch', methods: ['GET'])]
public function favicon(Request $request): Response
{
$size = 'favicon_touch' === $request->attributes->get('_route') ? 180 : 32;
$path = $this->media->faviconFile($size) ?? throw $this->createNotFoundException();
return new BinaryFileResponse($path, headers: ['Content-Type' => 'image/png', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=604800', 'Content-Security-Policy' => "default-src 'none'"]);
}
#[Route('/media/{id}/{width}', requirements: ['id' => '[a-f0-9]{32}', 'width' => '\d{1,4}'], methods: ['GET'])]
public function serve(string $id, int $width): Response
{
+2 -2
View File
@@ -17,7 +17,7 @@ final class MenuController extends AbstractController
{
}
#[Route('/admin/menus', name: 'admin_menus', methods: ['GET'])]
#[Route('/%kant.admin_path%/menus', name: 'admin_menus', methods: ['GET'])]
public function index(): Response
{
$menus = $this->menus->all();
@@ -39,7 +39,7 @@ final class MenuController extends AbstractController
]);
}
#[Route('/admin/menus/{slot}', name: 'admin_menu_save', methods: ['POST'])]
#[Route('/%kant.admin_path%/menus/{slot}', name: 'admin_menu_save', methods: ['POST'])]
public function save(Request $request, string $slot): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
+298 -15
View File
@@ -4,15 +4,19 @@ namespace App\Controller;
use App\Content\Consent;
use App\Content\CustomCode;
use App\Content\FontLibrary;
use App\Content\FormRepository;
use App\Content\MediaLibrary;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\PostRepository;
use App\Content\RedirectRepository;
use App\Content\SiteSettings;
use App\Content\StyleBundle;
use App\Content\StyleCompiler;
use App\Content\Theme;
use App\Content\Turnstile;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -20,7 +24,7 @@ use Symfony\Component\Routing\Attribute\Route;
final class PageController extends AbstractController
{
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, 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 PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin)
{
}
@@ -35,22 +39,32 @@ final class PageController extends AbstractController
$home = $this->settings->homePath();
$path = '' === $path ? $home : $path;
// One canonical address for the root page: its own path redirects to "/".
if ($path === $home && '/' !== $request->getPathInfo()) {
// Ausgenommen der Weg aus dem Formularversand: der zeigt dieselbe Seite mit Meldungen,
// und eine Umleitung wuerde genau die Meldungen verwerfen.
if ($path === $home && '/' !== $request->getPathInfo() && null === $request->attributes->get('kant_form_state')) {
return $this->redirectToRoute('home', status: 301);
}
$page = $this->pages->findPublished($path);
$styles = '';
$googleFonts = null;
$areas = $this->areas();
$localFonts = [];
// The page decides which header and footer it gets - including none.
$areas = $this->areas($page);
if (null !== $page) {
$theme = $this->theme->load();
// Theme first, block styles after, so a block always wins over the site defaults.
$sections = $this->sections($page['document'], $areas);
$styles = $this->theme->css($theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
$styles = $this->theme->css($theme).$this->scrollTopStyles($this->settings->load(), $theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
}
$googleFonts = $this->theme->googleFontsUrl($theme);
// Liegen die Schriften auf dem eigenen Server, wird nichts von Google geholt - weder
// die Datei noch die IP-Adresse des Besuchers.
if ($this->fonts->hasLocal($theme)) {
$localFonts = $this->fonts->preload($theme);
} else {
$googleFonts = $this->theme->googleFontsUrl($theme);
}
}
if (null === $page) {
// An address that moved or was deliberately removed answers before the 404 does.
@@ -64,24 +78,57 @@ final class PageController extends AbstractController
'page' => $page,
'canonical' => rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $home),
'navigation' => $this->navigation($path),
'postLists' => $this->postLists($page['document']),
'postLists' => $this->postLists($page['document'], max(1, $request->query->getInt('seite', 1))),
// Ohne Beiträge gibt es keinen Feed, auf den der Kopf verweisen könnte.
'hasPosts' => $this->posts->anyPublished(),
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null,
// Die Nachbarn kosten eine Abfrage; sie fällt nur an, wenn die Seite den
// Baustein wirklich enthält.
'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id']) : [],
'needsTabs' => $this->hasType($page['document'], 'tabs'),
// Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der
// dafür ein Skript braucht - nach demselben Muster wie die Tabs.
'needsSlider' => $this->hasAutoplaySlider($page['document']),
// Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die
// grosse Fassung. Das Skript kommt nur dazu, wo es etwas zu tun hat.
'needsZoom' => $this->hasZoom($page['document']) || (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasZoom($area)),
// Own scripts arrive as one file per page, never inline, so the policy can stay
// hash based and no page without a script loses its strict setting.
'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js',
'forms' => $this->formStates($page, $request->query->getString('gesendet')),
'forms' => $this->formStates($page, $request->query->getString('gesendet'), $request),
'breadcrumbs' => $this->breadcrumbs($page),
'socialImage' => $this->socialImage($page),
'structuredData' => $this->structuredData($page),
'areas' => $areas,
'sections' => $sections,
// Rendered before the page content, so the header decides first.
// Das grosse Bild ueber der Falz - es soll zuerst geladen werden. Gesucht wird nur
// im ersten Abschnitt des Inhalts: ein Bild weiter unten waere die falsche
// Eile, und das Logo im Kopfbereich ist klein und laedt ohnehin sofort.
'eagerImage' => $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]),
// Und das Gegenstueck fuer den haeufigeren Fall: Das grosse Bild ueber der Falz
// ist gar kein Bild, sondern der Hintergrund des ersten Abschnitts.
'heroBackground' => $this->heroBackground($areas['header'] ?? null, $page['document']),
'pageStyles' => $styles,
'googleFonts' => $googleFonts,
'localFonts' => $localFonts,
'styleBundle' => $this->bundle->url([] !== $localFonts),
// Steht die Rechtszeile als Baustein im Dokument oder in einem Bereich, entfaellt
// die eingebaute Leiste am Seitenende - zweimal dasselbe waere schlechter als gar
// nichts, und die Pflicht ist mit dem Baustein erfuellt.
'hasLegalBlock' => $this->hasType($page['document'], 'legal')
|| (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasType($area, 'legal')),
]);
}
$wantsScript = null !== $page && ($this->hasType($page['document'], 'tabs') || [] !== $this->code->scriptsOf($page['document']));
// Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur
// dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website.
$mitTurnstile = null !== $page && $this->turnstile->isActive() && $this->hasType($page['document'], 'form');
$wantsScript = null !== $page && ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $this->hasAutoplaySlider($page['document']) || $this->hasZoom($page['document']) || [] !== $this->code->scriptsOf($page['document']));
$frames = null === $page ? [] : $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
if ($mitTurnstile) {
$frames[] = 'challenges.cloudflare.com';
}
return $this->shell($response, $wantsScript, $styles, null !== $googleFonts, $frames);
}
@@ -114,9 +161,19 @@ final class PageController extends AbstractController
$response->headers->set('Cache-Control', 'no-store');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Diese Website braucht weder Kamera noch Mikrofon, Standort oder Bewegungssensoren. Das
// hier zu sagen kostet nichts und nimmt einem eingeschleusten Skript - oder einer
// eingebetteten fremden Seite - die Möglichkeit, danach zu fragen.
$response->headers->set('Permissions-Policy', 'accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()');
// Steht ein Rahmen von Cloudflare offen, dann gehoert dorthin auch das Skript, das ihn
// oeffnet - sonst laedt die Pruefung nicht und niemand kann das Formular absenden.
$scriptSources = $allowScript ? ["'self'"] : ["'none'"];
if (in_array('challenges.cloudflare.com', $frameHosts, true)) {
$scriptSources = ["'self'", Turnstile::HOST];
}
$response->headers->set('Content-Security-Policy', sprintf(
"default-src 'self'; script-src %s; style-src %s; font-src %s; img-src 'self' https: data:; frame-src %s; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
$allowScript ? "'self'" : "'none'",
implode(' ', $scriptSources),
implode(' ', $styleSources),
implode(' ', $fontSources),
[] === $frameHosts ? "'none'" : implode(' ', array_map(static fn (string $host): string => 'https://'.$host, $frameHosts)),
@@ -131,10 +188,20 @@ final class PageController extends AbstractController
*
* @return array<string, array>
*/
private function formStates(array $page, string $sentSlug): array
/**
* Der Zustand jedes Formulars auf dieser Seite.
*
* Kommt die Anfrage aus dem Formularversand zurueck, weil eine Eingabe fehlte, dann stehen die
* Meldungen und die eingetippten Werte schon fest und werden hier uebernommen. Genau deshalb
* laeuft dieser Fall durch dieselbe Stelle wie jeder andere Seitenaufruf: vorher baute der
* Formularcode die Seite selbst noch einmal auf - ohne Theme, ohne Schriften, ohne
* Stylesheet-Buendel. Wer ein Pflichtfeld vergass, bekam eine Seite ohne Gestaltung zu sehen.
*/
private function formStates(array $page, string $sentSlug, ?Request $request = null): array
{
$issued = time();
$states = [];
$uebergeben = is_array($request?->attributes->get('kant_form_state')) ? $request->attributes->get('kant_form_state') : [];
$walk = function (array $blocks) use (&$walk, &$states, $page, $sentSlug, $issued): void {
foreach ($blocks as $block) {
if ('form' === ($block['type'] ?? null)) {
@@ -146,12 +213,19 @@ final class PageController extends AbstractController
'blockId' => $block['id'], 'page' => $page,
'issued' => $issued,
'issuedSignature' => hash_hmac('sha256', (string) $issued, $this->secret),
// Leer, solange die Pruefung aus ist - dann steht auch kein Widget da.
'turnstileKey' => $this->turnstile->isActive() ? $this->turnstile->siteKey() : '',
];
}
$walk($block['children'] ?? []);
}
};
$walk($page['document']['blocks'] ?? []);
foreach ($uebergeben as $blockId => $zustand) {
if (isset($states[$blockId])) {
$states[$blockId] = [...$states[$blockId], ...$zustand];
}
}
return $states;
}
@@ -186,6 +260,76 @@ final class PageController extends AbstractController
*
* @return list<array>
*/
/**
* Die Gestaltungswerte des Knopfes „nach oben", als Variablen für das Stylesheet.
*
* Sie stehen in den Einstellungen und nicht im Theme, weil sie zur Website gehören und nicht
* zum Dokument - aber sie müssen in denselben Stilblock, dessen Prüfsumme in der
* Sicherheitsrichtlinie steht. Ein zweiter Block wäre ein zweiter Hash.
*/
private function scrollTopStyles(array $settings, array $theme): string
{
if (!($settings['scrollTop'] ?? false)) {
return '';
}
$farbe = static function (string $wert) use ($theme): ?string {
if ('' === $wert) {
return null;
}
if (!str_starts_with($wert, '@')) {
return $wert;
}
foreach ($theme['colors'] as $eintrag) {
if ($eintrag['key'] === substr($wert, 1)) {
return sprintf('var(--k-%s)', $eintrag['key']);
}
}
return null;
};
$werte = [sprintf('--kant-top-size:%dpx', (int) ($settings['scrollTopSize'] ?? 48))];
$werte[] = sprintf('--kant-top-radius:%s', match ($settings['scrollTopShape'] ?? 'rund') {
'eckig' => '0',
'weich' => '12px',
default => '999px',
});
$werte[] = sprintf('--kant-top-after:%dpx', (int) ($settings['scrollTopAfter'] ?? 400));
foreach (['scrollTopColor' => '--kant-top-bg', 'scrollTopInk' => '--kant-top-ink'] as $feld => $variable) {
$wert = $farbe((string) ($settings[$feld] ?? ''));
if (null !== $wert) {
$werte[] = $variable.':'.$wert;
}
}
return ':root{'.implode(';', $werte).'}';
}
/**
* Das Bild, das beim Teilen erscheint - und im strukturierten Datensatz.
*
* Zuerst das eigens gesetzte Vorschaubild, sonst bei einem Beitrag sein Beitragsbild. Dieses
* Feld gab es schon, ausgegeben wurde es nie: eine Einstellung, die speichert und nichts tut.
* Die Adresse muss absolut sein, weil sie in fremden Diensten landet, und die Variante nicht
* die größte - 1600 px reichen jedem Vorschaubild, 2400 kostet nur Ladezeit beim Teilen.
*
* @return array{url: string, alt: string}|null
*/
private function socialImage(array $page): ?array
{
$id = (string) ($page['og_media_id'] ?? '') ?: (string) ($page['featured_media_id'] ?? '');
if ('' === $id) {
return null;
}
$item = $this->media->find($id);
if (null === $item || [] === $item['variants']) {
return null;
}
$passend = array_filter($item['variants'], static fn (array $variant): bool => $variant['width'] <= 1600);
$variant = end($passend) ?: $item['variants'][0];
return ['url' => rtrim($this->siteOrigin, '/').$variant['src'], 'alt' => (string) ($item['alt'] ?? '')];
}
private function structuredData(array $page): array
{
$origin = rtrim($this->siteOrigin, '/');
@@ -211,6 +355,10 @@ final class PageController extends AbstractController
if (null !== ($page['author'] ?? null) && '' !== $page['author']) {
$article['author'] = ['@type' => 'Person', 'name' => $page['author']];
}
$bild = $this->socialImage($page);
if (null !== $bild) {
$article['image'] = $bild['url'];
}
$blocks[] = $article;
}
$trail = $this->breadcrumbs($page);
@@ -230,6 +378,42 @@ final class PageController extends AbstractController
}
/** True when the stored document contains at least one block of that type. */
/** Enthaelt das Dokument ein Bild oder eine Galerie, die sich vergroessern laesst? */
private function hasZoom(array $document): bool
{
$gefunden = false;
$walk = function (array $blocks) use (&$walk, &$gefunden): void {
foreach ($blocks as $block) {
if ('vergroessern' === ($block['props']['click'] ?? null) || true === ($block['props']['lightbox'] ?? null)) {
$gefunden = true;
return;
}
$walk($block['children'] ?? []);
}
};
$walk($document['blocks'] ?? []);
return $gefunden;
}
/** True when a slider on this page asked to advance by itself. */
private function hasAutoplaySlider(array $document): bool
{
$found = false;
$walk = function (array $blocks) use (&$walk, &$found): void {
foreach ($blocks as $block) {
if ('slider' === ($block['type'] ?? null) && ($block['props']['autoplay'] ?? 0) >= 3) {
$found = true;
}
$walk($block['children'] ?? []);
}
};
$walk($document['blocks'] ?? []);
return $found;
}
private function hasType(array $document, string $type): bool
{
$found = false;
@@ -259,6 +443,11 @@ final class PageController extends AbstractController
}
$page = max(1, $request->query->getInt('seite', 1));
$result = $this->posts->published($page, $term['id'] ?? null);
// Ein Archiv ohne Beitrag ist kein Inhalt, sondern eine Baustelle mit eigener Adresse -
// egal ob die Website noch gar keinen Blog hat oder ob diese eine Kategorie leer ist.
if (0 === $result['total']) {
return $this->shell($this->render('not-found.html.twig', ['navigation' => $this->navigation('')], new Response(status: 404)));
}
$base = null === $term ? '/beitraege' : '/'.PostRepository::ARCHIVE_PREFIX[$kind].'/'.$term['slug'];
$frames = $this->frameHosts($request, ...array_values($this->areas()));
@@ -278,6 +467,9 @@ final class PageController extends AbstractController
#[Route('/feed.xml', name: 'feed', methods: ['GET'])]
public function feed(): Response
{
if (!$this->posts->anyPublished()) {
return $this->shell($this->render('not-found.html.twig', ['navigation' => $this->navigation('')], new Response(status: 404)));
}
$result = $this->posts->published(1, null, 20);
$response = $this->shell($this->render('feed.xml.twig', [
'posts' => $result['posts'],
@@ -294,10 +486,10 @@ final class PageController extends AbstractController
*
* @return array<string, list<array>>
*/
private function postLists(array $document): array
private function postLists(array $document, int $page = 1): array
{
$lists = [];
$walk = function (array $blocks) use (&$walk, &$lists): void {
$walk = function (array $blocks) use (&$walk, &$lists, $page): void {
foreach ($blocks as $block) {
if ('postlist' === ($block['type'] ?? null)) {
$props = $block['props'] ?? [];
@@ -307,7 +499,12 @@ final class PageController extends AbstractController
$term = $this->posts->findTerm($kind, $props[$kind]);
}
}
$lists[$block['id']] = $this->posts->published(1, $term['id'] ?? null, $props['count'])['posts'];
// Eine blätternde Liste nimmt die Seite aus der Anfrage, eine Auswahl
// immer die erste - sonst würden zwei Listen auf einer Seite einander
// umblättern.
$paginate = (bool) ($props['paginate'] ?? false);
$result = $this->posts->published($paginate ? $page : 1, $term['id'] ?? null, $props['count']);
$lists[$block['id']] = $paginate ? $result : $result['posts'];
}
$walk($block['children'] ?? []);
}
@@ -339,6 +536,82 @@ 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.
*/
/**
* Das Hintergrundbild des ersten Abschnitts, um es vorab zu holen.
*
* Gemessen an der Startseite des Piloten: Das groesste Element ueber der Falz ist dort kein
* `<img>`, sondern der Hintergrund des Heros. Ein Hintergrundbild steht im Stylesheet, und
* das kommt nach dem HTML - der Browser erfaehrt also erst vom Bild, wenn er das CSS geparst
* hat. Lighthouse nennt das „LCP request discovery"; gemessen 1,3 s statt 0,9 s.
*
* Vorab geholt wird **genau die Variante, die das Stylesheet auch nimmt**: drei Verweise mit
* denselben Grenzen wie der Compiler, nicht ein `imagesrcset`. Ein `srcset` waehlt nach
* Breite, die Regeln waehlen nach Geraet; wo beide auseinanderlaufen, laedt der Browser zwei
* Bilder statt einem - und dann kostet das Vorabholen mehr, als es bringt.
*
* @return list<array{src: string, media: string}>
*/
private function heroBackground(?array $header, array $document): array
{
// Steht ueber dem Inhalt ein Kopfbereich mit eigenem Hintergrundbild, ist dessen Bild das
// obere. Sonst der erste Abschnitt des Dokuments.
foreach ([$header, $document] as $kandidat) {
$block = ($kandidat['blocks'] ?? [])[0] ?? null;
$id = $block['style']['desktop']['backgroundImage'] ?? null;
if (!is_string($id) || '' === $id) {
continue;
}
$item = $this->media->find($id);
if (null === $item || [] === ($item['variants'] ?? [])) {
continue;
}
$verweise = [];
foreach (StyleCompiler::PRELOAD_DEVICES as $device => $media) {
$verweise[] = [
'src' => StyleCompiler::variantFor($item['variants'], $device)['src'],
'media' => $media,
];
}
return $verweise;
}
return [];
}
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.
*
@@ -374,11 +647,21 @@ final class PageController extends AbstractController
*
* @return array<string, array>
*/
private function areas(): array
private function areas(?array $page = null): array
{
$areas = [];
foreach (['header', 'footer'] as $kind) {
$partial = $this->partials->findByKind($kind);
// A page may ask for another area or for none at all: a landing page without a
// navigation is a normal wish, and it must not need a second header document that
// happens to be empty.
$choice = $page[$kind.'_area'] ?? null;
if ('none' === $choice) {
continue;
}
$partial = $this->partials->findByKind($kind, is_string($choice) && '' !== $choice ? $choice : 'default');
// A page pointing at an area that no longer exists falls back to the standard one
// rather than losing its header without saying so.
$partial ??= $this->partials->findByKind($kind);
if (null !== $partial) {
$areas[$kind] = $partial['document'];
}
+1 -1
View File
@@ -25,7 +25,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
* against areas without a second implementation. Areas have no draft and no publication: they are
* shared, so a saved change is live. The editor hides the publish button for them.
*/
#[Route('/admin/partials')]
#[Route('/%kant.admin_path%/partials')]
final class PartialController extends AbstractController
{
public function __construct(
+2 -2
View File
@@ -9,7 +9,7 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
final class SecurityController extends AbstractController
{
#[Route('/admin/login', name: 'admin_login', methods: ['GET', 'POST'])]
#[Route('/%kant.admin_path%/login', name: 'admin_login', methods: ['GET', 'POST'])]
public function login(AuthenticationUtils $auth): Response
{
if ($this->getUser()) {
@@ -19,7 +19,7 @@ final class SecurityController extends AbstractController
return $this->render('admin/login.html.twig', ['error' => $auth->getLastAuthenticationError(), 'username' => $auth->getLastUsername()]);
}
#[Route('/admin/logout', name: 'admin_logout', methods: ['POST'])]
#[Route('/%kant.admin_path%/logout', name: 'admin_logout', methods: ['POST'])]
public function logout(): never
{
throw new \LogicException('Handled by Symfony Security.');
+10 -1
View File
@@ -21,6 +21,7 @@ final class SeoController extends AbstractController
private SiteSettings $settings,
private PostRepository $posts,
private string $siteOrigin,
private string $adminPath,
) {
}
@@ -58,7 +59,15 @@ final class SeoController extends AbstractController
public function robots(): Response
{
$settings = $this->settings->load();
$lines = ['User-agent: *', 'Disallow: /admin', 'Disallow: /formular/'];
// Die Verwaltung wird hier nur genannt, solange sie „admin" heisst - diese Adresse kennt
// ohnehin jeder Scanner. Wer sie umbenannt hat, will sie nicht in einer oeffentlichen
// Datei wiederfinden: robots.txt ist das erste, was ein Angreifer liest. Vom Indexieren
// haelt die Verwaltung ihr eigener Kopf `X-Robots-Tag: noindex` ab.
$lines = ['User-agent: *'];
if ('admin' === $this->adminPath) {
$lines[] = 'Disallow: /admin';
}
$lines[] = 'Disallow: /formular/';
if ('' !== $settings['robotsExtra']) {
$lines[] = $settings['robotsExtra'];
}
+13 -7
View File
@@ -4,9 +4,11 @@ namespace App\Controller;
use App\Content\Consent;
use App\Content\DemoContent;
use App\Content\MediaLibrary;
use App\Content\PageRepository;
use App\Content\RedirectRepository;
use App\Content\SiteSettings;
use App\Content\Theme;
use App\Domain\InvalidPage;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -15,12 +17,12 @@ use Symfony\Component\Routing\Attribute\Route;
final class SettingsController extends AbstractController
{
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages)
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private MediaLibrary $media, private Theme $theme)
{
}
#[Route('/admin/settings', name: 'admin_settings', methods: ['GET'])]
public function index(): Response
#[Route('/%kant.admin_path%/settings', name: 'admin_settings', methods: ['GET'])]
public function index(Request $request): Response
{
$choosable = [];
foreach ($this->pages->listForEditor() as $page) {
@@ -33,15 +35,19 @@ 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(),
'consentGroupHelp' => Consent::GROUP_HELP,
// Für das Symbol der Website: die Mediathek zur Auswahl, die neuesten zuerst.
'images' => $this->media->page(1, false, 96)['items'],
// Fuer die Farbfelder: dieselbe Palette, die auch der Editor anbietet.
'palette' => $this->theme->load()['colors'],
]);
}
#[Route('/admin/settings/company', name: 'admin_settings_save', methods: ['POST'])]
#[Route('/%kant.admin_path%/settings/company', name: 'admin_settings_save', methods: ['POST'])]
public function save(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
@@ -57,7 +63,7 @@ final class SettingsController extends AbstractController
return $this->back('Die Unternehmensdaten wurden gespeichert.', 'success');
}
#[Route('/admin/settings/redirects', name: 'admin_redirect_save', methods: ['POST'])]
#[Route('/%kant.admin_path%/settings/redirects', name: 'admin_redirect_save', methods: ['POST'])]
public function saveRedirect(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
@@ -89,7 +95,7 @@ final class SettingsController extends AbstractController
* The pages are mandatory, the text is not something a CMS can write: what gets created is the
* structure with every required item named and marked as still to be filled in.
*/
#[Route('/admin/settings/rechtliches', name: 'admin_legal_pages', methods: ['POST'])]
#[Route('/%kant.admin_path%/settings/rechtliches', name: 'admin_legal_pages', methods: ['POST'])]
public function legalPages(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
+2 -2
View File
@@ -17,7 +17,7 @@ final class StatisticsController extends AbstractController
{
}
#[Route('/admin/statistik', name: 'admin_statistics', methods: ['GET'])]
#[Route('/%kant.admin_path%/statistik', name: 'admin_statistics', methods: ['GET'])]
public function index(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
@@ -32,7 +32,7 @@ final class StatisticsController extends AbstractController
]);
}
#[Route('/admin/statistik.csv', name: 'admin_statistics_csv', methods: ['GET'])]
#[Route('/%kant.admin_path%/statistik.csv', name: 'admin_statistics_csv', methods: ['GET'])]
public function csv(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
+129 -4
View File
@@ -2,6 +2,10 @@
namespace App\Controller;
use App\Domain\InvalidPage;
use App\Ops\ArchiveUpdate;
use App\Ops\Backup;
use App\Ops\Installation;
use App\Ops\Updater;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -17,11 +21,11 @@ use Symfony\Component\Routing\Attribute\Route;
*/
final class SystemController extends AbstractController
{
public function __construct(private Updater $updater)
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath)
{
}
#[Route('/admin/system', name: 'admin_system', methods: ['GET'])]
#[Route('/%kant.admin_path%/system', name: 'admin_system', methods: ['GET'])]
public function index(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
@@ -35,10 +39,95 @@ final class SystemController extends AbstractController
'available' => $request->query->getBoolean('pruefen') ? $this->updater->availableVersion() : null,
'asked' => $request->query->getBoolean('pruefen'),
'steps' => $request->getSession()->remove('update_steps') ?: [],
'backups' => $this->backup->all(),
'adminPath' => $this->adminPath,
// Der Weg ohne Git: das Releasearchiv. Auf geteiltem Hosting ist das der einzige.
'archiveVersion' => $request->query->getBoolean('pruefen') ? $this->archive->updateAvailable() : null,
'archivePending' => $this->archive->pending(),
// Wer die Dateien von Hand hochgeladen hat - über FTP, ohne Git und ohne das
// Releasearchiv -, hat danach neuen Code und eine alte Datenbank. Dieser Bildschirm
// ist die einzige Stelle, an der das jemand merken kann.
'pendingMigrations' => $this->pendingMigrations(),
]);
}
#[Route('/admin/system/update', name: 'admin_system_update', methods: ['POST'])]
/**
* Update über das Releasearchiv - für Installationen, die kein Git kennen.
*
* Zwei Schritte, und zwar mit Absicht: Der erste ersetzt die Dateien, der zweite führt die
* Migrationen aus. Dazwischen liegt eine neue Anfrage, denn erst die startet mit dem Code der
* neuen Fassung; ein Prozess, der sich selbst ausgetauscht hat, arbeitet mit dem alten weiter.
*/
/**
* Ausstehende Migrationen ausführen - ohne Git, ohne Archiv, ohne Konsole.
*
* Der Weg für ein Update, das von Hand eingespielt wurde: Dateien liegen neu, die Datenbank
* ist alt. Ohne diesen Knopf bräuchte es eine Konsole, und genau die gibt es auf geteiltem
* Hosting nicht.
*/
#[Route('/%kant.admin_path%/system/migrationen', name: 'admin_system_migrate', methods: ['POST'])]
public function migrate(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
@set_time_limit(0);
try {
$namen = $this->installation->migrate();
$this->addFlash('success', [] === $namen
? 'Es stand keine Migration an - die Datenbank war bereits auf dem Stand des Codes.'
: sprintf('%d Migration(en) ausgeführt: %s.', count($namen), implode(', ', $namen)));
} catch (\Throwable $fehler) {
$this->addFlash('error', 'Die Migration ist fehlgeschlagen: '.$fehler->getMessage());
}
return $this->redirectToRoute('admin_system');
}
/** Das Lesen der Liste darf nie der Grund sein, dass dieser Bildschirm nicht erscheint. */
private function pendingMigrations(): array
{
try {
return $this->installation->pendingMigrations();
} catch (\Throwable) {
return [];
}
}
#[Route('/%kant.admin_path%/system/archiv', name: 'admin_system_archive', methods: ['POST'])]
public function archive(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
@set_time_limit(0);
if ($request->request->getBoolean('finish')) {
$ergebnis = $this->archive->finish($this->installation);
$request->getSession()->set('update_steps', $ergebnis['steps']);
$this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok']
? 'Das Update ist fertig. Die Website ist wieder erreichbar.'
: 'Die Migrationen sind fehlgeschlagen. Die Website bleibt im Wartungszustand.');
return $this->redirectToRoute('admin_system');
}
$ergebnis = $this->archive->run($request->request->getString('target') ?: null);
$request->getSession()->set('update_steps', $ergebnis['steps']);
if ($ergebnis['migrate'] ?? false) {
$this->addFlash('success', 'Die Dateien sind ersetzt. Jetzt noch die Migrationen - der Knopf steht oben.');
return $this->redirectToRoute('admin_system');
}
$this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok']
? 'Es war nichts einzuspielen.'
: 'Das Update ist nicht durchgelaufen. Die Schritte stehen unten.');
return $this->redirectToRoute('admin_system');
}
#[Route('/%kant.admin_path%/system/update', name: 'admin_system_update', methods: ['POST'])]
public function update(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
@@ -61,7 +150,43 @@ final class SystemController extends AbstractController
return $this->redirectToRoute('admin_system');
}
#[Route('/admin/system/wartung', name: 'admin_system_maintenance', methods: ['POST'])]
/**
* Creating a backup is safe, so it has a button. Putting one back is not, so it has none -
* `cms:restore` is the way, and the list says which backups exist and whether their checksum
* still matches.
*/
#[Route('/%kant.admin_path%/system/sicherung', name: 'admin_system_backup', methods: ['POST'])]
public function backup(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
@set_time_limit(0);
try {
$result = $this->backup->create('Verwaltung');
} catch (InvalidPage $error) {
return $this->back($error->getMessage());
} catch (\Throwable $error) {
// Alles andere ist kein erwarteter Fall, sondern einer, den dieses Hosting mitbringt -
// eine alte SQLite-Fassung, ein fehlendes Schreibrecht, ein voller Datentraeger. Eine
// 500-Seite laesst den Betreiber damit allein; der Satz hier sagt wenigstens, woran es
// lag.
return $this->back('Die Sicherung ist fehlgeschlagen: '.$error->getMessage());
}
$removed = $this->backup->rotate();
return $this->back(sprintf(
'%s angelegt: %d Seite(n), %d Fassung(en), %d Mediendatei(en).%s',
$result['name'],
$result['manifest']['inhalt']['seiten'],
$result['manifest']['inhalt']['fassungen'],
$result['manifest']['medien']['dateien'],
[] === $removed ? '' : ' Ältere entfernt: '.implode(', ', $removed).'.',
), 'success');
}
#[Route('/%kant.admin_path%/system/wartung', name: 'admin_system_maintenance', methods: ['POST'])]
public function maintenance(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
+44 -4
View File
@@ -2,6 +2,7 @@
namespace App\Controller;
use App\Content\FontLibrary;
use App\Content\Theme;
use App\Domain\InvalidPage;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -11,21 +12,60 @@ use Symfony\Component\Routing\Attribute\Route;
final class ThemeController extends AbstractController
{
public function __construct(private Theme $theme)
public function __construct(private Theme $theme, private FontLibrary $fonts)
{
}
#[Route('/admin/theme', name: 'admin_theme', methods: ['GET'])]
#[Route('/%kant.admin_path%/theme', name: 'admin_theme', methods: ['GET'])]
public function index(): Response
{
$theme = $this->theme->load();
return $this->render('admin/theme.html.twig', [
'theme' => $this->theme->load(),
'theme' => $theme,
'fallbacks' => Theme::FALLBACKS,
'weights' => Theme::WEIGHTS,
'fontsLocal' => $this->fonts->hasLocal($theme),
'fontsMissing' => $this->fonts->missing($theme),
]);
}
#[Route('/admin/theme/save', name: 'admin_theme_save', methods: ['POST'])]
/**
* Holt die gewaehlten Google-Schriften auf den eigenen Server.
*
* Auf Zuruf, nicht bei einem Seitenaufruf: das dauert Sekunden und braucht eine Verbindung
* nach aussen, die ein Besucher nie abwarten soll.
*/
#[Route('/%kant.admin_path%/theme/fonts', name: 'admin_theme_fonts', methods: ['POST'])]
public function fonts(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('theme', $request->request->getString('_csrf_token'))) {
$this->addFlash('error', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
return $this->redirectToRoute('admin_theme');
}
try {
$result = $this->fonts->fetch($this->theme->load());
$this->addFlash('success', sprintf(
'%d Schriftdatei(en) geladen, %s insgesamt: %s. Die Website lädt jetzt nichts mehr von Google.',
$result['files'],
$this->bytes($result['bytes']),
implode(', ', $result['families']),
));
} catch (\RuntimeException $error) {
$this->addFlash('error', $error->getMessage());
}
return $this->redirectToRoute('admin_theme');
}
private function bytes(int $bytes): string
{
return $bytes < 1024 * 1024 ? round($bytes / 1024).' KB' : round($bytes / 1024 / 1024, 1).' MB';
}
#[Route('/%kant.admin_path%/theme/save', name: 'admin_theme_save', methods: ['POST'])]
public function save(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
+7 -7
View File
@@ -22,7 +22,7 @@ final class UserController extends AbstractController
{
}
#[Route('/admin/benutzer', name: 'admin_users', methods: ['GET'])]
#[Route('/%kant.admin_path%/benutzer', name: 'admin_users', methods: ['GET'])]
public function index(): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
@@ -37,7 +37,7 @@ final class UserController extends AbstractController
]);
}
#[Route('/admin/benutzer/neu', name: 'admin_user_create', methods: ['POST'])]
#[Route('/%kant.admin_path%/benutzer/neu', name: 'admin_user_create', methods: ['POST'])]
public function create(Request $request): Response
{
if (null !== $refusal = $this->guard($request)) {
@@ -56,7 +56,7 @@ final class UserController extends AbstractController
return $this->back('Das Konto wurde angelegt.', 'success');
}
#[Route('/admin/benutzer/{id}/rolle', name: 'admin_user_role', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['POST'])]
#[Route('/%kant.admin_path%/benutzer/{id}/rolle', name: 'admin_user_role', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['POST'])]
public function role(Request $request, string $id): Response
{
if (null !== $refusal = $this->guard($request)) {
@@ -71,7 +71,7 @@ final class UserController extends AbstractController
return $this->back('Die Rolle wurde geändert. Offene Sitzungen dieses Kontos sind damit abgemeldet.', 'success');
}
#[Route('/admin/benutzer/{id}/passwort', name: 'admin_user_password', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['POST'])]
#[Route('/%kant.admin_path%/benutzer/{id}/passwort', name: 'admin_user_password', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['POST'])]
public function password(Request $request, string $id): Response
{
if (null !== $refusal = $this->guard($request)) {
@@ -86,7 +86,7 @@ final class UserController extends AbstractController
return $this->back('Das Passwort wurde gesetzt. Offene Sitzungen dieses Kontos sind damit abgemeldet.', 'success');
}
#[Route('/admin/benutzer/{id}/loeschen', name: 'admin_user_delete', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['POST'])]
#[Route('/%kant.admin_path%/benutzer/{id}/loeschen', name: 'admin_user_delete', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['POST'])]
public function delete(Request $request, string $id): Response
{
if (null !== $refusal = $this->guard($request)) {
@@ -101,7 +101,7 @@ final class UserController extends AbstractController
return $this->back('Das Konto wurde gelöscht.', 'success');
}
#[Route('/admin/konto', name: 'admin_account', methods: ['GET'])]
#[Route('/%kant.admin_path%/konto', name: 'admin_account', methods: ['GET'])]
public function account(): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
@@ -122,7 +122,7 @@ final class UserController extends AbstractController
]);
}
#[Route('/admin/konto/passwort', name: 'admin_account_password', methods: ['POST'])]
#[Route('/%kant.admin_path%/konto/passwort', name: 'admin_account_password', methods: ['POST'])]
public function ownPassword(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
@@ -8,6 +8,10 @@ use Symfony\Component\HttpKernel\KernelEvents;
final class AdminHeadersSubscriber implements EventSubscriberInterface
{
public function __construct(private string $adminPath)
{
}
public static function getSubscribedEvents(): array
{
return [KernelEvents::RESPONSE => ['onResponse', -300]];
@@ -15,7 +19,7 @@ final class AdminHeadersSubscriber implements EventSubscriberInterface
public function onResponse(ResponseEvent $event): void
{
if (!preg_match('#^/admin(?:/|$)#', $event->getRequest()->getPathInfo())) {
if (!preg_match('#^/'.preg_quote($this->adminPath, '#').'(?:/|$)#', $event->getRequest()->getPathInfo())) {
return;
}
$headers = $event->getResponse()->headers;
@@ -23,6 +27,7 @@ final class AdminHeadersSubscriber implements EventSubscriberInterface
$headers->set('X-Robots-Tag', 'noindex, nofollow');
$headers->set('X-Content-Type-Options', 'nosniff');
$headers->set('Referrer-Policy', 'same-origin');
$headers->set('Permissions-Policy', 'accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()');
// The preview must show the same typeface as the published page, so the font hosts are
// allowed here as well. Everything else stays as narrow as on the public side.
$headers->set('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\EventSubscriber;
use App\Ops\Installation;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* An installation that does not exist yet answers with the installer instead of an error.
*
* Without this, the first visit to a freshly unpacked website is a 500 from a missing table - the
* one moment where a person needs a sentence, not a stack trace.
*
* The cost of knowing is kept at zero for a running website: once an account exists, a marker file
* is written and no request asks the database again. The marker only ever silences this
* subscriber; the installer itself always asks the database, so deleting the file does not reopen
* anything.
*/
final class InstallSubscriber implements EventSubscriberInterface
{
public const MARKER = 'var/installed';
public function __construct(private Installation $installation, private string $projectDir)
{
}
public static function getSubscribedEvents(): array
{
// After maintenance (40), before the router: an unfinished installation has no routes
// worth matching except its own.
return [KernelEvents::REQUEST => ['onRequest', 36]];
}
public function onRequest(RequestEvent $event): void
{
if (!$event->isMainRequest() || is_file($this->projectDir.'/'.self::MARKER)) {
return;
}
if ($this->installation->isInstalled()) {
@file_put_contents($this->projectDir.'/'.self::MARKER, gmdate('c')."\n");
return;
}
$path = $event->getRequest()->getPathInfo();
if (str_starts_with($path, '/install') || preg_match('#^/(assets/|favicon)#', $path)) {
return;
}
$event->setResponse(new RedirectResponse('/install'));
}
}
@@ -19,7 +19,7 @@ use Twig\Environment;
*/
final class MaintenanceSubscriber implements EventSubscriberInterface
{
public function __construct(private Environment $twig, private string $projectDir)
public function __construct(private Environment $twig, private string $projectDir, private string $adminPath)
{
}
@@ -35,7 +35,7 @@ final class MaintenanceSubscriber implements EventSubscriberInterface
return;
}
$path = $event->getRequest()->getPathInfo();
if (preg_match('#^/(admin|assets/|media/|favicon)#', $path)) {
if (preg_match('#^/('.preg_quote($this->adminPath, '#').'|assets/|media/|favicon)#', $path)) {
return;
}
$since = @file_get_contents($this->projectDir.'/'.Updater::MAINTENANCE_FILE);
@@ -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);
}
};
}
};
}
}
+359
View File
@@ -0,0 +1,359 @@
<?php
namespace App\Ops;
/**
* Ein Update für Installationen ohne Git.
*
* Der Updater über Git setzt eine Arbeitskopie voraus. Auf geteiltem Hosting gibt es die nicht:
* dort wird über das Releasearchiv installiert, und danach steht in der Verwaltung „Dieses
* Verzeichnis ist keine Git-Arbeitskopie" - also kein Update, außer von Hand über FTP. Das ist
* genau der Fall, für den dieses CMS gedacht ist.
*
* Hier läuft derselbe Weg wie bei der Erstinstallation: Archiv laden, Prüfsumme vergleichen,
* entpacken, Dateien ersetzen, Migrationen ausführen. Mit drei Unterschieden, die ein Update von
* einer Installation trennen:
*
* 1. **Was dem Betreiber gehört, wird nicht angefasst.** `var/`, `.env`, `.env.local`, die
* hochgeladenen Schriften und das erzeugte Stylesheet-Bündel bleiben, wie sie sind.
* 2. **Der alte Stand wird beiseitegelegt, nicht gelöscht.** Geht etwas schief, steht er unter
* `var/update/` und lässt sich zurückschieben.
* 3. **Die Website ist währenddessen im Wartungszustand** - eine halb ersetzte Anwendung darf
* niemand zu sehen bekommen.
*/
final class ArchiveUpdate
{
/** Was aus dem Archiv übernommen wird. Alles andere im Projektstamm bleibt unberührt. */
private const ERSETZEN = ['bin', 'config', 'migrations', 'src', 'templates', 'vendor', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION'];
/** Was innerhalb von public/ dem Betreiber gehört und deshalb stehen bleibt. */
private const EIGENES_IN_PUBLIC = ['assets/fonts', 'assets/build'];
/**
* Die Marke zwischen den beiden Schritten.
*
* Sie liegt auf der Platte und nicht in der Sitzung: Fällt die Sitzung zwischen „Dateien
* ersetzt" und „Migrationen" aus - Zeitüberschreitung, anderer Browser, anderer Rechner -,
* bliebe die Website sonst im Wartungszustand und der Knopf, der sie herausholt, wäre weg.
*/
private const MARKE = 'var/update/offen.json';
/** Das Protokoll - dieselbe Datei, in die auch der Updater über Git schreibt. */
private const PROTOKOLL = 'var/updates.log';
public function __construct(
private string $root,
private Updater $updater,
private Backup $backup,
private Download $download,
private string $releaseBase = 'https://git.kant-it.de/kantarci/KantSites/releases/download',
private string $apiBase = 'https://git.kant-it.de/api/v1/repos/kantarci/KantSites',
) {
}
/**
* Die neueste veröffentlichte Fassung, oder null wenn sie sich nicht ermitteln lässt.
*
* @return array{version: string, url: string, checksum: ?string}|null
*/
public function latest(): ?array
{
$antwort = $this->download->get($this->apiBase.'/releases/latest');
if (null === $antwort) {
return null;
}
try {
$daten = json_decode($antwort, true, 16, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
$tag = (string) ($daten['tag_name'] ?? '');
if (1 !== preg_match('/^v(\d+\.\d+\.\d+)$/D', $tag, $treffer)) {
return null;
}
$version = $treffer[1];
return [
'version' => $version,
'url' => sprintf('%s/%s/kantsites-%s.zip', $this->releaseBase, $tag, $version),
'checksum' => $this->checksumOf(sprintf('%s/%s/kantsites-%s.zip.sha256', $this->releaseBase, $tag, $version)),
];
}
/** Gibt es etwas Neueres als das, was hier läuft? */
public function updateAvailable(): ?string
{
$neueste = $this->latest();
return null !== $neueste && version_compare($neueste['version'], $this->updater->version(), '>')
? $neueste['version']
: null;
}
/**
* Spielt die angegebene (oder die neueste) Fassung ein.
*
* @return array{ok: bool, steps: list<array{step: string, ok: bool, detail: string}>}
*/
public function run(?string $version = null): array
{
$schritte = [];
// Jeder Schritt geht sofort ins Protokoll, nicht erst am Ende.
//
// Der Grund steht in ADR 071: Wenn die Seite, die das Ergebnis anzeigt, nicht erscheint -
// aus welchem Grund auch immer -, weiß niemand mehr, was passiert ist. Eine Zeile in
// einer Datei überlebt das, und auf einem Hosting ohne Konsole ist sie manchmal das
// Einzige, was von einem gescheiterten Update übrig bleibt.
$melden = function (array &$schritte, string $schritt, bool $ok, string $detail = ''): bool {
$schritte[] = ['step' => $schritt, 'ok' => $ok, 'detail' => $detail];
$this->protokoll(sprintf('%s %s: %s', $ok ? 'ok ' : 'FEHLER', $schritt, $detail));
return $ok;
};
$this->protokoll(sprintf('--- Update aus dem Archiv, %s UTC', gmdate('Y-m-d H:i:s')));
$neueste = $this->latest();
if (null === $neueste) {
$melden($schritte, 'Fassung ermitteln', false, trim('Die Releaseseite antwortet nicht. Ohne sie ist nicht feststellbar, was aktuell ist. '.$this->download->reason()));
return ['ok' => false, 'steps' => $schritte];
}
$ziel = $version ?? $neueste['version'];
$melden($schritte, 'Fassung ermitteln', true, sprintf('Installiert: %s, verfügbar: %s.', $this->updater->version(), $neueste['version']));
if (version_compare($ziel, $this->updater->version(), '<=')) {
$melden($schritte, 'Nichts zu tun', true, 'Diese Installation ist bereits auf dem neuesten Stand.');
return ['ok' => true, 'steps' => $schritte];
}
$arbeit = $this->root.'/var/update/'.gmdate('Ymd-His');
if (!@mkdir($arbeit, 0o775, true) && !is_dir($arbeit)) {
$melden($schritte, 'Arbeitsordner', false, $arbeit.' ließ sich nicht anlegen. Fehlt das Schreibrecht auf var/?');
return ['ok' => false, 'steps' => $schritte];
}
// Erst sichern, dann anfassen. Eine Sicherung nach dem Ersetzen wäre keine.
try {
$sicherung = $this->backup->create('vor dem Update');
$melden($schritte, 'Sicherung', true, sprintf('Datenbank und Medien liegen unter var/backups/%s.', (string) ($sicherung['name'] ?? 'sicherung')));
} catch (\Throwable $fehler) {
$melden($schritte, 'Sicherung', false, 'Die Sicherung ist fehlgeschlagen: '.$fehler->getMessage().' Ohne sie wird nichts ersetzt.');
return ['ok' => false, 'steps' => $schritte];
}
$archiv = $arbeit.'/kantsites-'.$ziel.'.zip';
$inhalt = $this->download->get(sprintf('%s/v%s/kantsites-%s.zip', $this->releaseBase, $ziel, $ziel));
if (null === $inhalt || '' === $inhalt) {
$melden($schritte, 'Archiv laden', false, trim('Das Archiv ließ sich nicht herunterladen. '.$this->download->reason()));
return ['ok' => false, 'steps' => $schritte];
}
file_put_contents($archiv, $inhalt);
$melden($schritte, 'Archiv laden', true, sprintf('%d KB geladen.', (int) round(strlen($inhalt) / 1024)));
$erwartet = $ziel === $neueste['version'] ? $neueste['checksum'] : $this->checksumOf(sprintf('%s/v%s/kantsites-%s.zip.sha256', $this->releaseBase, $ziel, $ziel));
if (null === $erwartet) {
$melden($schritte, 'Prüfsumme', false, 'Zu diesem Archiv gibt es keine Prüfsumme. Ohne sie wird nichts entpackt.');
return ['ok' => false, 'steps' => $schritte];
}
$tatsaechlich = hash_file('sha256', $archiv);
if (!hash_equals($erwartet, (string) $tatsaechlich)) {
$melden($schritte, 'Prüfsumme', false, 'Die Prüfsumme stimmt nicht. Das Archiv wird nicht verwendet.');
return ['ok' => false, 'steps' => $schritte];
}
$melden($schritte, 'Prüfsumme', true, 'Stimmt mit der Angabe auf der Releaseseite überein.');
$entpackt = $arbeit.'/neu';
$zip = new \ZipArchive();
if (true !== $zip->open($archiv) || !$zip->extractTo($entpackt)) {
$melden($schritte, 'Entpacken', false, 'Das Archiv ließ sich nicht entpacken.');
return ['ok' => false, 'steps' => $schritte];
}
$zip->close();
$quelle = $entpackt.'/kantsites-'.$ziel;
if (!is_file($quelle.'/VERSION') || !is_file($quelle.'/vendor/autoload.php')) {
$melden($schritte, 'Entpacken', false, 'Im Archiv fehlen VERSION oder vendor/ - das ist kein vollständiges Release.');
return ['ok' => false, 'steps' => $schritte];
}
$melden($schritte, 'Entpacken', true, 'Vollständig: VERSION und vendor/ sind vorhanden.');
$this->updater->maintenance(true);
$melden($schritte, 'Wartungszustand', true, 'Die Website antwortet währenddessen mit 503.');
$alt = $arbeit.'/alt';
@mkdir($alt, 0o775, true);
$ersetzt = 0;
try {
foreach (self::ERSETZEN as $eintrag) {
$ziant = $this->root.'/'.$eintrag;
if (file_exists($ziant) && !@rename($ziant, $alt.'/'.$eintrag)) {
throw new \RuntimeException($eintrag.' ließ sich nicht beiseitelegen.');
}
if (file_exists($quelle.'/'.$eintrag) && !@rename($quelle.'/'.$eintrag, $ziant)) {
throw new \RuntimeException($eintrag.' ließ sich nicht ersetzen.');
}
++$ersetzt;
}
// public/ wird Datei für Datei aktualisiert: Schriften und erzeugte Bündel gehören dem
// Betreiber und wären sonst weg.
$this->mergePublic($quelle.'/public', $this->root.'/public');
} catch (\Throwable $fehler) {
foreach (self::ERSETZEN as $eintrag) {
if (is_dir($alt.'/'.$eintrag) || is_file($alt.'/'.$eintrag)) {
@rename($this->root.'/'.$eintrag, $quelle.'/'.$eintrag);
@rename($alt.'/'.$eintrag, $this->root.'/'.$eintrag);
}
}
$melden($schritte, 'Dateien ersetzen', false, $fehler->getMessage().' Der alte Stand wurde zurückgeschoben.');
$this->updater->maintenance(false);
return ['ok' => false, 'steps' => $schritte];
}
$melden($schritte, 'Dateien ersetzen', true, sprintf('%d Einträge ersetzt, var/ und .env unberührt.', $ersetzt));
// Der Zwischenspeicher gehört zum alten Stand.
$this->removeDirectory($this->root.'/var/cache');
$melden($schritte, 'Zwischenspeicher', true, 'Geleert - sonst liefe die neue Fassung mit den Wegweisern der alten.');
// Hier endet dieser Durchgang mit Absicht. Der laufende Prozess hat den alten Code im
// Speicher - Klassen, die PHP schon geladen hat, laedt es nicht neu. Die Migrationen
// gehoeren deshalb in eine eigene Anfrage, die mit der neuen Fassung startet. Der
// Wartungszustand bleibt so lange an.
@file_put_contents($this->root.'/'.self::MARKE, json_encode([
'fassung' => $ziel,
'seit' => gmdate('c'),
'alt' => basename($arbeit),
], JSON_UNESCAPED_SLASHES));
$melden($schritte, 'Dateien sind ersetzt', true, sprintf(
'Fassung %s liegt bereit. Jetzt fehlen noch die Migrationen - sie laufen im nächsten Schritt mit dem neuen Code.',
$ziel,
));
return ['ok' => true, 'steps' => $schritte, 'migrate' => true];
}
/**
* Steht ein zweiter Schritt aus? Gibt Fassung und Zeitpunkt zurück, sonst null.
*
* @return array{fassung: string, seit: string, alt: string}|null
*/
public function pending(): ?array
{
$inhalt = @file_get_contents($this->root.'/'.self::MARKE);
if (!is_string($inhalt)) {
return null;
}
try {
$daten = json_decode($inhalt, true, 8, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
return is_array($daten) && isset($daten['fassung']) ? $daten : null;
}
/**
* Der zweite Schritt: Migrationen ausführen und die Website wieder freigeben.
*
* Läuft in einer eigenen Anfrage, also mit dem Code der neuen Fassung.
*
* @return array{ok: bool, steps: list<array{step: string, ok: bool, detail: string}>}
*/
public function finish(Installation $installation): array
{
$schritte = [];
$this->protokoll(sprintf('--- Migrationen nach dem Update, %s UTC', gmdate('Y-m-d H:i:s')));
try {
$namen = $installation->migrate();
$schritte[] = ['step' => 'Migrationen', 'ok' => true, 'detail' => [] === $namen
? 'Es stand keine an - die Datenbank war bereits auf dem Stand der neuen Fassung.'
: sprintf('%d ausgeführt: %s.', count($namen), implode(', ', $namen))];
} catch (\Throwable $fehler) {
$schritte[] = ['step' => 'Migrationen', 'ok' => false, 'detail' => $fehler->getMessage().' Die Website bleibt im Wartungszustand, bis das geklärt ist.'];
$this->protokoll('FEHLER Migrationen: '.$fehler->getMessage());
return ['ok' => false, 'steps' => $schritte];
}
@unlink($this->root.'/'.self::MARKE);
$this->updater->maintenance(false);
$this->protokoll('ok Migrationen: '.$schritte[0]['detail']);
$schritte[] = ['step' => 'Fertig', 'ok' => true, 'detail' => sprintf(
'Fassung %s läuft. Der vorherige Stand liegt unter var/update/, bis du ihn löschst.',
$this->updater->version(),
)];
return ['ok' => true, 'steps' => $schritte];
}
/**
* Eine Zeile ins Protokoll, und zwar sofort.
*
* Ohne Ausnahme bei Fehlschlag: Ein Update, das daran scheitert, dass sein Protokoll nicht
* schreibbar ist, wäre absurd.
*/
private function protokoll(string $zeile): void
{
@file_put_contents($this->root.'/'.self::PROTOKOLL, $zeile.PHP_EOL, FILE_APPEND);
}
/** Kopiert das neue public/ über das alte, ohne das zu berühren, was dort entstanden ist. */
private function mergePublic(string $von, string $nach): void
{
if (!is_dir($von)) {
return;
}
foreach (scandir($von) ?: [] as $eintrag) {
if ('.' === $eintrag || '..' === $eintrag) {
continue;
}
$quelle = $von.'/'.$eintrag;
$ziel = $nach.'/'.$eintrag;
$relativ = ltrim(str_replace($this->root.'/public', '', $ziel), '/');
if (in_array($relativ, self::EIGENES_IN_PUBLIC, true)) {
continue;
}
if (is_dir($quelle)) {
@mkdir($ziel, 0o775, true);
$this->mergePublic($quelle, $ziel);
continue;
}
@copy($quelle, $ziel);
}
}
/** Liest die Prüfsumme aus der Datei neben dem Archiv. */
private function checksumOf(string $url): ?string
{
$inhalt = $this->download->get($url);
if (null === $inhalt || 1 !== preg_match('/^([a-f0-9]{64})/', trim($inhalt), $treffer)) {
return null;
}
return $treffer[1];
}
private function removeDirectory(string $pfad): void
{
if (!is_dir($pfad)) {
return;
}
foreach (scandir($pfad) ?: [] as $eintrag) {
if ('.' === $eintrag || '..' === $eintrag) {
continue;
}
$voll = $pfad.'/'.$eintrag;
is_dir($voll) ? $this->removeDirectory($voll) : @unlink($voll);
}
@rmdir($pfad);
}
}
+378
View File
@@ -0,0 +1,378 @@
<?php
namespace App\Ops;
use App\Domain\InvalidPage;
use Doctrine\DBAL\Connection;
/**
* Sicherung und Wiederherstellung.
*
* Three things decide whether this is worth anything:
*
* 1. **The database copy has to be consistent.** A plain `copy()` of a SQLite file while something
* writes produces a file that looks fine and opens with an error later. `VACUUM INTO` asks
* SQLite for a complete copy instead.
* 2. **The media folder belongs in it.** Documents reference image IDs; without the files the
* backup restores a site full of broken pictures.
* 3. **`.env` does not.** It holds APP_SECRET, and a backup is something that gets downloaded and
* passed around. It is also not needed to restore content.
*
* Restoring is the part nobody practises, so it is the part with the most checks: a backup from a
* newer version is refused, a safety copy of the current state is taken first, and the media are
* added rather than replaced - deleting files that were uploaded after the backup would turn a
* restore into a second loss.
*/
final class Backup
{
public const MANIFEST = 'sicherung.json';
/** Keep this many, newest first. Without rotation the disk fills and the next backup fails. */
public const KEEP = 10;
public function __construct(
private string $root,
private string $databaseUrl,
private string $mediaDirectory,
private Connection $db,
private CommandRunner $runner,
/**
* Ab welcher SQLite-Fassung `VACUUM INTO` benutzt wird.
*
* Als Parameter und nicht als Konstante, damit der Weg fuer aeltere Fassungen im Test
* wirklich laeuft. Ein Ersatzweg, der nur auf fremden Servern ausgefuehrt wird, ist ein
* ungeprueftes Versprechen.
*/
private string $vacuumSince = '3.27.0',
) {
}
/**
* @return array{name: string, path: string, manifest: array}
*
* @throws InvalidPage
*/
public function create(string $reason = 'manuell', \DateTimeImmutable $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'))): array
{
// Two backups within the same second must not collide - and the second one must not
// fail either: a restore takes a safety copy, and that can land in the same second as the
// backup it is about to restore.
$name = 'sicherung-'.$now->format('Ymd-His');
$directory = $this->directory().'/'.$name;
for ($suffix = 2; is_dir($directory) && $suffix < 100; ++$suffix) {
$name = sprintf('sicherung-%s-%d', $now->format('Ymd-His'), $suffix);
$directory = $this->directory().'/'.$name;
}
if (is_dir($directory)) {
throw new InvalidPage('Es gibt bereits zu viele Sicherungen mit diesem Zeitstempel.');
}
if (!@mkdir($directory, 0o775, true)) {
throw new InvalidPage(sprintf('Das Verzeichnis %s ließ sich nicht anlegen. Schreibrechte auf var/ prüfen.', $directory));
}
$database = $this->copyDatabase($directory);
$media = $this->copyMedia($directory.'/medien');
$manifest = [
'erstellt' => $now->format('c'),
'anlass' => $reason,
'fassung' => $this->version(),
// A database that has never been migrated by the tool has no such table; that is not
// a reason to refuse a backup of its content.
'migration' => $this->migration(),
'datenbank' => $database,
'medien' => ['dateien' => $media['count'], 'bytes' => $media['bytes']],
'inhalt' => [
'seiten' => (int) $this->db->fetchOne('SELECT COUNT(*) FROM page WHERE deleted_at IS NULL'),
'fassungen' => (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision'),
'medien' => (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'),
],
// Not in here, and it is worth saying so where somebody reads it.
'enthaelt_nicht' => ['.env', '.env.local', 'vendor/', 'Quellcode'],
];
file_put_contents($directory.'/'.self::MANIFEST, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
return ['name' => $name, 'path' => $directory, 'manifest' => $manifest];
}
/**
* @return list<array{name: string, path: string, bytes: int, manifest: ?array, intact: bool}>
*/
public function all(): array
{
$backups = [];
foreach (glob($this->directory().'/sicherung-*', GLOB_ONLYDIR) ?: [] as $path) {
$manifest = $this->manifest($path);
$backups[] = [
'name' => basename($path),
'path' => $path,
'bytes' => $this->size($path),
'manifest' => $manifest,
// A backup whose database is missing or whose checksum does not match is not a
// backup, and it must say so before somebody relies on it.
'intact' => null !== $manifest && $this->intact($path, $manifest),
];
}
usort($backups, static fn (array $a, array $b): int => strcmp($b['name'], $a['name']));
return $backups;
}
/** Removes the oldest beyond the limit. Returns the names it removed. */
public function rotate(int $keep = self::KEEP): array
{
$removed = [];
foreach (array_slice($this->all(), max(1, $keep)) as $backup) {
$this->remove($backup['path']);
$removed[] = $backup['name'];
}
return $removed;
}
/**
* Puts a backup back in place.
*
* @return array{steps: list<array{step: string, ok: bool, detail: string}>, ok: bool}
*/
public function restore(string $name, bool $force = false): array
{
$steps = [];
$step = static function (array &$steps, string $name, bool $ok, string $detail): bool {
$steps[] = ['step' => $name, 'ok' => $ok, 'detail' => $detail];
return $ok;
};
$path = $this->directory().'/'.basename($name);
$manifest = $this->manifest($path);
if (!$step($steps, 'Sicherung finden', null !== $manifest, null === $manifest
? sprintf('%s gibt es nicht oder es fehlt das Manifest.', basename($name))
: sprintf('%s vom %s, Fassung %s.', basename($name), $manifest['erstellt'], $manifest['fassung']))) {
return ['steps' => $steps, 'ok' => false];
}
if (!$step($steps, 'Unversehrt', $this->intact($path, $manifest), $this->intact($path, $manifest)
? 'Die Prüfsumme der Datenbank stimmt.'
: 'Die Prüfsumme der Datenbank stimmt nicht. Diese Sicherung wird nicht eingespielt.')) {
return ['steps' => $steps, 'ok' => false];
}
// A backup from a newer version carries a schema this code does not know.
$newer = version_compare((string) $manifest['fassung'], $this->version(), '>');
if (!$step($steps, 'Fassung passt', !$newer || $force, $newer
? sprintf('Die Sicherung stammt aus Fassung %s, installiert ist %s. Erst aktualisieren.', $manifest['fassung'], $this->version())
: sprintf('Sicherung %s, installiert %s.', $manifest['fassung'], $this->version()))) {
return ['steps' => $steps, 'ok' => false];
}
try {
$safety = $this->create('vor Wiederherstellung');
$step($steps, 'Aktuellen Stand sichern', true, $safety['name'].' – auch eine Wiederherstellung kann die falsche sein.');
} catch (InvalidPage $error) {
$step($steps, 'Aktuellen Stand sichern', false, $error->getMessage());
return ['steps' => $steps, 'ok' => false];
}
$file = $this->databaseFile();
if (null === $file) {
$step($steps, 'Datenbank zurückschreiben', false, 'Diese Installation nutzt keine Dateidatenbank. Für MySQL fehlt das Einspielen noch (siehe docs/UPDATE.md).');
return ['steps' => $steps, 'ok' => false];
}
$this->db->close();
$copied = @copy($path.'/'.basename($file), $file);
if (!$step($steps, 'Datenbank zurückschreiben', $copied, $copied ? basename($file).' ersetzt.' : 'Die Datei ließ sich nicht ersetzen.')) {
return ['steps' => $steps, 'ok' => false];
}
// Media are added, never deleted: files uploaded after the backup would otherwise be lost
// by the very operation that is meant to prevent loss.
$restored = $this->restoreMedia($path.'/medien');
$step($steps, 'Medien ergänzen', true, sprintf('%d Datei(en) zurückgeschrieben. Neuere Dateien bleiben erhalten.', $restored));
return ['steps' => $steps, 'ok' => true];
}
private function migration(): string
{
try {
return (string) $this->db->fetchOne('SELECT MAX(version) FROM migration_versions');
} catch (\Throwable) {
return 'unbekannt';
}
}
public function directory(): string
{
return $this->root.'/var/backups';
}
public function version(): string
{
$file = $this->root.'/VERSION';
return is_readable($file) ? trim((string) file_get_contents($file)) : '0.0.0';
}
/** @return array{datei: string, bytes: int, sha256: string} */
private function copyDatabase(string $directory): array
{
$file = $this->databaseFile();
if (null === $file) {
if (!$this->runner->has('mysqldump', $this->root)) {
throw new InvalidPage('Diese Installation nutzt keine Dateidatenbank, und mysqldump ist nicht aufrufbar. Ohne eines von beiden gibt es keine Sicherung.');
}
throw new InvalidPage('Sicherung über mysqldump ist noch nicht umgesetzt (Arbeitspaket J).');
}
$target = $directory.'/'.basename($file);
// VACUUM INTO statt copy(): Eine Kopie, die waehrend eines Schreibvorgangs entsteht, kann
// unbrauchbar sein - und sie sagt das nicht, sondern erst an dem Tag, an dem sie gebraucht
// wird. Der Pfad kommt von uns, nicht aus einer Eingabe.
//
// **Es gibt VACUUM INTO aber erst seit SQLite 3.27.** Auf Umuts Hosting ist die Fassung
// aelter: Die Installation lief (dafuer reicht 3.16), die Sicherung endete mit 500. Auf
// einer solchen Installation wird stattdessen unter einer Schreibsperre kopiert - das ist
// der Weg, den SQLite selbst fuer diesen Fall nennt, und er ist ebenso konsistent.
if (version_compare($this->sqliteVersion(), $this->vacuumSince, '>=')) {
$this->db->executeStatement(sprintf("VACUUM INTO '%s'", str_replace("'", "''", str_replace('\\', '/', $target))));
} else {
$this->copyUnderLock($file, $target);
}
if (!is_readable($target)) {
throw new InvalidPage('Die Datenbank ließ sich nicht sichern.');
}
return ['datei' => basename($file), 'bytes' => (int) filesize($target), 'sha256' => (string) hash_file('sha256', $target)];
}
/** Die Fassung von SQLite, oder '0' wenn das keine SQLite-Verbindung ist. */
private function sqliteVersion(): string
{
try {
return (string) $this->db->fetchOne('SELECT sqlite_version()');
} catch (\Throwable) {
return '0';
}
}
/**
* Eine konsistente Kopie ohne VACUUM INTO.
*
* `BEGIN IMMEDIATE` haelt jeden anderen Schreibvorgang an, solange kopiert wird; der
* Checkpoint davor schreibt zurueck, was noch im Write-Ahead-Log steht, damit die Kopie
* vollstaendig ist und nicht die Haelfte der letzten Aenderungen fehlt.
*
* @throws InvalidPage
*/
private function copyUnderLock(string $file, string $target): void
{
try {
$this->db->executeStatement('PRAGMA wal_checkpoint(TRUNCATE)');
} catch (\Throwable) {
// Ohne WAL gibt es nichts zurueckzuschreiben - das ist kein Fehler.
}
try {
$this->db->executeStatement('BEGIN IMMEDIATE');
} catch (\Throwable $fehler) {
throw new InvalidPage('Die Datenbank ließ sich fuer die Sicherung nicht sperren: '.$fehler->getMessage());
}
try {
if (!@copy($file, $target)) {
throw new InvalidPage('Die Datenbankdatei ließ sich nicht kopieren. Schreibrechte auf var/backups pruefen.');
}
} finally {
try {
$this->db->executeStatement('COMMIT');
} catch (\Throwable) {
// Die Sperre faellt spaetestens mit der Verbindung.
}
}
}
/** @return array{count: int, bytes: int} */
private function copyMedia(string $target): array
{
if (!is_dir($this->mediaDirectory)) {
return ['count' => 0, 'bytes' => 0];
}
@mkdir($target, 0o775, true);
$count = 0;
$bytes = 0;
foreach (glob($this->mediaDirectory.'/*') ?: [] as $file) {
if (is_file($file) && @copy($file, $target.'/'.basename($file))) {
++$count;
$bytes += (int) filesize($file);
}
}
return ['count' => $count, 'bytes' => $bytes];
}
private function restoreMedia(string $source): int
{
if (!is_dir($source)) {
return 0;
}
@mkdir($this->mediaDirectory, 0o775, true);
$count = 0;
foreach (glob($source.'/*') ?: [] as $file) {
if (is_file($file) && @copy($file, $this->mediaDirectory.'/'.basename($file))) {
++$count;
}
}
return $count;
}
private function manifest(string $path): ?array
{
$file = $path.'/'.self::MANIFEST;
if (!is_readable($file)) {
return null;
}
try {
$manifest = json_decode((string) file_get_contents($file), true, 16, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
return is_array($manifest) ? $manifest : null;
}
private function intact(string $path, array $manifest): bool
{
$file = $path.'/'.($manifest['datenbank']['datei'] ?? '');
if (!is_readable($file)) {
return false;
}
return hash_equals((string) ($manifest['datenbank']['sha256'] ?? ''), (string) hash_file('sha256', $file));
}
private function size(string $path): int
{
$bytes = 0;
foreach (glob($path.'/*') ?: [] as $entry) {
$bytes += is_dir($entry) ? $this->size($entry) : (int) filesize($entry);
}
return $bytes;
}
private function remove(string $path): void
{
foreach (glob($path.'/*') ?: [] as $entry) {
is_dir($entry) ? $this->remove($entry) : @unlink($entry);
}
@rmdir($path);
}
private function databaseFile(): ?string
{
if (!str_starts_with($this->databaseUrl, 'sqlite:')) {
return null;
}
$path = preg_replace('#^sqlite:(///)?#', '', $this->databaseUrl);
return '' === $path ? null : str_replace('\\', '/', (string) $path);
}
}
+37 -2
View File
@@ -12,6 +12,9 @@ namespace App\Ops;
*/
final class CommandRunner
{
/** @var array<string, list<string>|null> */
private array $resolved = [];
public function available(): bool
{
if (!function_exists('proc_open')) {
@@ -79,9 +82,41 @@ final class CommandRunner
return ['ok' => 0 === $exit, 'exit' => $exit, 'output' => trim($output)];
}
/** Whether an executable can be found at all, so a missing one is reported, not guessed at. */
/**
* Whether an executable can be found at all, so a missing one is reported, not guessed at.
*
* On Windows several of these are batch files, and proc_open without a shell cannot start a
* .bat - which reported "composer is not available" on a machine that has it. The variants are
* tried in order, and the one that answers is remembered for the next call.
*/
public function has(string $executable, string $cwd): bool
{
return $this->run([$executable, '--version'], $cwd, 30)['ok'];
return null !== $this->resolve($executable, $cwd);
}
/** The spelling of a command that actually starts here, or null. */
public function resolve(string $executable, string $cwd): ?array
{
if (array_key_exists($executable, $this->resolved)) {
return $this->resolved[$executable];
}
$candidates = [[$executable]];
if ('\\' === DIRECTORY_SEPARATOR) {
// On Windows composer is a .bat, and CreateProcess cannot start a batch file on its
// own - it needs the command interpreter. The argument list stays fixed, nothing from
// a request ever reaches it.
$candidates[] = [getenv('COMSPEC') ?: 'cmd.exe', '/c', $executable];
}
// A phar in the project is the way on hosting where nothing is installed globally.
if (is_file($cwd.'/'.$executable.'.phar')) {
$candidates[] = [PHP_BINARY, $cwd.'/'.$executable.'.phar'];
}
foreach ($candidates as $candidate) {
if ($this->run([...$candidate, '--version'], $cwd, 30)['ok']) {
return $this->resolved[$executable] = $candidate;
}
}
return $this->resolved[$executable] = null;
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace App\Ops;
/**
* Holt eine Datei über HTTPS - über curl, ersatzweise über den Dateizeiger.
*
* Zwei Wege, weil auf einem Webhosting mal das eine da ist und mal das andere: curl fehlt auf
* manchen Paketen, `allow_url_fopen` ist auf anderen abgeschaltet. Dieselbe Doppelung steht im
* Installer und beim Laden der Schriften.
*
* Eine eigene Klasse ist das aus zwei Gründen: Die Prüfung des Zertifikats und die Sperre für
* alles außer HTTPS stehen an genau einer Stelle - und der Updater lässt sich prüfen, ohne dass
* ein Test ins Netz geht.
*/
class Download
{
/**
* Die curl-Fehler, die „ich kann das Zertifikat nicht prüfen" heißen.
*
* Als Zahlen und nicht als Konstanten: `CURLE_PEER_FAILED_VERIFICATION` gibt es nicht in
* jedem PHP-Bau, und ein fehlender Konstantenname wäre ein Absturz genau an der Stelle, an
* der eigentlich eine hilfreiche Meldung stehen soll.
*/
private const ZERTIFIKATSFEHLER = [51, 60, 77];
/** Warum der letzte Versuch nichts gebracht hat - in einem Satz, der jemandem weiterhilft. */
private string $grund = '';
public function __construct(
private int $connectTimeout = 15,
private int $timeout = 900,
) {
}
/**
* Der Grund des letzten Fehlschlags.
*
* Ohne ihn steht in der Verwaltung „die Releaseseite antwortet nicht", und das ist in drei
* Fällen dieselbe Meldung: kein Weg nach draußen, kein Zertifikatsspeicher, falsche Adresse.
* Der mittlere ist auf einem Webhosting der häufigste und der einzige, den man in einer
* Minute behebt - er muss also dastehen.
*/
public function reason(): string
{
return $this->grund;
}
/** Den Inhalt der Adresse, oder null wenn sie nicht mit 200 antwortet. */
public function get(string $url): ?string
{
$this->grund = '';
// Kein http, kein file, kein ftp: ein Update, das über eine unverschlüsselte Verbindung
// kommt, kann unterwegs ausgetauscht worden sein.
if (!str_starts_with($url, 'https://')) {
$this->grund = 'Nur HTTPS-Adressen werden geholt.';
return null;
}
if (function_exists('curl_init')) {
$curl = curl_init($url);
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_CONNECTTIMEOUT => $this->connectTimeout,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_USERAGENT => 'KantSites-Updater',
]);
$antwort = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$fehler = curl_errno($curl);
$text = curl_error($curl);
curl_close($curl);
if (is_string($antwort) && 200 === $status) {
return $antwort;
}
// Ein fehlender Zertifikatsspeicher ist keine kaputte Leitung, sondern eine
// Einstellung - und er sieht sonst genauso aus wie eine gesperrte Verbindung.
$this->grund = match (true) {
in_array($fehler, self::ZERTIFIKATSFEHLER, true) => 'Das Zertifikat der Gegenstelle lässt sich nicht prüfen: Diesem PHP fehlt ein Zertifikatsspeicher (curl.cainfo in der php.ini). Geholt wird trotzdem nur mit Prüfung - ein Update ohne sie könnte unterwegs ausgetauscht worden sein.',
0 !== $fehler => 'Die Verbindung kam nicht zustande: '.$text,
0 === $status => 'Der Server hat nicht geantwortet.',
default => sprintf('Die Adresse antwortet mit %d statt 200.', $status),
};
// Bei einem Zertifikatsproblem lohnt der zweite Weg: Der Dateizeiger benutzt die
// Einstellungen von OpenSSL, und die sind manchmal gesetzt, wo curl nichts findet.
if (!in_array($fehler, self::ZERTIFIKATSFEHLER, true)) {
return null;
}
}
if (!filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL)) {
$this->grund = '' !== $this->grund ? $this->grund : 'Dieser Server darf keine Adressen abrufen: curl fehlt und allow_url_fopen ist abgeschaltet.';
return null;
}
$context = stream_context_create([
'http' => ['timeout' => $this->timeout, 'follow_location' => 1, 'max_redirects' => 3, 'header' => "User-Agent: KantSites-Updater\r\n"],
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
]);
$antwort = @file_get_contents($url, false, $context);
if (is_string($antwort)) {
$this->grund = '';
return $antwort;
}
if ('' === $this->grund) {
$this->grund = 'Die Adresse war über den Dateizeiger nicht erreichbar.';
}
return null;
}
}
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more