Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c046681302 | ||
|
|
269fb223af | ||
|
|
8c49f6a67a | ||
|
|
af3589c95c | ||
|
|
67b85751cf | ||
|
|
464f52b147 | ||
|
|
806df4b5df | ||
|
|
7251b82f09 | ||
|
|
30d8d58bbf | ||
|
|
5a8d67a638 |
No files matched your search
@@ -109,4 +109,4 @@ Serverkonfiguration über echte Umgebungsvariablen oder eine nicht öffentliche
|
||||
|
||||
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.
|
||||
|
||||
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. 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.
|
||||
+10
-1
@@ -7,7 +7,7 @@ services:
|
||||
exclude: '../src/{Kernel.php,Domain/}'
|
||||
Doctrine\DBAL\Connection:
|
||||
factory: ['App\Infrastructure\ConnectionFactory', 'create']
|
||||
arguments: ['%env(resolve:DATABASE_URL)%']
|
||||
arguments: ['%env(resolve:DATABASE_URL)%', '@App\Infrastructure\QueryCounter']
|
||||
App\Controller\PageController:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
@@ -32,3 +32,12 @@ services:
|
||||
App\EventSubscriber\MaintenanceSubscriber:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
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%'
|
||||
@@ -440,6 +440,74 @@ Vom Verweis bleibt nur der Host. Die vollständige Adresse, aus der jemand kam,
|
||||
|
||||
**Aufbewahrung:** nach 60 Tagen werden die Stunden eines Tages zu einer Zeile verschmolzen, nach 425 Tagen wird gelöscht. Gut ein Jahr, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. `cms:stats:prune` macht beides und ist für einen Cronjob gedacht.
|
||||
|
||||
## ADR 039 – Messen statt vermuten: Abfragen, Ladebudget, Blättern (12.09.2026)
|
||||
|
||||
Arbeitspaket M sagte „Abfragen je Seite zählen. Messen, dann zusammenfassen.“ Messen braucht ein Messgerät, also gibt es jetzt eines.
|
||||
|
||||
**Ein Zähler als Doctrine-Middleware** zählt die Datenbankabfragen einer Anfrage, und außerhalb der Produktion steht die Zahl im Kopf `X-Kant-Queries`. Kein Profiler, kein Log – eine Zahl, damit „wie oft fragt diese Seite nach“ messbar statt geraten ist.
|
||||
|
||||
Die erste Messung: **die Startseite brauchte 18 Abfragen.** Nicht wegen einer komplizierten Seite, sondern weil `SiteSettings::load()` und `Theme::load()` je Aufrufer einmal dieselbe Zeile holten – der Renderer, die strukturierten Daten, der Einwilligungsschalter, die Vorlagen. Beide merken sich ihr Ergebnis jetzt für die Dauer einer Anfrage. **Ergebnis: 18 → 8.**
|
||||
|
||||
Dabei ist mir die klassische Falle des Merkens einmal selbst passiert, und der Test hat sie sofort gezeigt: ich habe den Merker am *Anfang* des Speicherns geleert. Das Speichern liest den Stand aber noch einmal (um die Vorlagen nicht zu verlieren), füllt den Merker damit erneut – und schreibt danach. Der Rest der Anfrage bekam den alten Stand. Geleert wird **nach** dem Schreiben.
|
||||
|
||||
**Das Ladebudget wird gemessen, nicht behauptet.** Die Browserprüfung liest je Seite HTML-Größe, CSS-Größe, Anzahl Anfragen, Anzahl Skripte und die Abfragezahl und prüft Obergrenzen. Heute: Startseite 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 8 Abfragen, **0 Skripte**. Die Null ist die wichtigste Zahl davon – sie hält die Zusage fest, dass eine öffentliche Seite ohne JavaScript vollständig ist, und zwar als Test statt als Absicht.
|
||||
|
||||
**Das erste Bild einer Seite ist nicht mehr `lazy`.** Es ist meistens das größte Ding über der Falz, und es zu verzögern verzögert genau das Pixel, auf das jemand wartet. Der Renderer bestimmt es in Renderreihenfolge – Kopfbereich zuerst, dann der Seiteninhalt – und gibt ihm `loading="eager"` und `fetchpriority="high"`. Alles darunter bleibt `lazy`.
|
||||
|
||||
**Eine feste Obergrenze ist kein Blättern.** Der Fassungsverlauf hörte bei dreißig auf, die Einsendungen bei fünfzig, die Weiterleitungen zeigten alle bis zu zweitausend auf einmal. Das Erste sieht aus wie „es gibt dreißig“ und ist das Einzige, was eine Historie nicht tun darf. Alle drei blättern jetzt, mit derselben Rechnung (`Pagination`) und demselben Pager wie Beitragsarchiv und Mediathek.
|
||||
|
||||
## ADR 038 – Sicherung: die Wiederherstellung ist die Funktion (12.09.2026)
|
||||
|
||||
Umut wollte eine Standard-Sicherungsfunktion. Gebaut ist sie so, dass der Teil im Mittelpunkt steht, den sonst niemand übt.
|
||||
|
||||
**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.
|
||||
|
||||
+52
-4
@@ -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 und Ressourcenpakete prüfen. ~~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. Umsetzung offen; zwei Entscheidungen hängen am Hosting (Composer auf dem Server oder `vendor/` im Tag, und ob es Shell-Zugriff gibt).
|
||||
|
||||
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,7 +189,7 @@ Umut: „alle inhalte prüfen immer pagination und minimal laden“. Als durchge
|
||||
- **Ein Ladebudget je Seite**, in der Browserprüfung nachgemessen statt geschätzt: HTML, CSS und Bilder je Seite mit einer Obergrenze, die bricht, wenn sie überschritten wird. Heute lädt eine öffentliche Seite kein JavaScript, zwei bis drei Stylesheets und die Bilder – das soll so bleiben und nachweisbar sein.
|
||||
- **Abfragen je Seite zählen.** Eine Seite mit zehn verknüpften Sektionen darf nicht zehn Einzelabfragen auslösen. Messen, dann zusammenfassen.
|
||||
|
||||
Abnahme: keine Liste in der Verwaltung, die mehr Einträge hat als sie zeigt, ohne das zu sagen und ohne Blättern. Ein automatischer Test, der die Größe der Startseite und die Anzahl der Datenbankabfragen prüft.
|
||||
Abnahme: keine Liste in der Verwaltung, die mehr Einträge hat als sie zeigt, ohne das zu sagen und ohne Blättern. Ein automatischer Test, der die Größe der Startseite und die Anzahl der Datenbankabfragen prüft. **Beides erfüllt.** Offen bleibt aus diesem Paket: `srcset` ist für Bilder aus der Mediathek vorhanden, aber eine Seite mit vielen Bildern ist nie gegen das Budget gemessen worden, und die Benutzerliste blättert noch nicht (sie ist naturgemäß kurz).
|
||||
|
||||
### N – Installer (Nutzerauftrag vom 12.09.2026)
|
||||
|
||||
@@ -195,12 +201,54 @@ Umut: „Einen Installer brauche ich noch wie wordpress schritt für schritt all
|
||||
|
||||
**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
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# 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.
|
||||
+37
-1
@@ -1,5 +1,40 @@
|
||||
# Qualitätsbericht – erster ausführbarer Prototyp
|
||||
|
||||
## Ergänzung: Ladeverhalten, 12.09.2026
|
||||
|
||||
- **347 PHP-Tests / 1422 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung mit neuem Ladebudget.
|
||||
- **Die Messung hat sofort etwas gefunden:** die Startseite brauchte 18 Datenbankabfragen, weil Einstellungen und Theme je Aufrufer erneut gelesen wurden. Mit einem Merker für die Dauer einer Anfrage sind es 8. Das ist der Unterschied zwischen „vermutlich in Ordnung“ und einer Zahl.
|
||||
- **Und die Messung hat einen Fehler von mir gefunden**, den ich beim Merken eingebaut hatte: der Merker wurde am Anfang des Speicherns geleert, das Speichern liest den Stand aber noch einmal und füllte ihn damit erneut – der Rest der Anfrage bekam den alten Stand. Ein bestehender Test über die Gestaltungsvorlagen fiel sofort um. Geleert wird jetzt nach dem Schreiben.
|
||||
- Das Ladebudget prüft je Seite HTML-Größe, CSS-Größe, Anzahl Anfragen, Anzahl Skripte und Abfragen gegen Obergrenzen. Heute: Startseite 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 8 Abfragen, 0 Skripte; Beitragsübersicht 3,5 KB HTML, 29,8 KB CSS, 3 Anfragen, 13 Abfragen, 0 Skripte. **Die Null bei den Skripten ist die wichtigste Zahl** – sie hält als Test fest, was bisher nur Absicht war.
|
||||
- Blättern ist mit 30 Fassungen und 12 Weiterleitungen geprüft, einschließlich einer Seitenzahl jenseits des Endes.
|
||||
- **Nicht geprüft:** die Grenzen sind an der heutigen Demoseite gemessen; eine Seite mit vielen Bildern wird andere Zahlen haben, und die Obergrenzen müssen dann begründet angehoben werden statt stillschweigend. Auf echtem Hosting ist nichts davon gemessen (Arbeitspaket J).
|
||||
|
||||
## Ergänzung: Sicherung und Wiederherstellung, 12.09.2026
|
||||
|
||||
- **344 PHP-Tests / 1397 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung.
|
||||
- **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 +42,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
|
||||
|
||||
+17
-1
@@ -1,6 +1,6 @@
|
||||
# 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: 12.09.2026. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Startseite von 18 auf 8 Datenbankabfragen, gemessenes Ladebudget, alle langen Listen blättern. Sicherung und Wiederherstellung, letztere nachgewiesen. Globale Seitenbreite wirkt jetzt auch nach oben, vier Menüplätze mit drei Menüarten, und jede Seite wählt ihren Kopf- und Fußbereich – auch „keiner“. 93 Gestaltungseigenschaften, neun Einblendungen, sieben Hover-Effekte, Symbol, Fortschritt und ein Slider ohne JavaScript. Update über Git mit Vorprüfung, Sicherung und Rückweg. Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
||||
|
||||
Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
|
||||
|
||||
@@ -25,6 +25,16 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- Auf ausdrücklichen Nutzerwunsch lokalen Administrator `umut` mit zufälligem Passwort angelegt und im In-App-Browser erfolgreich angemeldet. Das Passwort steht in keiner Projektdatei; nur der Hash in der lokalen Datenbank. Keine Browser-Passwortspeicherung ausgelöst.
|
||||
- Seitenerstellung und -löschung im Browser: `POST /admin/pages` (ab `ROLE_EDITOR`) legt Seite oder Beitrag mit gültigem Startdokument als privaten Entwurf an; `POST /admin/pages/{id}/delete` (nur `ROLE_ADMIN`) entfernt eine Seite mit allen Revisionen. Beide mit CSRF-Token, Fehlermeldungen als Flash und ohne JavaScript bedienbar.
|
||||
- Adresshierarchie: neue Spalten `path`, `parent_id`, `type`, `created_at` über Migration `20260912000400`; websiteweit eindeutiger Pfad statt eindeutigem Slug, Tiefe bis vier Ebenen, Beiträge ohne Unterseiten, reservierte Adressen und Adressableitung aus dem Titel. Öffentliche Route bedient verschachtelte Pfade wie `/leistungen/webdesign`; ein Lookahead hält `/admin`, `/media` und `/assets` aus dem Auffangmuster.
|
||||
- **Ladeverhalten gemessen statt vermutet** (ADR 039, Arbeitspaket M): ein Zähler liefert die Datenbankabfragen je Anfrage im Kopf `X-Kant-Queries` (außerhalb der Produktion). Die erste Messung zeigte 18 Abfragen auf der Startseite, weil Einstellungen und Theme je Aufrufer erneut gelesen wurden; beide merken sich ihren Stand jetzt für die Dauer einer Anfrage – **18 → 8**. Die Browserprüfung misst je Seite HTML, CSS, Anfragen, Skripte und Abfragen gegen Obergrenzen: Startseite 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 0 Skripte.
|
||||
- **Das erste Bild einer Seite lädt sofort**, alles darunter weiterhin verzögert. Es ist meistens das größte Ding über der Falz.
|
||||
- **Fassungsverlauf, Einsendungen und Weiterleitungen blättern** statt bei 30, 50 beziehungsweise 2000 Einträgen stillschweigend aufzuhören.
|
||||
- **Sicherung und Wiederherstellung** (ADR 038, Arbeitspaket O): `cms:backup` schreibt Datenbank (über `VACUUM INTO`, nicht als Dateikopie) und Medienordner nach `var/backups/`, mit Manifest samt Prüfsumme, und rotiert die alten. In der Verwaltung gibt es die Übersicht mit Zustand je Sicherung und einen Knopf zum Anlegen. **Zurückspielen nur auf der Konsole** (`cms:restore`): Prüfsumme, Fassungsvergleich, Sicherheitskopie des aktuellen Stands, Wartungszustand, Datenbank ersetzen, Medien **ergänzen** statt ersetzen. Nicht enthalten: `.env`, `vendor/`, Quellcode – steht so im Manifest.
|
||||
- **Seiten ohne Menü, andere Köpfe je Seite** (ADR 037, Dokumentschema v16): jede Seite wählt Kopf- und Fußbereich – Standard, ein anderer Bereich oder **keiner**, für eine Landingpage ohne Navigation. Dafür sind mehrere Kopf- und Fußbereiche erlaubt, jeder mit eigener Adresse; der erste bleibt `default`. Ein Verweis auf einen gelöschten Bereich fällt auf den Standard zurück statt die Navigation stillschweigend zu verlieren.
|
||||
- **Vier Menüplätze statt zwei** (Hauptnavigation, zweite Navigation, Fußzeile, Rechtliches) und **drei Menüarten**: waagerecht, untereinander, aufklappbar als Burger. Der Burger ist ein `details`-Element und öffnet ohne JavaScript.
|
||||
- **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 +83,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.
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -600,3 +600,4 @@ 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) }
|
||||
@@ -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;
|
||||
|
||||
@@ -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' };
|
||||
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: '⬓', 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' };
|
||||
@@ -46,10 +46,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 };
|
||||
if (type === 'image') block.props = { src: '/assets/image-placeholder.svg', alt: 'Abstrakte Landschaft als Platzhalter', decorative: false, width: 1000, height: 600 };
|
||||
return block;
|
||||
}
|
||||
@@ -64,6 +67,7 @@ export function insertBlock(document, type, selected) {
|
||||
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);
|
||||
@@ -168,7 +172,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; };
|
||||
|
||||
@@ -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}
|
||||
+48
-7
@@ -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,7 +163,11 @@ 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 (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
|
||||
@@ -235,8 +241,30 @@ 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') {
|
||||
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.' });
|
||||
@@ -274,7 +302,7 @@ function renderProperties() {
|
||||
}
|
||||
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 +405,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 +422,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) {
|
||||
@@ -611,6 +646,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;
|
||||
|
||||
@@ -3,3 +3,36 @@
|
||||
@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 #2c6b4f26, 0 10px 30px #1b2a2426 }
|
||||
.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 } }
|
||||
+166
-1
@@ -2,7 +2,7 @@
|
||||
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}
|
||||
: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}
|
||||
*{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(var(--k-container,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}}
|
||||
|
||||
/* Menus. Submenus open with details/summary, so the navigation works without JavaScript. */
|
||||
@@ -160,3 +160,168 @@ body.consent-open{padding-bottom:260px}
|
||||
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}
|
||||
|
||||
/* 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:#e2e7e1;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 #b85c12;outline-offset:6px}
|
||||
.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}
|
||||
@@ -22,6 +22,39 @@ try {
|
||||
results.push({ name: 'machine readable routes reachable', paths: 3 });
|
||||
await context.close();
|
||||
|
||||
// A load budget, measured rather than assumed: what a page actually transfers, and how many
|
||||
// database queries it needed. Both are ceilings - a page that starts asking once per section
|
||||
// or shipping a megabyte of CSS fails here instead of only getting slower.
|
||||
{
|
||||
const budget = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
|
||||
const page = await budget.newPage();
|
||||
const transferred = new Map();
|
||||
page.on('response', async response => {
|
||||
const type = (response.headers()['content-type'] ?? '').split(';')[0];
|
||||
const length = Number(response.headers()['content-length'] ?? 0);
|
||||
transferred.set(response.url(), { type, length });
|
||||
});
|
||||
for (const path of ['/', '/beitraege']) {
|
||||
transferred.clear();
|
||||
const response = await page.goto(origin + path, { waitUntil: 'networkidle' });
|
||||
const queries = Number(response.headers()['x-kant-queries'] ?? -1);
|
||||
const html = (await response.text()).length;
|
||||
const styles = [...transferred.values()].filter(entry => entry.type === 'text/css').reduce((sum, entry) => sum + entry.length, 0);
|
||||
const scripts = [...transferred.values()].filter(entry => entry.type.includes('javascript')).length;
|
||||
const requests = transferred.size;
|
||||
|
||||
assert.ok(html < 150000, `${path}: HTML ${html} Bytes, Obergrenze 150000`);
|
||||
assert.ok(styles < 120000, `${path}: CSS ${styles} Bytes, Obergrenze 120000`);
|
||||
// A public page runs no script unless it carries tabs or its own; the budget keeps that honest.
|
||||
assert.equal(scripts, 0, `${path}: ${scripts} Skript(e) geladen`);
|
||||
assert.ok(requests <= 12, `${path}: ${requests} Anfragen, Obergrenze 12`);
|
||||
if (queries >= 0) assert.ok(queries <= 20, `${path}: ${queries} Datenbankabfragen, Obergrenze 20`);
|
||||
results.push({ name: `load budget ${path}`, html, styles, requests, queries });
|
||||
console.log(`Budget ${path}: HTML ${html} B, CSS ${styles} B, ${requests} Anfragen, ${queries} Abfragen, ${scripts} Skripte`);
|
||||
}
|
||||
await budget.close();
|
||||
}
|
||||
|
||||
for (const width of [1440, 390]) {
|
||||
const context = await browser.newContext({ viewport: { width, height: 1000 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,10 @@ 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 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 +38,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], 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 +66,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)) || $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 +94,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,7 +112,7 @@ 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.
|
||||
@@ -173,14 +184,47 @@ 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 ('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.
|
||||
@@ -236,7 +280,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'];
|
||||
|
||||
@@ -253,10 +253,15 @@ final class FormRepository
|
||||
$this->db->executeStatement('UPDATE form_submission SET delivered = ? WHERE id = ?', [true, $submissionId]);
|
||||
}
|
||||
|
||||
/** @return list<array{id: string, form: string, data: array, createdAt: string, consentAt: ?string, delivered: bool}> */
|
||||
public function submissions(int $limit = 100): array
|
||||
/**
|
||||
* One page of the submissions, newest first.
|
||||
*
|
||||
* @return array{items: list<array>, page: int, pages: int, total: int}
|
||||
*/
|
||||
public function submissions(int $number = 1, int $perPage = 25): array
|
||||
{
|
||||
return array_map(static fn (array $row): array => [
|
||||
$slice = Pagination::of($number, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'), $perPage);
|
||||
$items = array_map(static fn (array $row): array => [
|
||||
'id' => $row['id'],
|
||||
'form' => $row['name'] ?? $row['form_id'],
|
||||
'data' => json_decode($row['data'], true, 512, JSON_THROW_ON_ERROR),
|
||||
@@ -264,7 +269,9 @@ final class FormRepository
|
||||
'consentAt' => $row['consent_at'],
|
||||
'delivered' => (bool) $row['delivered'],
|
||||
], $this->db->fetchAllAssociative(
|
||||
'SELECT s.*, f.name FROM form_submission s LEFT JOIN form f ON f.id = s.form_id ORDER BY s.created_at DESC, s.id DESC LIMIT '.max(1, min($limit, 500)),
|
||||
'SELECT s.*, f.name FROM form_submission s LEFT JOIN form f ON f.id = s.form_id ORDER BY s.created_at DESC, s.id DESC LIMIT '.$slice['perPage'].' OFFSET '.$slice['offset'],
|
||||
));
|
||||
|
||||
return ['items' => $items, 'page' => $slice['page'], 'pages' => $slice['pages'], 'total' => $slice['total']];
|
||||
}
|
||||
}
|
||||
@@ -14,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;
|
||||
|
||||
|
||||
@@ -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.og_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.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
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -40,12 +40,24 @@ final class SiteSettings
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembered for the length of one request.
|
||||
*
|
||||
* These settings are asked for several times per page - by the renderer, by the structured
|
||||
* data, by the consent gate, by the templates - and each time it was the same row again. The
|
||||
* value is dropped on every save, so nothing stale survives a change.
|
||||
*/
|
||||
private ?array $loaded = null;
|
||||
|
||||
public function load(): array
|
||||
{
|
||||
if (null !== $this->loaded) {
|
||||
return $this->loaded;
|
||||
}
|
||||
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']);
|
||||
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
|
||||
|
||||
return [...self::DEFAULTS, ...(is_array($stored) ? $stored : [])];
|
||||
return $this->loaded = [...self::DEFAULTS, ...(is_array($stored) ? $stored : [])];
|
||||
}
|
||||
|
||||
public function save(array $input): void
|
||||
@@ -132,6 +144,7 @@ final class SiteSettings
|
||||
}
|
||||
}
|
||||
|
||||
$this->loaded = null;
|
||||
$this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
|
||||
json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||
'default',
|
||||
|
||||
@@ -111,6 +111,12 @@ final class StyleCompiler
|
||||
if ('' !== $base) {
|
||||
$rules .= sprintf('#%s{%s}', $id, $base);
|
||||
}
|
||||
// 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');
|
||||
if ('' !== $images) {
|
||||
$rules .= sprintf('#%s img{%s}', $id, $images);
|
||||
}
|
||||
$hover = is_array($values['hover'] ?? null) ? $this->declarations($values['hover']) : '';
|
||||
if ('' !== $hover) {
|
||||
$rules .= sprintf('#%s:hover,#%s:focus-within{%s}', $id, $id, $hover);
|
||||
@@ -127,8 +133,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
|
||||
{
|
||||
$properties = StyleSchema::properties();
|
||||
$out = [];
|
||||
@@ -137,6 +147,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 +161,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),
|
||||
$this->shadow($values),
|
||||
$this->textShadow($values),
|
||||
$this->filter($values),
|
||||
$this->transform($values),
|
||||
$this->grid($values),
|
||||
$this->transition($values),
|
||||
] as $composed) {
|
||||
if ('' !== $composed) {
|
||||
$out[] = $composed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,7 +240,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 +250,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 = [];
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
+14
-2
@@ -56,15 +56,21 @@ final class Theme
|
||||
{
|
||||
}
|
||||
|
||||
/** Remembered for one request, like the settings; dropped on every write. */
|
||||
private ?array $loaded = null;
|
||||
|
||||
public function load(): array
|
||||
{
|
||||
if (null !== $this->loaded) {
|
||||
return $this->loaded;
|
||||
}
|
||||
$raw = $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']);
|
||||
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
|
||||
if (!is_array($stored) || !isset($stored['colors'], $stored['fonts'], $stored['base'])) {
|
||||
return [...self::DEFAULTS, 'presets' => []];
|
||||
return $this->loaded = [...self::DEFAULTS, 'presets' => []];
|
||||
}
|
||||
|
||||
return [
|
||||
return $this->loaded = [
|
||||
'colors' => $stored['colors'],
|
||||
'fonts' => $stored['fonts'],
|
||||
'base' => [...self::DEFAULTS['base'], ...$stored['base']],
|
||||
@@ -97,6 +103,9 @@ final class Theme
|
||||
json_encode($theme, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||
'default',
|
||||
]);
|
||||
// After the write, never before: saving reads the theme again, and an early reset
|
||||
// would only refill the memo with the state that is about to be replaced.
|
||||
$this->loaded = null;
|
||||
}
|
||||
|
||||
private function colors(array $input): array
|
||||
@@ -240,6 +249,9 @@ final class Theme
|
||||
json_encode([...$theme, 'presets' => $presets], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||
'default',
|
||||
]);
|
||||
// After the write, never before: saving reads the theme again, and an early reset
|
||||
// would only refill the memo with the state that is about to be replaced.
|
||||
$this->loaded = null;
|
||||
}
|
||||
|
||||
/** The stylesheet every page carries: palette variables, font variables and base typography. */
|
||||
|
||||
@@ -45,6 +45,8 @@ 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)),
|
||||
// Which header and footer areas a page can choose from.
|
||||
'areaChoices' => ['header' => $this->partials->choices('header'), 'footer' => $this->partials->choices('footer')],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -148,6 +150,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 +249,14 @@ final class EditorController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/pages/{id}/revisions', name: 'admin_revisions', methods: ['GET'])]
|
||||
public function revisions(string $id): Response
|
||||
public function revisions(Request $request, string $id): Response
|
||||
{
|
||||
$page = $this->page($id);
|
||||
|
||||
return $this->render('admin/revisions.html.twig', [
|
||||
'page' => $page,
|
||||
'title' => $page['document']['title'] ?? $page['path'],
|
||||
'revisions' => $this->pages->revisions($id),
|
||||
'revisions' => $this->pages->revisions($id, $request->query->getInt('seite', 1)),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,12 +17,12 @@ final class FormAdminController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/forms', name: 'admin_forms', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
return $this->render('admin/forms.html.twig', [
|
||||
'forms' => $this->forms->all(),
|
||||
'types' => FormRepository::TYPES,
|
||||
'submissions' => $this->forms->submissions(50),
|
||||
'submissions' => $this->forms->submissions($request->query->getInt('seite', 1)),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,8 @@ final class PageController extends AbstractController
|
||||
$page = $this->pages->findPublished($path);
|
||||
$styles = '';
|
||||
$googleFonts = null;
|
||||
$areas = $this->areas();
|
||||
// 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.
|
||||
@@ -75,6 +76,8 @@ final class PageController extends AbstractController
|
||||
'structuredData' => $this->structuredData($page),
|
||||
'areas' => $areas,
|
||||
'sections' => $sections,
|
||||
// Rendered before the page content, so the header decides first.
|
||||
'eagerImage' => $this->firstImage($areas['header'] ?? [], $page['document']),
|
||||
'pageStyles' => $styles,
|
||||
'googleFonts' => $googleFonts,
|
||||
]);
|
||||
@@ -339,6 +342,39 @@ final class PageController extends AbstractController
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first image of the page, in the order it is rendered.
|
||||
*
|
||||
* That one must not be lazy: it is usually the largest thing above the fold, and deferring it
|
||||
* delays exactly the pixel a visitor waits for. Everything below it stays lazy.
|
||||
*/
|
||||
private function firstImage(array ...$documents): ?string
|
||||
{
|
||||
foreach ($documents as $document) {
|
||||
$found = $this->findImage($document['blocks'] ?? []);
|
||||
if (null !== $found) {
|
||||
return $found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function findImage(array $blocks): ?string
|
||||
{
|
||||
foreach ($blocks as $block) {
|
||||
if ('image' === ($block['type'] ?? null)) {
|
||||
return $block['id'];
|
||||
}
|
||||
$inside = $this->findImage($block['children'] ?? []);
|
||||
if (null !== $inside) {
|
||||
return $inside;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The hosts of embeds that may load on this request.
|
||||
*
|
||||
@@ -374,11 +410,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'];
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ final class SettingsController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/settings', name: 'admin_settings', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$choosable = [];
|
||||
foreach ($this->pages->listForEditor() as $page) {
|
||||
@@ -33,7 +33,7 @@ final class SettingsController extends AbstractController
|
||||
|
||||
return $this->render('admin/settings.html.twig', [
|
||||
'settings' => $this->settings->load(),
|
||||
'redirects' => $this->redirects->all(),
|
||||
'redirects' => $this->redirects->all($request->query->getInt('seite', 1)),
|
||||
'choosableHome' => $choosable,
|
||||
'consentGroups' => Consent::GROUPS,
|
||||
'missingLegal' => $this->settings->missingLegalPages(),
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Ops\Backup;
|
||||
use App\Ops\Updater;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -17,7 +19,7 @@ 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)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -35,6 +37,7 @@ 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(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -61,6 +64,36 @@ final class SystemController extends AbstractController
|
||||
return $this->redirectToRoute('admin_system');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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('/admin/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());
|
||||
}
|
||||
$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('/admin/system/wartung', name: 'admin_system_maintenance', methods: ['POST'])]
|
||||
public function maintenance(Request $request): Response
|
||||
{
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
<?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,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 instead of copy(): a copy taken while something writes can be unusable, and
|
||||
// it would not say so until the day it is needed. The path is ours, not user input.
|
||||
$this->db->executeStatement(sprintf("VACUUM INTO '%s'", str_replace("'", "''", str_replace('\\', '/', $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)];
|
||||
}
|
||||
|
||||
/** @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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig;
|
||||
|
||||
use App\Ops\Updater;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
/**
|
||||
* The running version, for the administration shell.
|
||||
*
|
||||
* A function rather than a global, so the templates get exactly this one read-only answer and not
|
||||
* a whole updater with an update() method on it.
|
||||
*/
|
||||
final class VersionExtension extends AbstractExtension
|
||||
{
|
||||
public function __construct(private Updater $updater)
|
||||
{
|
||||
}
|
||||
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [new TwigFunction('kant_version', $this->updater->version(...))];
|
||||
}
|
||||
}
|
||||
@@ -20,13 +20,18 @@
|
||||
<main id="admin-main" class="editor-layout" tabindex="-1">
|
||||
<aside class="left-panel" aria-label="Elemente und Seitenstruktur">
|
||||
<section class="panel-section"><div class="panel-title"><h2>Werkzeugkasten</h2><button id="toggle-library" class="text-button" aria-expanded="true" aria-controls="toolbox">− <span class="visually-hidden">Werkzeugkasten einklappen</span></button></div><div id="toolbox"><h3 class="preset-heading">Element hinzufügen</h3>
|
||||
<p class="panel-hint">In die gewählte Sektion oder hinter das gewählte Element.</p>
|
||||
<p class="panel-hint">In die gewählte Sektion oder hinter das gewählte Element. Der Aufbau ist <strong>Sektion → Zeile → Spalte → Inhalt</strong>: eine Zeile hält Spalten nebeneinander.</p>
|
||||
<div id="rows" class="row-library">
|
||||
{% for count, label in {2: 'Zeile, 2 Spalten', 3: 'Zeile, 3 Spalten', 4: 'Zeile, 4 Spalten'} %}
|
||||
<button type="button" data-row="{{ count }}" disabled><span aria-hidden="true">{{ {2: '▥▥', 3: '▥▥▥', 4: '▥▥▥▥'}[count] }}</span> {{ label }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div id="library" class="library">
|
||||
{% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie', postlist:'Beitragsliste', iconcard:'Symbolkarte', stat:'Kennzahl', quote:'Kundenstimme', logos:'Logoleiste', cta:'Aufruf-Band', contact:'Kontaktdaten', accordion:'Akkordeon', accordionitem:'Eintrag', tabs:'Tabs', tabitem:'Tab', form:'Formular', breadcrumb:'Brotkrumen', menu:'Navigation', partialref:'Verkn. Sektion', embed:'Externer Inhalt', divider:'Trennlinie', spacer:'Abstand'} %}<button type="button" data-add="{{ type }}" disabled><span aria-hidden="true">{{ {section:'▣',row:'☰',column:'▥',heading:'H',text:'T',image:'▧',button:'↗',gallery:'▦',postlist:'☷',iconcard:'◈',stat:'№',quote:'❝',logos:'⬚',cta:'◉',contact:'✆',accordion:'⌵',accordionitem:'·',tabs:'▭',tabitem:'▫',form:'✉',breadcrumb:'⟩',menu:'☰',partialref:'⧉',embed:'⬓',divider:'—',spacer:'⇳'}[type] }}</span>{{ label }}</button>{% endfor %}
|
||||
{% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie', postlist:'Beitragsliste', iconcard:'Symbolkarte', stat:'Kennzahl', quote:'Kundenstimme', logos:'Logoleiste', cta:'Aufruf-Band', contact:'Kontaktdaten', accordion:'Akkordeon', accordionitem:'Eintrag', tabs:'Tabs', tabitem:'Tab', form:'Formular', breadcrumb:'Brotkrumen', menu:'Navigation', partialref:'Verkn. Sektion', embed:'Externer Inhalt', icon:'Symbol', progress:'Fortschritt', slider:'Slider', divider:'Trennlinie', spacer:'Abstand'} %}<button type="button" data-add="{{ type }}" disabled><span aria-hidden="true">{{ {section:'▣',row:'☰',column:'▥',heading:'H',text:'T',image:'▧',button:'↗',gallery:'▦',postlist:'☷',iconcard:'◈',stat:'№',quote:'❝',logos:'⬚',cta:'◉',contact:'✆',accordion:'⌵',accordionitem:'·',tabs:'▭',tabitem:'▫',form:'✉',breadcrumb:'⟩',menu:'☰',partialref:'⧉',embed:'⬓',icon:'◆',progress:'▰',slider:'⇄',divider:'—',spacer:'⇳'}[type] }}</span>{{ label }}</button>{% endfor %}
|
||||
</div>
|
||||
<button id="open-media-library" class="secondary full-width media-launch" disabled>Medienverwaltung öffnen</button>
|
||||
<h3 class="preset-heading">Fertige Abschnitte</h3><p class="panel-hint">Wird nach dem aktuellen Seitenabschnitt eingefügt.</p>
|
||||
<div id="presets" class="preset-library"><button data-preset="intro" disabled><span aria-hidden="true">▤</span> Intro mit Button</button><button data-preset="columns" disabled><span aria-hidden="true">▥</span> Zwei Leistungen</button><button data-preset="cta" disabled><span aria-hidden="true">↗</span> Kontaktaufruf</button></div>
|
||||
<div id="presets" class="preset-library"><button data-preset="hero" disabled><span aria-hidden="true">▮</span> Vollbild-Hero</button><button data-preset="intro" disabled><span aria-hidden="true">▤</span> Intro mit Button</button><button data-preset="columns" disabled><span aria-hidden="true">▥</span> Zwei Leistungen</button><button data-preset="cta" disabled><span aria-hidden="true">↗</span> Kontaktaufruf</button></div>
|
||||
</div></section>
|
||||
<section class="panel-section structure-section"><div class="panel-title"><h2>Struktur</h2><button id="select-page" class="text-button">Seite</button></div><label class="search-label" for="tree-search">Element suchen</label><input id="tree-search" type="search" placeholder="Text oder Elementtyp …"><div class="tree-tools"><button id="expand-all" class="text-button">Alle öffnen</button><button id="collapse-all" class="text-button">Einklappen</button></div><p class="panel-hint">Ziehen oder mit den Schaltflächen verschieben.</p><nav id="structure" aria-label="Blöcke auswählen"></nav>
|
||||
<div class="block-actions"><button id="move-up" title="Nach oben verschieben" aria-label="Nach oben verschieben">↑</button><button id="move-down" title="Nach unten verschieben" aria-label="Nach unten verschieben">↓</button><button id="nest" title="In vorherigen Container verschachteln" aria-label="In vorherigen Container verschachteln">⇥</button><button id="outdent" title="Eine Ebene herausheben" aria-label="Eine Ebene herausheben">⇤</button><button id="duplicate">Duplizieren</button><button id="delete" class="danger-text">Löschen</button></div>
|
||||
|
||||
@@ -73,12 +73,12 @@
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
<div class="section-head"><h2>Einsendungen <span class="count">{{ submissions|length }}</span></h2><span class="muted">Die neuesten 50.</span></div>
|
||||
{% if submissions is empty %}
|
||||
<div class="section-head"><h2>Einsendungen <span class="count">{{ submissions.total }}</span></h2><span class="muted">Neueste zuerst.</span></div>
|
||||
{% if submissions.items is empty %}
|
||||
<div class="empty"><h3>Noch keine Einsendungen.</h3><p>Hier erscheinen eingegangene Nachrichten, auch wenn der E-Mail-Versand fehlschlägt.</p></div>
|
||||
{% else %}
|
||||
<div class="rows">
|
||||
{% for entry in submissions %}
|
||||
{% for entry in submissions.items %}
|
||||
<article class="row row-top">
|
||||
<div class="row-main">
|
||||
<h3>{{ entry.form }}</h3>
|
||||
@@ -90,5 +90,6 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include 'admin/pager.html.twig' with {result: submissions, route: 'admin_forms', label: 'Seiten der Einsendungen'} only %}
|
||||
<p class="footnote">KantSites · Arbeitsstand</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,10 @@
|
||||
{# One pager for every paged administration list: previous, position, next.
|
||||
|
||||
A fixed upper limit shows the first entries and says nothing about the rest; this says both. #}
|
||||
{% if result.pages > 1 %}
|
||||
<nav class="pager" aria-label="{{ label|default('Seiten') }}">
|
||||
{% if result.page > 1 %}<a class="secondary" href="{{ path(route, params|default({})|merge({seite: result.page - 1})) }}" rel="prev"><span aria-hidden="true">←</span> Neuere</a>{% endif %}
|
||||
<span class="muted">Seite {{ result.page }} von {{ result.pages }} · {{ result.total }} Einträge</span>
|
||||
{% if result.page < result.pages %}<a class="secondary" href="{{ path(route, params|default({})|merge({seite: result.page + 1})) }}" rel="next">Ältere <span aria-hidden="true">→</span></a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
@@ -154,6 +154,32 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<div class="sheet-part">
|
||||
<h4>Kopf- und Fußbereich</h4>
|
||||
<p>Standard ist der gemeinsame Bereich. „Kein Bereich“ liefert die Seite ohne Kopf beziehungsweise Fuß aus – für eine Landingpage ohne Navigation. Weitere Bereiche legst du unter Bereiche an.</p>
|
||||
<form action="{{ path('admin_page_areas', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<div class="fields">
|
||||
{% for kind, label in {header: 'Kopfbereich', footer: 'Fußbereich'} %}
|
||||
<div>
|
||||
<label for="{{ kind }}-{{ page.id }}">{{ label }}</label>
|
||||
<select id="{{ kind }}-{{ page.id }}" name="{{ kind }}Area">
|
||||
<option value=""{{ page[kind ~ '_area'] is null ? ' selected' : '' }}>Standard</option>
|
||||
{% for slug, name in areaChoices[kind] %}{% if slug != 'default' %}
|
||||
<option value="{{ slug }}"{{ page[kind ~ '_area'] == slug ? ' selected' : '' }}>{{ name }}</option>
|
||||
{% endif %}{% endfor %}
|
||||
<option value="none"{{ page[kind ~ '_area'] == 'none' ? ' selected' : '' }}>Kein Bereich</option>
|
||||
</select>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button class="secondary" type="submit">Bereiche speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_granted('ROLE_PUBLISHER') %}
|
||||
<div class="sheet-part">
|
||||
<h4>Veröffentlichung planen</h4>
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
{% block lead %}Jedes Speichern erzeugt eine eigene Fassung. Eine frühere Fassung wiederherzustellen überschreibt nichts: sie wird als neuer Entwurf gespeichert, der bisherige Stand bleibt in der Liste.{% endblock %}
|
||||
{% block actions %}<a class="secondary" href="{{ path('admin_editor', {id: page.id}) }}">Seite bearbeiten <span aria-hidden="true">→</span></a>{% endblock %}
|
||||
{% block content %}
|
||||
<div class="section-head"><h2>Verlauf <span class="count">{{ revisions|length }}</span></h2><span class="muted">Neueste zuerst, höchstens 30.</span></div>
|
||||
<div class="section-head"><h2>Verlauf <span class="count">{{ revisions.total }}</span></h2><span class="muted">Neueste zuerst.</span></div>
|
||||
<div class="rows">
|
||||
{% for revision in revisions %}
|
||||
{% for revision in revisions.items %}
|
||||
<article class="row">
|
||||
<span class="row-icon" aria-hidden="true">⟲</span>
|
||||
<div class="row-main">
|
||||
@@ -28,5 +28,6 @@
|
||||
<div class="empty"><h3>Noch keine Fassungen.</h3></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% include 'admin/pager.html.twig' with {result: revisions, route: 'admin_revisions', params: {id: page.id}, label: 'Seiten des Verlaufs'} only %}
|
||||
<p class="footnote">KantSites · Arbeitsstand</p>
|
||||
{% endblock %}
|
||||
@@ -103,7 +103,7 @@
|
||||
</section>
|
||||
|
||||
<section class="stack-section">
|
||||
<div class="section-head"><h2>Weiterleitungen <span class="count">{{ redirects|length }}</span></h2><span class="muted">Werden vor dem 404 geprüft.</span></div>
|
||||
<div class="section-head"><h2>Weiterleitungen <span class="count">{{ redirects.total }}</span></h2><span class="muted">Werden vor dem 404 geprüft.</span></div>
|
||||
<form action="{{ path('admin_redirect_save') }}" method="post" class="card card-body">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
|
||||
<div class="fields">
|
||||
@@ -127,12 +127,12 @@
|
||||
<button class="primary" type="submit">Weiterleitung speichern</button>
|
||||
</form>
|
||||
|
||||
{% if redirects is not empty %}
|
||||
{% if redirects.items is not empty %}
|
||||
<table class="table redirect-table">
|
||||
<caption class="visually-hidden">Gespeicherte Weiterleitungen</caption>
|
||||
<thead><tr><th scope="col">Alte Adresse</th><th scope="col">Ziel</th><th scope="col">Art</th><th scope="col">Entfernen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in redirects %}
|
||||
{% for entry in redirects.items %}
|
||||
<tr>
|
||||
<td><code>/{{ entry.from }}</code></td>
|
||||
<td>{% if entry.to %}<code>/{{ entry.to }}</code>{% else %}<span class="muted">—</span>{% endif %}</td>
|
||||
@@ -148,6 +148,7 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% include 'admin/pager.html.twig' with {result: redirects, route: 'admin_settings', label: 'Seiten der Weiterleitungen'} only %}
|
||||
{% endif %}
|
||||
</section>
|
||||
<p class="footnote">KantSites · Arbeitsstand</p>
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="shell-foot">
|
||||
<p class="shell-version">Fassung {{ kant_version() }}</p>
|
||||
<a class="shell-user" href="{{ path('admin_account') }}"{{ nav|default('') == 'account' ? ' aria-current="page"' : '' }}><span class="shell-avatar" aria-hidden="true">{{ app.user.userIdentifier|first|upper }}</span><strong>{{ app.user.userIdentifier }}</strong></a>
|
||||
<form action="{{ path('admin_logout') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}">
|
||||
|
||||
@@ -95,6 +95,38 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Sicherungen</h2><span class="muted">{{ backups|length }} vorhanden · var/backups</span></div>
|
||||
<div class="card-body">
|
||||
<p class="muted">Eine Sicherung enthält die Datenbank als vollständige Kopie – nicht als Dateikopie während des Schreibens, die unbrauchbar sein kann – und den Medienordner. <strong>Nicht enthalten sind <code>.env</code>, <code>vendor/</code> und der Quellcode</strong>: die kommen aus dem Repository, und <code>.env</code> enthält das Geheimnis dieser Installation.</p>
|
||||
<p class="field-help">Zurückspielen geht bewusst nur auf der Konsole: <code>php bin/console cms:restore <name></code>. Ein Knopf, der die ganze Datenbank ersetzt, gehört nicht neben einen, der eine Seite speichert. Für den täglichen Cronjob: <code>php bin/console cms:backup</code>.</p>
|
||||
<form action="{{ path('admin_system_backup') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<button class="primary" type="submit">Sicherung jetzt anlegen</button>
|
||||
</form>
|
||||
|
||||
{% if backups is not empty %}
|
||||
<table class="table">
|
||||
<caption class="visually-hidden">Vorhandene Sicherungen</caption>
|
||||
<thead><tr><th scope="col">Name</th><th scope="col">Erstellt</th><th scope="col">Fassung</th><th scope="col">Inhalt</th><th scope="col">Größe</th><th scope="col">Zustand</th></tr></thead>
|
||||
<tbody>
|
||||
{% for backup in backups %}
|
||||
<tr>
|
||||
<td><code>{{ backup.name }}</code></td>
|
||||
<td>{{ backup.manifest ? backup.manifest.erstellt|date('d.m.Y H:i') : '—' }}{% if backup.manifest %}<br><span class="muted">{{ backup.manifest.anlass }}</span>{% endif %}</td>
|
||||
<td>{{ backup.manifest ? backup.manifest.fassung : '—' }}</td>
|
||||
<td>{% if backup.manifest %}{{ backup.manifest.inhalt.seiten }} Seiten, {{ backup.manifest.medien.dateien }} Dateien{% else %}—{% endif %}</td>
|
||||
<td>{{ (backup.bytes / 1048576)|number_format(1, ',', '.') }} MB</td>
|
||||
<td>{% if backup.intact %}<span class="pill pill-live">unversehrt</span>{% else %}<span class="pill pill-off">Prüfsumme falsch</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="field-help">„Unversehrt“ heißt: die Prüfsumme der gesicherten Datenbank stimmt noch mit dem Manifest überein. Stimmt sie nicht, wird die Sicherung nicht eingespielt.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Verlauf</h2><span class="muted">var/updates.log</span></div>
|
||||
<div class="card-body">
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
<div>
|
||||
<label for="base-container">Inhaltsbreite</label>
|
||||
<input id="base-container" name="base[containerWidth]" value="{{ theme.base.containerWidth }}" placeholder="1240px">
|
||||
<p class="field-help">Die Breite des Inhalts auf allen Seiten. Abschnitte mit der Inhaltsbreite „Breit“ oder „Randlos“ halten sich nicht daran – das ist der Zweck dieser beiden.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-color">Textfarbe</label>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<a class="skip-link" href="#main">Zum Inhalt springen</a>
|
||||
{% if areas.header|default(null) %}
|
||||
<header class="site-header">
|
||||
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true} only %}{% endfor %}
|
||||
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true, eagerImage: null} only %}{% endfor %}
|
||||
</header>
|
||||
{% endif %}
|
||||
<main id="main" tabindex="-1">
|
||||
@@ -44,7 +44,7 @@
|
||||
</main>
|
||||
{% if areas.footer|default(null) %}
|
||||
<footer class="site-footer">
|
||||
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true} only %}{% endfor %}
|
||||
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true, eagerImage: null} only %}{% endfor %}
|
||||
</footer>
|
||||
{% endif %}
|
||||
{# Privacy statement, imprint and the way back to the consent decision. All three have to be
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{% set tag = type == 'section' ? 'section' : 'div' %}
|
||||
<{{ tag }} id="{{ block.id }}" class="block-{{ type }} tone-{{ layout.tone|default('paper') }} width-{{ layout.width|default('normal') }}{{ designClasses }}">
|
||||
{% if type == 'section' %}<div class="shell section-inner">{% endif %}
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}{% endfor %}
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}{% endfor %}
|
||||
{% if type == 'section' %}</div>{% endif %}
|
||||
</{{ tag }}>
|
||||
{% elseif type == 'heading' %}
|
||||
@@ -26,7 +26,7 @@
|
||||
{% elseif type == 'button' %}
|
||||
<a id="{{ block.id }}" class="button{{ designClasses }}" href="{{ props.href }}">{{ props.text }} <span aria-hidden="true">↗</span></a>
|
||||
{% elseif type == 'image' %}
|
||||
<figure id="{{ block.id }}" class="block-image{{ designClasses }}"><img src="{{ props.src }}" alt="{{ props.alt }}" width="{{ props.width }}" height="{{ props.height }}" loading="lazy" decoding="async"{% if props.sources|default([]) %} srcset="{% for source in props.sources %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px"{% endif %}>{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure>
|
||||
<figure id="{{ block.id }}" class="block-image{{ designClasses }}">{% set eager = eagerImage|default(null) == block.id %}<img src="{{ props.src }}" alt="{{ props.alt }}" width="{{ props.width }}" height="{{ props.height }}" loading="{{ eager ? 'eager' : 'lazy' }}"{{ eager ? ' fetchpriority="high"' : '' }} decoding="async"{% if props.sources|default([]) %} srcset="{% for source in props.sources %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px"{% endif %}>{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure>
|
||||
{% elseif type == 'iconcard' %}
|
||||
<div id="{{ block.id }}" class="block-iconcard{{ designClasses }}">
|
||||
{% include 'blocks/icon.html.twig' with {name: props.icon} only %}
|
||||
@@ -45,7 +45,7 @@
|
||||
</figure>
|
||||
{% elseif type == 'logos' %}
|
||||
<ul id="{{ block.id }}" class="block-logos{{ designClasses }}">
|
||||
{% for child in block.children|default([]) %}<li>{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}</li>{% endfor %}
|
||||
{% for child in block.children|default([]) %}<li>{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}</li>{% endfor %}
|
||||
</ul>
|
||||
{% elseif type == 'cta' %}
|
||||
<div id="{{ block.id }}" class="block-cta{{ designClasses }}">
|
||||
@@ -68,8 +68,18 @@
|
||||
{% elseif type == 'menu' %}
|
||||
{% set links = navigation[props.slot]|default([]) %}
|
||||
{% if links is not empty %}
|
||||
<nav id="{{ block.id }}" class="block-menu{{ designClasses }}" aria-label="{{ props.slot == 'footer' ? 'Fußzeile' : 'Hauptnavigation' }}">
|
||||
{# The layout is a class, and the burger variant is a details element: a menu that needs a
|
||||
script to open would be a menu that does not open on a page without one. #}
|
||||
<nav id="{{ block.id }}" class="block-menu menu-{{ props.layout|default('horizontal') }}{{ designClasses }}"
|
||||
aria-label="{{ {header: 'Hauptnavigation', secondary: 'Zweite Navigation', footer: 'Fußzeile', legal: 'Rechtliches'}[props.slot]|default('Navigation') }}">
|
||||
{% if props.layout|default('horizontal') == 'burger' %}
|
||||
<details class="menu-burger">
|
||||
<summary aria-label="Menü öffnen"><span class="burger-lines" aria-hidden="true"></span><span class="burger-text">Menü</span></summary>
|
||||
<div class="menu-burger-body">{% include 'nav.html.twig' with {links: links} only %}</div>
|
||||
</details>
|
||||
{% else %}
|
||||
{% include 'nav.html.twig' with {links: links} only %}
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% elseif type == 'partialref' %}
|
||||
@@ -78,7 +88,7 @@
|
||||
{% set section = sections[props.partial]|default(null) %}
|
||||
{% if section %}
|
||||
<div id="{{ block.id }}" class="block-partialref{{ designClasses }}">
|
||||
{% for child in section.blocks %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}{% endfor %}
|
||||
{% for child in section.blocks %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}{% endfor %}
|
||||
</div>
|
||||
{% elseif editing|default(false) %}
|
||||
<p id="{{ block.id }}" class="block-unavailable" role="note">Die verknüpfte Sektion „{{ props.partial }}“ gibt es nicht mehr. Der Verweis bleibt gespeichert.</p>
|
||||
@@ -107,24 +117,53 @@
|
||||
<ul class="tabs-nav">
|
||||
{% for child in block.children|default([]) %}<li><a href="#{{ child.id }}">{{ child.props.title }}</a></li>{% endfor %}
|
||||
</ul>
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}{% endfor %}
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}{% endfor %}
|
||||
</div>
|
||||
{% elseif type == 'tabitem' %}
|
||||
<section id="{{ block.id }}" class="block-tabitem{{ designClasses }}">
|
||||
<h3 class="tabs-heading">{{ props.title }}</h3>
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}{% endfor %}
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}{% endfor %}
|
||||
</section>
|
||||
{% elseif type == 'accordion' %}
|
||||
<div id="{{ block.id }}" class="block-accordion{{ designClasses }}">
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}{% endfor %}
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}{% endfor %}
|
||||
</div>
|
||||
{% elseif type == 'accordionitem' %}
|
||||
<details id="{{ block.id }}" class="block-accordionitem{{ designClasses }}"{% if props.open|default(false) %} open{% endif %}>
|
||||
<summary>{{ props.title }}</summary>
|
||||
<div class="accordion-body">
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}{% endfor %}
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% elseif type == 'icon' %}
|
||||
{# A single icon. Decorative ones are hidden from assistive technology; one that means
|
||||
something carries its label as text for screen readers. #}
|
||||
{% set iconBody %}{% include 'blocks/icon.html.twig' with {name: props.icon} only %}{% if not props.decorative %}<span class="visually-hidden">{{ props.label }}</span>{% endif %}{% endset %}
|
||||
{% if props.href|default('') %}
|
||||
<a id="{{ block.id }}" class="block-icon icon-size-{{ props.size }}{{ designClasses }}" href="{{ props.href }}"{{ props.decorative ? ' aria-hidden="true" tabindex="-1"' : '' }}>{{ iconBody }}</a>
|
||||
{% else %}
|
||||
<span id="{{ block.id }}" class="block-icon icon-size-{{ props.size }}{{ designClasses }}"{{ props.decorative ? ' aria-hidden="true"' : '' }}>{{ iconBody }}</span>
|
||||
{% endif %}
|
||||
{% elseif type == 'progress' %}
|
||||
{# A progress bar as a real meter: the number is in the markup, not only in the width of a
|
||||
coloured div, so it is readable without seeing it. #}
|
||||
<div id="{{ block.id }}" class="block-progress{{ designClasses }}">
|
||||
<p class="progress-label">{{ props.label }}{% if props.showValue %} <span class="progress-value">{{ props.value }} %</span>{% endif %}</p>
|
||||
<div class="progress-track" role="meter" aria-valuenow="{{ props.value }}" aria-valuemin="0" aria-valuemax="100" aria-label="{{ props.label }}">
|
||||
<div class="progress-fill progress-fill-{{ props.value }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% elseif type == 'slider' %}
|
||||
{# A slider without a script: a scroll container with scroll snapping. It works with a finger,
|
||||
a trackpad, a mouse wheel and the keyboard, and it degrades to a scrollable strip on a
|
||||
browser that ignores snapping. No autoplay - a moving carousel is something people ask to
|
||||
stop, not something a page should start on its own. #}
|
||||
<div id="{{ block.id }}" class="block-slider{{ designClasses }} slide-desktop-{{ props.desktopVisible }} slide-tablet-{{ props.tabletVisible }} slide-mobile-{{ props.mobileVisible }}{{ props.snap ? ' slider-snap' : '' }}"
|
||||
tabindex="0" role="group" aria-label="Zum Blättern seitwärts scrollen">
|
||||
{% for child in block.children|default([]) %}
|
||||
<div class="slider-slide">{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elseif type == 'embed' %}
|
||||
{# Content from a third party. Until the group is granted, nothing of it is requested - no
|
||||
iframe, no image, no preconnect. That is the whole point of the consent question: the
|
||||
@@ -160,7 +199,7 @@
|
||||
</div>
|
||||
{% elseif type == 'gallery' %}
|
||||
<div id="{{ block.id }}" class="block-gallery{{ designClasses }}" role="group" aria-label="Bildergalerie">
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}{% endfor %}
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
{#
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
instead of being written into this template. #}
|
||||
{% if areas.header|default(null) %}
|
||||
<header class="site-header">
|
||||
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.header.schemaVersion|default(1) >= 12} only %}{% endfor %}
|
||||
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.header.schemaVersion|default(1) >= 12, eagerImage: eagerImage|default(null)} only %}{% endfor %}
|
||||
</header>
|
||||
{% endif %}
|
||||
<main id="main" tabindex="-1">
|
||||
@@ -52,11 +52,11 @@
|
||||
{% for term in postMeta.tag %}<a class="post-tag" href="{{ term.href }}">#{{ term.name }}</a>{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% for block in page.document.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: page.document.schemaVersion|default(1) >= 12} only %}{% endfor %}
|
||||
{% for block in page.document.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: page.document.schemaVersion|default(1) >= 12, eagerImage: eagerImage|default(null)} only %}{% endfor %}
|
||||
</main>
|
||||
{% if areas.footer|default(null) %}
|
||||
<footer class="site-footer">
|
||||
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.footer.schemaVersion|default(1) >= 12} only %}{% endfor %}
|
||||
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.footer.schemaVersion|default(1) >= 12, eagerImage: null} only %}{% endfor %}
|
||||
</footer>
|
||||
{% endif %}
|
||||
{# Privacy statement, imprint and the way back to the consent decision. All three have to be
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Ops\Backup;
|
||||
use App\Ops\CommandRunner;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Backups, and above all the half nobody practises: putting one back.
|
||||
*
|
||||
* The test that matters is testARestorePutsDeletedContentBack - everything else is bookkeeping.
|
||||
* A backup function whose restore has never run is a guess.
|
||||
*/
|
||||
final class BackupTest extends TestCase
|
||||
{
|
||||
private string $root;
|
||||
private Connection $db;
|
||||
private Backup $backup;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->root = str_replace('\\', '/', sys_get_temp_dir()).'/kant-backup-'.bin2hex(random_bytes(6));
|
||||
mkdir($this->root.'/var/media', 0o775, true);
|
||||
file_put_contents($this->root.'/VERSION', "1.1.0\n");
|
||||
// Not part of a backup, and the test says so below.
|
||||
file_put_contents($this->root.'/.env', "APP_SECRET=geheim\n");
|
||||
|
||||
$url = 'sqlite:///'.$this->root.'/var/cms.db';
|
||||
$this->db = ConnectionFactory::create($url);
|
||||
TestSchema::build($this->db);
|
||||
$this->db->insert('website', ['id' => 'default', 'name' => 'Test', 'default_locale' => 'de', 'theme' => '{}']);
|
||||
foreach (['p1' => 'home', 'p2' => 'leistungen'] as $id => $slug) {
|
||||
$this->db->insert('page', ['id' => $id, 'slug' => $slug, 'path' => $slug, 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
|
||||
}
|
||||
file_put_contents($this->root.'/var/media/bild-480.webp', 'erstes bild');
|
||||
|
||||
$this->backup = new Backup($this->root, $url, $this->root.'/var/media', $this->db, new CommandRunner());
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->db->close();
|
||||
gc_collect_cycles();
|
||||
$this->remove($this->root);
|
||||
}
|
||||
|
||||
public function testABackupHoldsTheDatabaseTheMediaAndAManifestButNoSecrets(): void
|
||||
{
|
||||
$result = $this->backup->create('Test');
|
||||
$path = $result['path'];
|
||||
|
||||
self::assertFileExists($path.'/cms.db');
|
||||
self::assertFileExists($path.'/medien/bild-480.webp');
|
||||
self::assertFileExists($path.'/'.Backup::MANIFEST);
|
||||
// The secret stays out: a backup gets downloaded and passed around.
|
||||
self::assertFileDoesNotExist($path.'/.env');
|
||||
self::assertFileDoesNotExist($path.'/.env.local');
|
||||
|
||||
$manifest = $result['manifest'];
|
||||
self::assertSame('1.1.0', $manifest['fassung']);
|
||||
self::assertSame(2, $manifest['inhalt']['seiten']);
|
||||
self::assertSame(1, $manifest['medien']['dateien']);
|
||||
self::assertSame(hash_file('sha256', $path.'/cms.db'), $manifest['datenbank']['sha256']);
|
||||
self::assertContains('.env', $manifest['enthaelt_nicht']);
|
||||
|
||||
// The copy is a usable database, not a file that merely exists.
|
||||
$copy = ConnectionFactory::create('sqlite:///'.$path.'/cms.db');
|
||||
self::assertSame(2, (int) $copy->fetchOne('SELECT COUNT(*) FROM page'));
|
||||
$copy->close();
|
||||
|
||||
$listed = $this->backup->all();
|
||||
self::assertCount(1, $listed);
|
||||
self::assertTrue($listed[0]['intact']);
|
||||
self::assertGreaterThan(0, $listed[0]['bytes']);
|
||||
}
|
||||
|
||||
public function testARestorePutsDeletedContentBack(): void
|
||||
{
|
||||
$backup = $this->backup->create('vor dem Unfall');
|
||||
|
||||
// The accident: a page and an image are gone.
|
||||
$this->db->executeStatement('DELETE FROM page WHERE id = ?', ['p2']);
|
||||
unlink($this->root.'/var/media/bild-480.webp');
|
||||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
|
||||
|
||||
$result = $this->backup->restore($backup['name']);
|
||||
self::assertTrue($result['ok'], $this->explain($result));
|
||||
|
||||
// A fresh connection, because the file underneath was replaced.
|
||||
$after = ConnectionFactory::create('sqlite:///'.$this->root.'/var/cms.db');
|
||||
self::assertSame(2, (int) $after->fetchOne('SELECT COUNT(*) FROM page'));
|
||||
self::assertSame('leistungen', $after->fetchOne('SELECT path FROM page WHERE id = ?', ['p2']));
|
||||
$after->close();
|
||||
self::assertFileExists($this->root.'/var/media/bild-480.webp');
|
||||
|
||||
// A safety copy of the state before the restore was taken first.
|
||||
self::assertContains('Aktuellen Stand sichern', array_column($result['steps'], 'step'));
|
||||
self::assertCount(2, $this->backup->all(), 'The safety copy is a backup of its own.');
|
||||
}
|
||||
|
||||
public function testARestoreKeepsFilesThatWereAddedAfterTheBackup(): void
|
||||
{
|
||||
$backup = $this->backup->create('Test');
|
||||
// Uploaded after the backup: a restore must not delete it, or the operation meant to
|
||||
// prevent loss would cause one.
|
||||
file_put_contents($this->root.'/var/media/spaeter-480.webp', 'neueres bild');
|
||||
|
||||
self::assertTrue($this->backup->restore($backup['name'])['ok']);
|
||||
self::assertFileExists($this->root.'/var/media/spaeter-480.webp');
|
||||
self::assertFileExists($this->root.'/var/media/bild-480.webp');
|
||||
}
|
||||
|
||||
public function testATamperedBackupIsNotRestored(): void
|
||||
{
|
||||
$backup = $this->backup->create('Test');
|
||||
file_put_contents($backup['path'].'/cms.db', 'kaputt');
|
||||
|
||||
self::assertFalse($this->backup->all()[0]['intact']);
|
||||
$result = $this->backup->restore($backup['name']);
|
||||
self::assertFalse($result['ok']);
|
||||
self::assertSame('Unversehrt', $this->firstFailed($result)['step']);
|
||||
// Nothing was touched - not even a safety copy, because it never got that far.
|
||||
self::assertCount(1, $this->backup->all());
|
||||
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
|
||||
}
|
||||
|
||||
public function testABackupFromANewerVersionIsRefusedUnlessForced(): void
|
||||
{
|
||||
$backup = $this->backup->create('Test');
|
||||
// The installation goes back to an older version, as after a rollback.
|
||||
file_put_contents($this->root.'/VERSION', "1.0.0\n");
|
||||
|
||||
$refused = $this->backup->restore($backup['name']);
|
||||
self::assertFalse($refused['ok']);
|
||||
self::assertSame('Fassung passt', $this->firstFailed($refused)['step']);
|
||||
self::assertStringContainsString('Erst aktualisieren', $this->firstFailed($refused)['detail']);
|
||||
|
||||
// With force it runs, because sometimes that is exactly what is wanted.
|
||||
self::assertTrue($this->backup->restore($backup['name'], true)['ok']);
|
||||
}
|
||||
|
||||
public function testAMissingBackupIsSaidSoRatherThanGuessed(): void
|
||||
{
|
||||
$result = $this->backup->restore('sicherung-gibt-es-nicht');
|
||||
self::assertFalse($result['ok']);
|
||||
self::assertSame('Sicherung finden', $this->firstFailed($result)['step']);
|
||||
}
|
||||
|
||||
public function testRotationKeepsTheNewestAndRemovesTheRest(): void
|
||||
{
|
||||
$names = [];
|
||||
for ($index = 0; $index < 4; ++$index) {
|
||||
$names[] = $this->backup->create('Test', new \DateTimeImmutable('2026-09-1'.$index.' 10:00:00', new \DateTimeZone('UTC')))['name'];
|
||||
}
|
||||
self::assertCount(4, $this->backup->all());
|
||||
|
||||
$removed = $this->backup->rotate(2);
|
||||
$left = array_column($this->backup->all(), 'name');
|
||||
self::assertCount(2, $left);
|
||||
// The newest two survive; rotation without a limit would let the disk fill up and the
|
||||
// next backup fail exactly when it is needed.
|
||||
self::assertSame([$names[3], $names[2]], $left);
|
||||
self::assertSame([$names[1], $names[0]], $removed);
|
||||
}
|
||||
|
||||
public function testTwoBackupsInTheSameSecondGetTwoDirectories(): void
|
||||
{
|
||||
// This is not academic: a restore takes a safety copy, and that lands in the same second
|
||||
// as the backup it is about to restore.
|
||||
$moment = new \DateTimeImmutable('2026-09-12 10:00:00', new \DateTimeZone('UTC'));
|
||||
$first = $this->backup->create('Test', $moment);
|
||||
$second = $this->backup->create('Test', $moment);
|
||||
|
||||
self::assertNotSame($first['name'], $second['name']);
|
||||
self::assertSame('sicherung-20260912-100000', $first['name']);
|
||||
self::assertSame('sicherung-20260912-100000-2', $second['name']);
|
||||
self::assertCount(2, $this->backup->all());
|
||||
}
|
||||
|
||||
private function firstFailed(array $result): array
|
||||
{
|
||||
foreach ($result['steps'] as $step) {
|
||||
if (!$step['ok']) {
|
||||
return $step;
|
||||
}
|
||||
}
|
||||
self::fail('Kein gescheiterter Schritt: '.$this->explain($result));
|
||||
}
|
||||
|
||||
private function explain(array $result): string
|
||||
{
|
||||
return implode("\n", array_map(
|
||||
static fn (array $step): string => sprintf('%s %s: %s', $step['ok'] ? 'ok' : 'FEHLER', $step['step'], $step['detail']),
|
||||
$result['steps'],
|
||||
));
|
||||
}
|
||||
|
||||
private function remove(string $path): void
|
||||
{
|
||||
if (!is_dir($path)) {
|
||||
@unlink($path);
|
||||
|
||||
return;
|
||||
}
|
||||
foreach (scandir($path) ?: [] as $entry) {
|
||||
if ('.' !== $entry && '..' !== $entry) {
|
||||
$this->remove($path.'/'.$entry);
|
||||
}
|
||||
}
|
||||
@rmdir($path);
|
||||
}
|
||||
}
|
||||
+391
-9
@@ -14,6 +14,8 @@ use App\Content\PostRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\RichText;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\StyleCompiler;
|
||||
use App\Content\StyleSchema;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidMenu;
|
||||
@@ -1301,16 +1303,19 @@ final class CmsTest extends TestCase
|
||||
$second = DemoContent::starter('Zweite Fassung');
|
||||
$this->pages->saveDraft('page-1', $first, 0);
|
||||
$this->pages->saveDraft('page-1', $second, 1);
|
||||
// The history is paged now, so it says how many there are instead of stopping silently.
|
||||
$revisions = $this->pages->revisions('page-1');
|
||||
self::assertSame(['Zweite Fassung', 'Erste Fassung'], array_column($revisions, 'title'), 'Newest first.');
|
||||
self::assertTrue($revisions[0]['draft']);
|
||||
self::assertSame(['Zweite Fassung', 'Erste Fassung'], array_column($revisions['items'], 'title'), 'Newest first.');
|
||||
self::assertSame(2, $revisions['total']);
|
||||
self::assertSame(1, $revisions['pages']);
|
||||
self::assertTrue($revisions['items'][0]['draft']);
|
||||
|
||||
$oldest = end($revisions);
|
||||
$oldest = end($revisions['items']);
|
||||
$this->pages->restoreRevision('page-1', $oldest['id'], 2);
|
||||
self::assertSame('Erste Fassung', $this->pages->findForEditor('page-1')['document']['title']);
|
||||
// Restoring adds a revision instead of overwriting one, so nothing is lost.
|
||||
self::assertCount(3, $this->pages->revisions('page-1'));
|
||||
self::assertSame('Zweite Fassung', $this->pages->revisions('page-1')[1]['title']);
|
||||
self::assertSame(3, $this->pages->revisions('page-1')['total']);
|
||||
self::assertSame('Zweite Fassung', $this->pages->revisions('page-1')['items'][1]['title']);
|
||||
|
||||
$foreign = $this->pages->saveDraft('page-2', DemoContent::starter('Fremd'), 0);
|
||||
$this->expectException(InvalidPage::class);
|
||||
@@ -1511,13 +1516,18 @@ final class CmsTest extends TestCase
|
||||
// Nothing was written, so the section still holds its original block.
|
||||
self::assertSame('section', $this->partials->find($id)['document']['blocks'][0]['type']);
|
||||
|
||||
// Only one header and one footer.
|
||||
// A second header is allowed now that pages can choose one - it keeps its own address,
|
||||
// while the first one stays "default" and is what a page without a choice gets.
|
||||
$this->partials->create('header', 'landing', 'Kopf für Landingpages', ['schemaVersion' => 11, 'title' => 'x', 'description' => 'x', 'blocks' => []]);
|
||||
self::assertSame(['default' => 'Kopfbereich', 'landing' => 'Kopf für Landingpages'], $this->partials->choices('header'));
|
||||
self::assertSame('Kopf für Landingpages', $this->partials->findByKind('header', 'landing')['name']);
|
||||
// The same address twice stays refused.
|
||||
try {
|
||||
$this->partials->create('header', 'zweiter', 'Zweiter Kopf', ['schemaVersion' => 11, 'title' => 'x', 'description' => 'x', 'blocks' => []]);
|
||||
self::fail('Expected a second header to be refused.');
|
||||
$this->partials->create('header', 'landing', 'Noch einer', ['schemaVersion' => 11, 'title' => 'x', 'description' => 'x', 'blocks' => []]);
|
||||
self::fail('Expected a duplicate address to be refused.');
|
||||
} catch (InvalidPage) {
|
||||
}
|
||||
// And they cannot be deleted, only emptied.
|
||||
// The standard area cannot be deleted, only emptied.
|
||||
$this->expectException(InvalidPage::class);
|
||||
$this->partials->delete($this->partials->findByKind('header')['id']);
|
||||
}
|
||||
@@ -1774,6 +1784,378 @@ final class CmsTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function testEveryLongListSaysHowMuchThereIsAndLetsYouReachIt(): void
|
||||
{
|
||||
// Thirty revisions: a fixed cap would show some and say nothing about the rest.
|
||||
for ($index = 1; $index <= 30; ++$index) {
|
||||
$this->pages->saveDraft('page-1', DemoContent::starter('Fassung '.$index), $index - 1);
|
||||
}
|
||||
$first = $this->pages->revisions('page-1', 1, 25);
|
||||
self::assertCount(25, $first['items']);
|
||||
self::assertSame(30, $first['total']);
|
||||
self::assertSame(2, $first['pages']);
|
||||
self::assertSame('Fassung 30', $first['items'][0]['title'], 'Newest first.');
|
||||
$second = $this->pages->revisions('page-1', 2, 25);
|
||||
self::assertCount(5, $second['items']);
|
||||
self::assertSame('Fassung 1', end($second['items'])['title']);
|
||||
// A page beyond the end shows the last one instead of an empty screen.
|
||||
self::assertSame(2, $this->pages->revisions('page-1', 99, 25)['page']);
|
||||
|
||||
// The same for redirects, which may run into the thousands.
|
||||
for ($index = 1; $index <= 12; ++$index) {
|
||||
$this->redirects->save('alt-'.$index, 'home', 301);
|
||||
}
|
||||
$redirects = $this->redirects->all(1, 5);
|
||||
self::assertCount(5, $redirects['items']);
|
||||
self::assertSame(12, $redirects['total']);
|
||||
self::assertSame(3, $redirects['pages']);
|
||||
self::assertCount(2, $this->redirects->all(3, 5)['items']);
|
||||
}
|
||||
|
||||
public function testTheFirstImageOfAPageIsNotLazy(): void
|
||||
{
|
||||
$document = DemoContent::starter('Mit Bildern');
|
||||
$document['schemaVersion'] = 3;
|
||||
foreach (['erstes', 'zweites'] as $id) {
|
||||
$document['blocks'][0]['children'][] = [
|
||||
'id' => $id, 'type' => 'image',
|
||||
'props' => ['src' => '/assets/image-placeholder.svg', 'alt' => 'Ein Bild', 'decorative' => false, 'width' => 800, 'height' => 600],
|
||||
];
|
||||
}
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
$html = $this->request('/')->getContent();
|
||||
|
||||
// The first one is usually the largest thing above the fold; deferring it delays exactly
|
||||
// the pixel a visitor waits for.
|
||||
self::assertMatchesRegularExpression('/<img[^>]*id="erstes"|id="erstes"[^>]*>/', $html);
|
||||
$first = $this->imageTag($html, 'erstes');
|
||||
$second = $this->imageTag($html, 'zweites');
|
||||
self::assertStringContainsString('loading="eager"', $first);
|
||||
self::assertStringContainsString('fetchpriority="high"', $first);
|
||||
self::assertStringContainsString('loading="lazy"', $second);
|
||||
self::assertStringNotContainsString('fetchpriority', $second);
|
||||
}
|
||||
|
||||
public function testAPageDoesNotAskTheDatabaseOverAndOver(): void
|
||||
{
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
$response = $this->request('/');
|
||||
$queries = (int) $response->headers->get('X-Kant-Queries');
|
||||
|
||||
// A ceiling, not an exact number: the point is that settings and theme are read once per
|
||||
// request instead of once per caller. Before that they were read eighteen times.
|
||||
self::assertGreaterThan(0, $queries, 'The counter has to be wired up at all.');
|
||||
self::assertLessThanOrEqual(12, $queries, sprintf('Die Startseite braucht %d Abfragen.', $queries));
|
||||
}
|
||||
|
||||
/** The img tag belonging to one block id, so the attributes can be read for that one image. */
|
||||
private function imageTag(string $html, string $id): string
|
||||
{
|
||||
$figure = strpos($html, 'id="'.$id.'"');
|
||||
self::assertNotFalse($figure, 'Bild '.$id.' fehlt.');
|
||||
$start = strpos($html, '<img', $figure);
|
||||
self::assertNotFalse($start);
|
||||
|
||||
return substr($html, $start, (int) strpos($html, '>', $start) - $start);
|
||||
}
|
||||
|
||||
public function testAPageCanUseAnotherHeaderOrNoneAtAll(): void
|
||||
{
|
||||
// The seeded areas plus a second header for landing pages.
|
||||
$this->partials->create('header', 'landing', 'Landing-Kopf', [
|
||||
'schemaVersion' => 11, 'title' => 'Landing', 'description' => 'x',
|
||||
'blocks' => [['id' => 'landing-text', 'type' => 'text', 'props' => ['text' => 'Nur ein Logo']]],
|
||||
]);
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
|
||||
// By default the standard header is used.
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('area-header-name', $html);
|
||||
self::assertStringNotContainsString('landing-text', $html);
|
||||
|
||||
// The page can ask for the other one.
|
||||
$version = (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']);
|
||||
$this->pages->saveAreas('page-1', 'landing', null, $version);
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('landing-text', $html);
|
||||
self::assertStringNotContainsString('area-header-name', $html);
|
||||
|
||||
// Or for none at all - a landing page without navigation.
|
||||
$this->pages->saveAreas('page-1', 'none', 'none', $version + 1);
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringNotContainsString('site-header', $html);
|
||||
self::assertStringNotContainsString('site-footer', $html);
|
||||
self::assertStringContainsString('Deine Inhalte', $html, 'The page itself is still there.');
|
||||
|
||||
// An address that no longer exists falls back to the standard area instead of silently
|
||||
// losing the header.
|
||||
$this->pages->saveAreas('page-1', 'weg', null, $version + 2);
|
||||
self::assertStringContainsString('area-header-name', $this->request('/')->getContent());
|
||||
|
||||
// A stale version is refused, and an invalid address too.
|
||||
$this->expectException(StaleWrite::class);
|
||||
$this->pages->saveAreas('page-1', null, null, $version);
|
||||
}
|
||||
|
||||
public function testAnInvalidAreaAddressIsRefused(): void
|
||||
{
|
||||
$this->expectException(InvalidPage::class);
|
||||
$this->pages->saveAreas('page-1', 'Kein Slug!', null, 0);
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string, bool}> */
|
||||
public static function menuLayouts(): iterable
|
||||
{
|
||||
yield 'horizontal' => ['horizontal', true];
|
||||
yield 'vertical' => ['vertical', true];
|
||||
yield 'burger without a script' => ['burger', true];
|
||||
yield 'invented' => ['mega', false];
|
||||
}
|
||||
|
||||
#[DataProvider('menuLayouts')]
|
||||
public function testAMenuCanBeLaidOutInThreeWays(string $layout, bool $accepted): void
|
||||
{
|
||||
$document = DemoContent::starter('Mit Menü');
|
||||
$document['schemaVersion'] = 16;
|
||||
$document['blocks'][0]['children'][] = ['id' => 'nav', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => $layout]];
|
||||
if (!$accepted) {
|
||||
$this->expectException(InvalidDocument::class);
|
||||
$this->pages->saveDraft('page-1', $document, 0);
|
||||
|
||||
return;
|
||||
}
|
||||
$this->menus->save('header', 'Hauptnavigation', [['label' => 'Start', 'type' => 'page', 'level' => 1, 'pageId' => 'page-1', 'position' => 10]], 0);
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('class="block-menu menu-'.$layout, $html);
|
||||
// The burger opens with a details element, so it works on a page that runs no script.
|
||||
self::assertSame('burger' === $layout, str_contains($html, 'menu-burger'));
|
||||
self::assertStringContainsString("script-src 'none'", (string) $this->request('/')->headers->get('Content-Security-Policy'));
|
||||
}
|
||||
|
||||
public function testAMenuLayoutNeedsSchemaSixteen(): void
|
||||
{
|
||||
$document = DemoContent::starter('Zu alt');
|
||||
$document['schemaVersion'] = 15;
|
||||
$document['blocks'][0]['children'][] = ['id' => 'nav', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'burger']];
|
||||
$this->expectException(InvalidDocument::class);
|
||||
$this->pages->saveDraft('page-1', $document, 0);
|
||||
}
|
||||
|
||||
public function testTheGlobalContentWidthCanMakeThePageWiderNotOnlyNarrower(): void
|
||||
{
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
// The stylesheet takes the number from the theme instead of a hard 1180px, which capped
|
||||
// every larger value and made the setting look broken.
|
||||
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
|
||||
self::assertStringContainsString('.shell{width:min(var(--k-container,1180px),calc(100% - 64px))', $css);
|
||||
|
||||
$theme = $this->themes->load();
|
||||
$this->themes->save($theme['colors'], $theme['fonts'], [...$theme['base'], 'containerWidth' => '1440px']);
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('--k-container:1440px', $html);
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string, bool}> */
|
||||
public static function sectionWidths(): iterable
|
||||
{
|
||||
yield 'normal' => ['normal', true];
|
||||
yield 'narrow' => ['narrow', true];
|
||||
yield 'wide' => ['wide', true];
|
||||
yield 'edge to edge' => ['full', true];
|
||||
yield 'invented' => ['riesig', false];
|
||||
}
|
||||
|
||||
#[DataProvider('sectionWidths')]
|
||||
public function testASectionCanRunEdgeToEdge(string $width, bool $accepted): void
|
||||
{
|
||||
$document = DemoContent::starter('Breite');
|
||||
$document['blocks'][0]['layout'] = ['tone' => 'paper', 'width' => $width];
|
||||
if (!$accepted) {
|
||||
$this->expectException(InvalidDocument::class);
|
||||
$this->pages->saveDraft('page-1', $document, 0);
|
||||
|
||||
return;
|
||||
}
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
self::assertStringContainsString('width-'.$width, $this->request('/')->getContent());
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string, bool}> */
|
||||
public static function heights(): iterable
|
||||
{
|
||||
// 100vh is wrong on a phone - it means the viewport without the address bar. The unit
|
||||
// people actually want is dvh, so it has to be allowed.
|
||||
yield 'dynamic viewport height' => ['100dvh', true];
|
||||
yield 'small viewport height' => ['100svh', true];
|
||||
yield 'large viewport height' => ['100lvh', true];
|
||||
yield 'classic viewport height' => ['100vh', true];
|
||||
yield 'pixels' => ['640px', true];
|
||||
yield 'invented unit' => ['100qh', false];
|
||||
yield 'no unit' => ['100', false];
|
||||
}
|
||||
|
||||
#[DataProvider('heights')]
|
||||
public function testAFullHeightSectionIsPossibleWithTheUnitThatWorksOnPhones(string $height, bool $accepted): void
|
||||
{
|
||||
$document = DemoContent::starter('Hoch');
|
||||
$document['schemaVersion'] = 10;
|
||||
$document['blocks'][0]['style'] = ['desktop' => ['minHeight' => $height, 'display' => 'flex', 'alignItems' => 'center']];
|
||||
if (!$accepted) {
|
||||
$this->expectException(InvalidDocument::class);
|
||||
$this->pages->saveDraft('page-1', $document, 0);
|
||||
|
||||
return;
|
||||
}
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('min-height:'.$height, $html);
|
||||
// Full height alone would put the content at the top; the flex properties centre it.
|
||||
self::assertStringContainsString('display:flex;align-items:center', $html);
|
||||
}
|
||||
|
||||
public function testAFullBleedImageFillsTheSection(): void
|
||||
{
|
||||
$document = DemoContent::starter('Bildfläche');
|
||||
$document['schemaVersion'] = 15;
|
||||
$document['blocks'][0]['layout'] = ['tone' => 'paper', 'width' => 'full'];
|
||||
$document['blocks'][0]['style'] = ['desktop' => ['padding' => ['top' => '0', 'bottom' => '0']]];
|
||||
$document['blocks'][0]['children'][] = [
|
||||
'id' => 'flaeche', 'type' => 'image',
|
||||
'props' => ['src' => '/assets/image-placeholder.svg', 'alt' => 'Weite Landschaft', 'decorative' => false, 'width' => 2400, 'height' => 1200],
|
||||
'style' => ['desktop' => ['width' => '100%', 'objectFit' => 'cover', 'imageHeight' => '100dvh']],
|
||||
];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('width-full', $html);
|
||||
self::assertStringContainsString('#flaeche{width:100%}', $html);
|
||||
// The height and the crop belong on the image itself, not on its figure.
|
||||
self::assertStringContainsString('#flaeche img{object-fit:cover;height:100dvh}', $html);
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string, array<string, mixed>, list<string>}> */
|
||||
public static function newBlocks(): iterable
|
||||
{
|
||||
yield 'a decorative icon is hidden from screen readers' => [
|
||||
'icon', ['icon' => 'star', 'size' => 48, 'decorative' => true, 'label' => '', 'href' => ''],
|
||||
['class="block-icon icon-size-48', 'aria-hidden="true"'],
|
||||
];
|
||||
yield 'a meaningful icon carries its label as text' => [
|
||||
'icon', ['icon' => 'phone', 'size' => 32, 'decorative' => false, 'label' => 'Anrufen', 'href' => '/kontakt'],
|
||||
['<a id="i1"', 'href="/kontakt"', '<span class="visually-hidden">Anrufen</span>'],
|
||||
];
|
||||
yield 'a progress bar states its value in the markup' => [
|
||||
'progress', ['label' => 'Fortschritt', 'value' => 70, 'showValue' => true],
|
||||
['role="meter"', 'aria-valuenow="70"', 'progress-fill-70', '70 %'],
|
||||
];
|
||||
yield 'a slider is a scroll container with a class per device' => [
|
||||
'slider', ['desktopVisible' => 3, 'tabletVisible' => 2, 'mobileVisible' => 1, 'snap' => true],
|
||||
['class="block-slider slide-desktop-3 slide-tablet-2 slide-mobile-1 slider-snap"', 'tabindex="0"', 'role="group"'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('newBlocks')]
|
||||
public function testTheNewBlocksRenderWhatTheyPromise(string $type, array $props, array $expected): void
|
||||
{
|
||||
$document = DemoContent::starter('Mit '.$type);
|
||||
$document['schemaVersion'] = 15;
|
||||
$document['blocks'][] = ['id' => 'i1', 'type' => $type, 'props' => $props];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
$html = $this->request('/')->getContent();
|
||||
foreach ($expected as $needle) {
|
||||
self::assertStringContainsString($needle, $html, $type);
|
||||
}
|
||||
// No script is needed for any of them: the public page still forbids scripting.
|
||||
self::assertStringContainsString("script-src 'none'", (string) $this->request('/')->headers->get('Content-Security-Policy'));
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string, array<string, mixed>}> */
|
||||
public static function refusedNewBlocks(): iterable
|
||||
{
|
||||
// A size without a class would leave the icon without dimensions, so only the listed ones.
|
||||
yield 'icon size off the list' => ['icon', ['icon' => 'star', 'size' => 37, 'decorative' => true, 'label' => '']];
|
||||
yield 'unknown icon' => ['icon', ['icon' => 'erfunden', 'size' => 48, 'decorative' => true, 'label' => '']];
|
||||
yield 'meaningful icon without a label' => ['icon', ['icon' => 'star', 'size' => 48, 'decorative' => false, 'label' => '']];
|
||||
yield 'decorative icon with a label' => ['icon', ['icon' => 'star', 'size' => 48, 'decorative' => true, 'label' => 'Doch etwas']];
|
||||
yield 'progress beyond a hundred' => ['progress', ['label' => 'X', 'value' => 140, 'showValue' => true]];
|
||||
yield 'progress with a text value' => ['progress', ['label' => 'X', 'value' => '70', 'showValue' => true]];
|
||||
yield 'slider with seven visible' => ['slider', ['desktopVisible' => 7, 'tabletVisible' => 2, 'mobileVisible' => 1, 'snap' => true]];
|
||||
yield 'slider missing a device' => ['slider', ['desktopVisible' => 3, 'tabletVisible' => 2, 'snap' => true]];
|
||||
}
|
||||
|
||||
#[DataProvider('refusedNewBlocks')]
|
||||
public function testRefusedNewBlocksNeverReachADocument(string $type, array $props): void
|
||||
{
|
||||
$document = DemoContent::starter('Mit '.$type);
|
||||
$document['schemaVersion'] = 15;
|
||||
$document['blocks'][] = ['id' => 'i1', 'type' => $type, 'props' => $props];
|
||||
$this->expectException(InvalidDocument::class);
|
||||
$this->pages->saveDraft('page-1', $document, 0);
|
||||
}
|
||||
|
||||
public function testTheNewBlocksNeedSchemaFifteen(): void
|
||||
{
|
||||
$document = DemoContent::starter('Zu alt');
|
||||
$document['schemaVersion'] = 14;
|
||||
$document['blocks'][] = ['id' => 'i1', 'type' => 'icon', 'props' => ['icon' => 'star', 'size' => 48, 'decorative' => true, 'label' => '']];
|
||||
$this->expectException(InvalidDocument::class);
|
||||
$this->pages->saveDraft('page-1', $document, 0);
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{array<string, mixed>, string}> */
|
||||
public static function styleDeclarations(): iterable
|
||||
{
|
||||
// A border width per side is spelled border-top-width, not border-width-top. Written the
|
||||
// wrong way the browser drops the declaration, so per-side borders had no effect at all.
|
||||
yield 'border width per side' => [['borderWidth' => ['top' => '2px', 'left' => '4px']], '#b1{border-top-width:2px;border-left-width:4px}'];
|
||||
yield 'padding keeps its own spelling' => [['padding' => ['top' => '12px']], '#b1{padding-top:12px}'];
|
||||
yield 'corners' => [['borderRadius' => ['topLeft' => '18px']], '#b1{border-top-left-radius:18px}'];
|
||||
yield 'outline' => [['outlineWidth' => '3px', 'outlineColor' => '#2c6b4f', 'outlineOffset' => '2px'], '#b1{outline-width:3px;outline-color:#2c6b4f;outline-offset:2px}'];
|
||||
yield 'position and layer' => [['position' => 'absolute', 'top' => '0', 'right' => '12px', 'zIndex' => '5'], '#b1{position:absolute;top:0;right:12px;z-index:5}'];
|
||||
yield 'flex item' => [['alignSelf' => 'center', 'flexGrow' => '2', 'flexBasis' => '40%'], '#b1{align-self:center;flex-grow:2;flex-basis:40%}'];
|
||||
yield 'separate gaps' => [['rowGap' => '8px', 'columnGap' => '24px'], '#b1{row-gap:8px;column-gap:24px}'];
|
||||
yield 'inner shadow' => [['shadowY' => '4px', 'shadowBlur' => '12px', 'shadowInset' => 'innen'], '#b1{box-shadow:inset 0 4px 12px 0 #00000026}'];
|
||||
yield 'outer shadow stays outer' => [['shadowY' => '4px'], '#b1{box-shadow:0 4px 0 0 #00000026}'];
|
||||
yield 'text shadow' => [['textShadowY' => '2px', 'textShadowBlur' => '6px', 'textShadowColor' => '#00000080'], '#b1{text-shadow:0 2px 6px #00000080}'];
|
||||
// The filter order is fixed by the compiler, not by the order someone set the values in.
|
||||
yield 'filters in a fixed order' => [['grayscale' => '0.4', 'blur' => '2', 'brightness' => '1.1'], '#b1{filter:blur(2px) brightness(1.1) grayscale(0.4)}'];
|
||||
yield 'hue rotation' => [['hueRotate' => '-30'], '#b1{filter:hue-rotate(-30deg)}'];
|
||||
yield 'blend mode' => [['mixBlendMode' => 'multiply'], '#b1{mix-blend-mode:multiply}'];
|
||||
// These belong on the image inside the block: object-fit on a figure does nothing.
|
||||
yield 'image properties target the image' => [['objectFit' => 'cover', 'imageRatio' => '16/9'], '#b1 img{object-fit:cover;aspect-ratio:16/9}'];
|
||||
yield 'image radius and border' => [['imageRadius' => ['topLeft' => '12px'], 'imageBorderWidth' => ['top' => '3px'], 'imageBorderColor' => '#2c6b4f'], '#b1 img{border-top-left-radius:12px;border-top-width:3px;border-color:#2c6b4f}'];
|
||||
yield 'both at once, in two rules' => [['padding' => ['top' => '8px'], 'objectFit' => 'contain'], '#b1{padding-top:8px}#b1 img{object-fit:contain}'];
|
||||
}
|
||||
|
||||
#[DataProvider('styleDeclarations')]
|
||||
public function testEveryStylePropertyCompilesToTheDeclarationItPromises(array $style, string $expected): void
|
||||
{
|
||||
$compiler = new StyleCompiler();
|
||||
self::assertSame($expected, $compiler->compileDocument([
|
||||
'blocks' => [['id' => 'b1', 'type' => 'image', 'style' => ['desktop' => $style]]],
|
||||
]));
|
||||
// And what compiles has to survive the validator, or the editor could offer it in vain.
|
||||
$document = DemoContent::starter('Mit Stil');
|
||||
$document['schemaVersion'] = 10;
|
||||
$document['blocks'][0]['style'] = ['desktop' => $style];
|
||||
$this->pages->saveDraft('page-1', $document, 0);
|
||||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', ['page-1']));
|
||||
}
|
||||
|
||||
public function testTheStyleSchemaAndTheEditorPanelKnowTheSameGroups(): void
|
||||
{
|
||||
$groups = [];
|
||||
foreach (StyleSchema::properties() as $property) {
|
||||
$groups[$property['group']] = true;
|
||||
}
|
||||
$panel = (string) file_get_contents(__DIR__.'/../public/assets/design-panel.js');
|
||||
foreach (array_keys($groups) as $group) {
|
||||
// A group the panel does not know would hold its properties in an unnamed accordion.
|
||||
self::assertMatchesRegularExpression('/
|
||||
'.$group.': /', $panel, sprintf('Die Gruppe „%s“ fehlt im Panel.', $group));
|
||||
}
|
||||
}
|
||||
|
||||
public function testALegalPathMustPointAtAPageThatExists(): void
|
||||
{
|
||||
foreach (['privacyPath', 'imprintPath'] as $key) {
|
||||
|
||||
@@ -16,6 +16,7 @@ use DoctrineMigrations\Version20260912000900;
|
||||
use DoctrineMigrations\Version20260912001000;
|
||||
use DoctrineMigrations\Version20260912001100;
|
||||
use DoctrineMigrations\Version20260912001200;
|
||||
use DoctrineMigrations\Version20260912001300;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
@@ -43,6 +44,7 @@ final class TestSchema
|
||||
Version20260912001000::class,
|
||||
Version20260912001100::class,
|
||||
Version20260912001200::class,
|
||||
Version20260912001300::class,
|
||||
] as $migration) {
|
||||
(new $migration($db, new NullLogger()))->up($schema);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EditorHistory, createBlock, locate, insertBlock, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, blockPath, insertMedia, setEffect, isPluginBlock, blockLabel, blockIcon, canContain, isContainer, setStyle, effectiveStyle, hoverStyle, resetStyleDevice, clearStyle, copyStyle } from '../public/assets/editor-model.js';
|
||||
import { EditorHistory, createBlock, locate, insertBlock, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, insertRow, blockPath, insertMedia, setEffect, isPluginBlock, blockLabel, blockIcon, canContain, isContainer, setStyle, effectiveStyle, hoverStyle, resetStyleDevice, clearStyle, copyStyle } from '../public/assets/editor-model.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { designCss } from '../scripts/build-design.mjs';
|
||||
|
||||
@@ -233,6 +233,11 @@ test('a new tab group starts with two tabs and accepts nothing but tabs', () =>
|
||||
|
||||
history.undo();
|
||||
assert.deepEqual(history.document, fixture());
|
||||
|
||||
// On a page without a main heading the hero brings one.
|
||||
const empty = new EditorHistory({ schemaVersion: 1, title: 'Leer', description: '', blocks: [] });
|
||||
const first = empty.change(doc => insertPreset(doc, 'hero', null));
|
||||
assert.equal(locate(empty.document.blocks, first).block.children[0].props.level, 1);
|
||||
});
|
||||
|
||||
test('free styles inherit from the wider device, reset individually and leave no skeleton', () => {
|
||||
@@ -268,3 +273,62 @@ test('free styles inherit from the wider device, reset individually and leave no
|
||||
for (let i = 0; i < 7; i++) history.undo();
|
||||
assert.deepEqual(history.document, fixture());
|
||||
});
|
||||
|
||||
test('a row arrives with its columns, inside the selected container', () => {
|
||||
const history = new EditorHistory(fixture());
|
||||
|
||||
// With a section selected, the row becomes a child of it - that is where people expect it.
|
||||
const rowId = history.change(doc => insertRow(doc, 3, 's1'));
|
||||
const row = locate(history.document.blocks, rowId);
|
||||
assert.equal(row.block.type, 'row');
|
||||
assert.equal(row.parent.id, 's1');
|
||||
assert.equal(row.block.children.length, 3);
|
||||
assert.ok(row.block.children.every(child => child.type === 'column'));
|
||||
assert.equal(row.block.layout.design.desktop.columns, 'three');
|
||||
assert.equal(row.block.layout.design.mobile.columns, 'one');
|
||||
// Column counts live in the design tokens, which need schema v2.
|
||||
assert.ok(history.document.schemaVersion >= 2);
|
||||
|
||||
// With a heading selected, the row is placed after it rather than inside it.
|
||||
const second = history.change(doc => insertRow(doc, 2, 'h1'));
|
||||
const placed = locate(history.document.blocks, second);
|
||||
assert.equal(placed.parent.id, 's1');
|
||||
assert.equal(placed.siblings[placed.index - 1].id, 'h1');
|
||||
assert.equal(placed.block.children.length, 2);
|
||||
|
||||
// Out of range counts are clamped instead of producing nonsense. Two statements, because
|
||||
// history.change replaces the document: reading blocks in the same expression reads the old one.
|
||||
const wideId = history.change(doc => insertRow(doc, 99, 's2'));
|
||||
assert.equal(locate(history.document.blocks, wideId).block.children.length, 6);
|
||||
const narrowId = history.change(doc => insertRow(doc, 0, 's2'));
|
||||
assert.equal(locate(history.document.blocks, narrowId).block.children.length, 2, 'An unusable number falls back to two.');
|
||||
|
||||
// Each insertion is one history entry.
|
||||
for (let i = 0; i < 4; i++) history.undo();
|
||||
assert.deepEqual(history.document, fixture());
|
||||
});
|
||||
|
||||
test('the hero preset assembles a full screen section that centres its content', () => {
|
||||
const history = new EditorHistory(fixture());
|
||||
const id = history.change(doc => insertPreset(doc, 'hero', 's1'));
|
||||
const hero = locate(history.document.blocks, id).block;
|
||||
|
||||
assert.equal(hero.type, 'section');
|
||||
assert.equal(hero.layout.width, 'full', 'Edge to edge, or it is not full screen.');
|
||||
// dvh, not vh: on a phone 100vh is taller than the screen.
|
||||
assert.equal(hero.style.desktop.minHeight, '100dvh');
|
||||
assert.equal(hero.style.mobile.minHeight, '80dvh');
|
||||
assert.equal(hero.style.desktop.display, 'flex');
|
||||
assert.equal(hero.style.desktop.alignItems, 'center');
|
||||
assert.equal(hero.style.desktop.backgroundSize, 'cover');
|
||||
assert.ok(hero.style.desktop.overlayOpacity > 0, 'An overlay, so text stays readable on a photo.');
|
||||
assert.deepEqual(hero.children.map(child => child.type), ['heading', 'text', 'button']);
|
||||
// The fixture already has a main heading, so the hero takes a second level one: a page has
|
||||
// exactly one h1, and a document with two would be refused on save.
|
||||
assert.equal(hero.children[0].props.level, 2);
|
||||
// Free styles need schema v10.
|
||||
assert.ok(history.document.schemaVersion >= 10);
|
||||
|
||||
history.undo();
|
||||
assert.deepEqual(history.document, fixture());
|
||||
});
|
||||
Reference in new issue
Block a user