Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a044e59e9 | ||
|
|
149e1fe520 | ||
|
|
b783d9d1e5 | ||
|
|
05ea703655 | ||
|
|
d1ac9d760b | ||
|
|
7c3c8671a7 | ||
|
|
9726af9254 | ||
|
|
7225ae052e | ||
|
|
0e2334d30f | ||
|
|
22e4ada830 | ||
|
|
af5a2cef86 | ||
|
|
d295757ce0 | ||
|
|
4153ba0f65 | ||
|
|
d6d476177a | ||
|
|
ea3dd4ddf2 | ||
|
|
c517173579 | ||
|
|
c5e85e4db0 | ||
|
|
43c20b14c7 | ||
|
|
b218dc79ff | ||
|
|
aaea385422 | ||
|
|
9f1ac39251 | ||
|
|
de1e66350b | ||
|
|
74766f6684 | ||
|
|
6c477a52be | ||
|
|
e0d06ac44c | ||
|
|
a2dfd0f796 | ||
|
|
7c6b2a9ec8 | ||
|
|
074ef0e7e5 | ||
|
|
35d8270afd | ||
|
|
f2ef2a4901 | ||
|
|
f0b634f0c1 | ||
|
|
0d6c1a17af | ||
|
|
84e88c6636 | ||
|
|
1b22265a54 | ||
|
|
5a5933f8cd | ||
|
|
b2c4f45474 | ||
|
|
2715d329ef | ||
|
|
1b164f4c86 | ||
|
|
5f23194760 | ||
|
|
e6dcc8e823 | ||
|
|
e21dff91fe | ||
|
|
70283db20a | ||
|
|
efa285c0d0 | ||
|
|
e34b4dca67 | ||
|
|
c3bff7d936 |
No files matched your search
@@ -4,3 +4,6 @@ DATABASE_URL="sqlite:///%kernel.project_dir%/var/cms.db"
|
||||
SITE_ORIGIN=http://127.0.0.1:8080
|
||||
MEDIA_DIRECTORY="%kernel.project_dir%/var/media"
|
||||
MAILER_DSN=sendmail://default
|
||||
# Die Adresse der Verwaltung. Aenderbar mit `php bin/console cms:admin:path <neu>`;
|
||||
# der Befehl schreibt .env.local und prueft, dass die Adresse frei ist.
|
||||
ADMIN_PATH=admin
|
||||
@@ -0,0 +1,14 @@
|
||||
# Vorlage für eine Installation. Der Installer schreibt daraus die echte .env und erzeugt das
|
||||
# Geheimnis selbst; von Hand: diese Datei nach .env kopieren, Werte eintragen und
|
||||
# `php bin/setup.php` ausführen, das APP_SECRET nach .env.local schreibt.
|
||||
APP_ENV=prod
|
||||
APP_DEBUG=0
|
||||
DATABASE_URL="sqlite:///%kernel.project_dir%/var/cms.db"
|
||||
# Für MySQL stattdessen, mit den Zugangsdaten des Hostings:
|
||||
# DATABASE_URL="mysql://benutzer:passwort@127.0.0.1:3306/datenbank?charset=utf8mb4"
|
||||
SITE_ORIGIN=https://example.de
|
||||
MEDIA_DIRECTORY="%kernel.project_dir%/var/media"
|
||||
MAILER_DSN=sendmail://default
|
||||
# Die Adresse der Verwaltung. Ohne Eintrag: /admin. Aenderbar mit
|
||||
# `php bin/console cms:admin:path <neu>` - der Befehl schreibt sie nach .env.local.
|
||||
#ADMIN_PATH=admin
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
- run: php vendor/bin/doctrine-migrations migrate --no-interaction
|
||||
- run: php bin/console cms:seed-demo
|
||||
- run: php bin/console cms:seed-demo
|
||||
- run: php bin/console lint:twig templates && php bin/console lint:yaml config && php bin/console lint:container
|
||||
- run: php bin/console lint:twig templates && php bin/console lint:yaml config --parse-tags && php bin/console lint:container
|
||||
- run: composer build
|
||||
browser:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -9,3 +9,9 @@
|
||||
/.php-cs-fixer.cache
|
||||
*.log
|
||||
/config/reference.php
|
||||
/var/releases/
|
||||
|
||||
# Die Schriften der Pilotwebsite liegen im Repository (siehe public/assets/fonts/LIZENZ.md),
|
||||
# damit auf einem frischen Server nichts fehlt und nichts von Google geladen wird. Andere
|
||||
# Installationen holen ihre eigenen mit `cms:fonts:fetch` - deren Dateien gehoeren nicht hierher.
|
||||
/public/assets/build/
|
||||
@@ -18,7 +18,9 @@ php -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0
|
||||
|
||||
Danach `http://127.0.0.1:8080/` öffnen. Der Routerskript-Parameter ist nicht optional: der eingebaute PHP-Server liefert vorhandene Dateien selbst aus und antwortet für jeden fehlenden Pfad mit Dateiendung mit 404, sodass `/sitemap.xml`, `/robots.txt`, `/llms.txt` und `/feed.xml` den Frontcontroller nie erreichen. Auf echtem Hosting erledigt das `public/.htaccess`; `bin/dev-router.php` bildet dasselbe lokal nach. Unter Windows hier vorhanden: `C:\xampp\php\php.exe` (8.2.12) und `C:\php84\php.exe` (8.4.22). Die Befehle verwenden die PHP-Version im PATH; alternativ den vollständigen Pfad einsetzen. Diese vorhandenen Patchstände sind kein empfohlenes Produktionsimage.
|
||||
|
||||
`bin/setup.php` erzeugt ein zufälliges lokales Secret und `var/`; bestehende `.env.local` bleibt erhalten. Die Datenbank liegt in `var/cms.db`. Wiederholtes Seeding erhält existierende Seiten einschließlich ihrer Bearbeitungen. Es gibt keinen öffentlichen Installer und keine Standardzugangsdaten. Editor-Endpunkte benötigen Anmeldung, Berechtigung und bei POST einen CSRF-Token.
|
||||
`bin/setup.php` erzeugt ein zufälliges lokales Secret und `var/`; bestehende `.env.local` bleibt erhalten. Die Datenbank liegt in `var/cms.db`. Wiederholtes Seeding erhält existierende Seiten einschließlich ihrer Bearbeitungen. Standardzugangsdaten gibt es nicht.
|
||||
|
||||
**Installation ohne Konsole** läuft über `/install`: Systemprüfung, Datenbank, Migration, Website, erstes Konto. Der Installer antwortet nur, solange keine Installation existiert – sobald ein Konto angelegt ist, liefert jede Adresse darunter 404, und die Sperre hängt am Zustand der Datenbank, nicht an einer Datei. Solange nichts installiert ist, führt jede Adresse dorthin. Davor genügt eine einzelne hochgeladene Datei: `install.php` aus einem Release lädt das Archiv von einer fest eingetragenen Adresse, vergleicht die SHA-256, entpackt es und löscht sich selbst. Ohne passende Prüfsumme wird nichts entpackt. Editor-Endpunkte benötigen Anmeldung, Berechtigung und bei POST einen CSRF-Token.
|
||||
|
||||
## Anmelden und bearbeiten
|
||||
|
||||
@@ -40,7 +42,17 @@ Ein Speicherkonflikt überschreibt nichts: lokale Änderungen als JSON sichern u
|
||||
|
||||
Rechts zwischen **Inhalt** und **Design** wechseln. Unter Design je nach Block Abstände, Schriftgröße/-stärke, Zeilenhöhe, Ausrichtung, Spalten und Ecken wählen. Oben **Desktop/Tablet/Mobil** bestimmt das bearbeitete Gerät: Desktop ist die Basis, Tablet überschreibt bis 1024 px, Mobil bis 650 px. „Übernehmen“ entfernt einen eigenen Wert und stellt die Vererbung wieder her. Gerätewerte lassen sich gemeinsam zurücksetzen und rückgängig machen. Farben und Inhaltsbreite einer Sektion stehen unter Inhalt.
|
||||
|
||||
Die Vorschau rendert mit echten 1240/768/390 px Layoutbreite und passt sich durch Skalierung an den verfügbaren Platz an. Links lassen sich fertige Intro-, Leistungs- und Kontaktabschnitte einfügen. Der Werkzeugkasten kann für mehr Platz geschlossen werden. Die Struktursuche findet Text oder Elementtypen und zeigt den Pfad zum Treffer; Dreiecke klappen Teilbäume ein. Oberhalb der Vorschau führen die Pfadschaltflächen direkt zum übergeordneten Element.
|
||||
Die Vorschau rendert auf Desktop mit mindestens 1240 px Layoutbreite und wächst bei mehr Platz mit. Tablet/Mobil verwenden 768/390 px; bei weniger Platz wird die Vorschau passend skaliert. Links lassen sich fertige Intro-, Leistungs- und Kontaktabschnitte einfügen. Der Werkzeugkasten kann für mehr Platz geschlossen werden. Die Struktursuche findet Text oder Elementtypen und zeigt den Pfad zum Treffer; Dreiecke klappen Teilbäume ein. Oberhalb der Vorschau führen die Pfadschaltflächen direkt zum übergeordneten Element.
|
||||
|
||||
### Platz und Blockgestaltung im Editor (1.6.1)
|
||||
|
||||
**Leere Layouts ab 1.6.2:** Sektionen und Spalten zeigen eine umrahmte Einfügefläche. In einer leeren Sektion direkt **1–4 Spalten** wählen; in einer Spalte **Inhalt hinzufügen** drücken und links das gewünschte Element auswählen. Alternativ ein Element auf die Fläche ziehen: die Markierung nennt das genaue Ziel, am Vorschaurand wird mitgescrollt. Neue Container werden in den sichtbaren Ausschnitt gebracht. Diese Hilfen erscheinen nur im Editor, verschwinden nach dem Befüllen und kehren bei Undo zurück.
|
||||
|
||||
**Neue Sektion auf Seitenebene** steht dauerhaft oben links und fügt nach dem aktuellen Seitenabschnitt ein, unabhängig von der ausgewählten Verschachtelung oder einem gesetzten Plus-Einfügepunkt. Auch „Sektion“ im Werkzeugkasten verwendet ohne expliziten Einfügepunkt die Seitenebene. Verschachtelte Sektionen sind weiterhin über die Plus-Einfügepunkte oder Drag-and-drop möglich.
|
||||
|
||||
Die Trennlinien links und rechts der Vorschau lassen sich ziehen. Mit Tab fokussieren und mit den Pfeiltasten ändern; Umschalt vergrößert den Schritt, Home/End wählen die Grenzen, Doppelklick setzt die jeweilige Breite zurück. Die Breiten werden im Browser behalten und bei schmalen Fenstern an den verfügbaren Platz angepasst. Auf dem Telefon stehen die Panels unter der Vorschau.
|
||||
|
||||
Text und Überschriften nutzen die Containerbreite. Die Sektion wählt unter **Inhalt → Inhaltsbreite** zwischen Websitebreite und randloser Ausgabe. **Design → Größe → Maximale Breite** begrenzt ein einzelnes Element ausdrücklich, etwa auf `60ch`; „Ohne Begrenzung“ hebt einen eigenen Grenzwert auf. Der Feinschliff zeigt passende Optionen für Text, Bild oder Container zuerst. **Alle Eigenschaften anzeigen** macht die übrigen Optionen zugänglich. Bereits gespeicherte Werte bleiben sichtbar.
|
||||
|
||||
## Produktionsmodus lokal prüfen
|
||||
|
||||
@@ -55,7 +67,7 @@ Unter Linux dieselben PHP-Argumente mit vorangestelltem `APP_ENV=prod APP_DEBUG=
|
||||
|
||||
## Medien, Galerien und Effekte
|
||||
|
||||
Links im Werkzeugkasten **Medienverwaltung öffnen** oder auf der Seitenübersicht **Medienverwaltung** wählen. JPEG/PNG/WebP hochladen, Titel und Alternativtext bearbeiten und speichern. Bilder lassen sich suchen, archivieren und wiederherstellen. Die Dateien werden als optimierte WebP-Varianten gespeichert; Originaldateien werden nicht aufgehoben. Uploadgrenzen: 8 MB, 12 Megapixel und 8000 px Kantenlänge; Hostinglimits können niedriger sein. Bild-URLs sind öffentlich erreichbar, auch wenn sie noch in keinem veröffentlichten Inhalt verwendet werden.
|
||||
Links im Werkzeugkasten **Medienverwaltung öffnen** oder auf der Seitenübersicht **Medienverwaltung** wählen. JPEG/PNG/WebP hochladen, Titel und Alternativtext bearbeiten und speichern. Bilder lassen sich suchen, archivieren und wiederherstellen. Die Dateien werden als optimierte WebP-Varianten in vier Breiten gespeichert (480, 960, 1600, 2400 px; ein schmaleres Bild bekommt weniger Stufen und wird nie hochskaliert). Originaldateien werden nicht aufgehoben. Bildbausteine liefern `srcset`, Hintergrundbilder wählen die Variante je Gerät. Uploadgrenzen: 8 MB, 12 Megapixel und 8000 px Kantenlänge; Hostinglimits können niedriger sein. Bild-URLs sind öffentlich erreichbar, auch wenn sie noch in keinem veröffentlichten Inhalt verwendet werden.
|
||||
|
||||
Für ein einzelnes Bild **Bild aus Mediathek wählen** anklicken. Für eine Galerie den Block **Galerie** einfügen und **Galeriebilder auswählen** öffnen. Mehrere Bilder markieren, Alt-Texte speichern oder die dekorative Verwendung wählen und **Auswahl übernehmen**. Im Strukturbaum einzelne Bilder auswählen, beschriften und sortieren; Reihenfolge und Einfügen lassen sich rückgängig machen. Unter **Design** Spaltenzahl und Abstände pro Gerät einstellen.
|
||||
|
||||
@@ -77,6 +89,7 @@ composer build
|
||||
npm.cmd ci
|
||||
npx.cmd playwright install chromium
|
||||
npm.cmd run test:browser
|
||||
npm.cmd run test:links
|
||||
npm.cmd test
|
||||
npm.cmd run test:editor
|
||||
npm.cmd run test:editor -- --audit
|
||||
@@ -85,9 +98,15 @@ npm.cmd run audit
|
||||
|
||||
Unter Linux/macOS `npm` und `npx` ohne `.cmd`; in CI `npx playwright install --with-deps chromium`. `test:browser` und `audit` benötigen den laufenden lokalen Produktionsmodus auf Port 8080. Ein anderes Ziel lässt sich über `CMS_URL` konfigurieren. `test:editor` startet einen eigenen PHP-Prozess auf einem freien Port mit isolierter Datenbank und zufälligem Testkonto; es ändert keine echten Seiten oder Benutzer. `PHP_BINARY` kann dafür einen alternativen PHP-Pfad angeben. Testdaten bleiben unter `var/browser-tests`, Berichte/Screenshots unter `reports/`. Das Skript erneuert zuvor den Produktionscache.
|
||||
|
||||
**Datenbank:** SQLite ab Fassung 3.16 (Doctrine liest das Schema mit Tabellenfunktionen, die es erst seit dieser Fassung gibt) oder MySQL/MariaDB. Auf älteren Systemen – manche Shared Hoster liefern noch SQLite 3.7 – bleibt MySQL; die Systemprüfung des Installers nennt die gefundene Fassung und bietet SQLite gar nicht erst an, wenn sie zu alt ist.
|
||||
|
||||
Die Verwaltung liegt unter `/admin`. `php bin/console cms:admin:path meine-verwaltung` legt sie auf eine andere Adresse: der Wert landet in `.env.local`, gilt für Routen, Firewall und Schutzkopfzeilen, und wird in der `robots.txt` bewusst nicht genannt. Ohne Argument zeigt der Befehl die aktuelle Adresse.
|
||||
|
||||
`composer format` formatiert PHP. `composer build` wärmt den Symfony-Produktionscache; öffentliche CSS-Dateien sind direkt auslieferbar, es ist kein Frontend-Bundler nötig. Bei Änderungen an `public/assets/design-tokens.json` einmal `npm.cmd run build:design` ausführen und die erzeugte CSS-Datei mitliefern; Node ist auf dem Hosting nicht erforderlich. Nach Änderungen an Produktionskonfiguration/Templates `php bin/console cache:clear --env=prod --no-debug` ausführen. Falls lokale Tests alte Templates verwenden, vor PHPUnit auch `php bin/console cache:clear --env=test --no-debug` ausführen.
|
||||
|
||||
Das Lighthouse-Skript misst die Startseite dreimal je Profil und prüft die Mediane gegen die Projektziele. `test:editor -- --audit` prüft die im isolierten Editortest gestaltete v3-Seite mit Abschnittsvorlage, Galerie, tatsächlichen Bildvarianten und Effekten; Berichte stehen unter `reports/lighthouse-editor`. Uploads und Datenbank des Tests liegen getrennt unter `var/browser-tests`. Diese kleinen Prototypseiten ersetzen nicht die spätere Referenzsuite mit Kundeninhalten, Fotos, Beitrag und Formular. Messgrenzen und Ergebnisse: `docs/QUALITY.md`.
|
||||
`test:links` folgt der Sitemap, sammelt jeden internen Verweis samt Bildern, Stylesheets und Hintergrundbildern und meldet jede Antwort ab 400 – gebaut, nachdem im Nachbau von kant-it.de vier Verweise ins Leere zeigten, die kein Test finden konnte.
|
||||
|
||||
Das Lighthouse-Skript misst dreimal je Profil und prüft die Mediane gegen die Projektziele. Welche Adressen geprüft werden, sagt `CMS_PATHS` (mit Komma getrennt, Standard `/`); in der Git-Bash davor `MSYS_NO_PATHCONV=1` setzen, sonst wird aus `/` ein Windows-Pfad. `test:editor -- --audit` prüft die im isolierten Editortest gestaltete v3-Seite mit Abschnittsvorlage, Galerie, tatsächlichen Bildvarianten und Effekten; Berichte stehen unter `reports/lighthouse-editor`. Uploads und Datenbank des Tests liegen getrennt unter `var/browser-tests`. Diese kleinen Prototypseiten ersetzen nicht die spätere Referenzsuite mit Kundeninhalten, Fotos, Beitrag und Formular. Messgrenzen und Ergebnisse: `docs/QUALITY.md`.
|
||||
|
||||
## Inhalte und Entwicklung
|
||||
|
||||
@@ -95,6 +114,25 @@ Die Formate v1/v2/v3 und erlaubten Blöcke stehen in `src/Content/DocumentValida
|
||||
|
||||
Ein JSON-Dokument lässt sich zusätzlich lokal über `php bin/console cms:save-draft PAGE_ID PFAD_ZUR_DATEI ERWARTETE_VERSION` speichern. Der Befehl gibt die neue Revisions-ID aus. Die redaktionelle Bearbeitung und Veröffentlichung erfolgen jetzt über den geschützten Editor.
|
||||
|
||||
## Wiederkehrende Aufgaben
|
||||
|
||||
Sicherung, geplante Veröffentlichung, Statistik verdichten, Updateprüfung und Aufräumen laufen über einen eingebauten Plan. Auf geteiltem Hosting gibt es oft keinen Cronjob – deshalb stößt hier **die Anfrage** die Arbeit an, nach der ausgelieferten Antwort (`kernel.terminate`), sodass kein Besucher darauf wartet. Der Bildschirm **Verwaltung → Aufgaben** zeigt Plan, letztes Ergebnis und Protokoll und schaltet jede Aufgabe einzeln.
|
||||
|
||||
Vier Auslöser, in aufsteigender Verlässlichkeit:
|
||||
|
||||
1. **Besuch** – jeder Seitenaufruf prüft, ob etwas fällig ist. Der Normalfall kostet eine gelesene Datei (`var/scheduler.json`) und keine Datenbankabfrage. Standardmäßig an.
|
||||
2. **Geöffnete Verwaltung** – das Backend fragt jede Minute nach, solange der Reiter sichtbar ist. Damit läuft der Plan auch auf einer Website ohne Besucher. Standardmäßig an.
|
||||
3. **URL-Cronjob** – wenn der Hoster nur das Aufrufen einer Adresse anbietet: die Adresse steht auf dem Bildschirm „Aufgaben“ (`/cms-cron/<Schlüssel>`, aus `APP_SECRET` abgeleitet), zum Beispiel alle fünf Minuten.
|
||||
4. **Konsole** – `php bin/console cms:tasks:run`, für einen echten Cronjob jede Minute:
|
||||
|
||||
```
|
||||
* * * * * cd /pfad/zur/installation && php bin/console cms:tasks:run
|
||||
```
|
||||
|
||||
`cms:tasks` zeigt denselben Plan wie die Verwaltung (`--log` zusätzlich das Protokoll), `cms:tasks:run --task=backup` startet eine bestimmte Aufgabe sofort, `cms:tasks:run --loop=60 --max-time=3600` läuft als Dauerläufer unter einem Prozessverwalter. Die alten Einzelbefehle `cms:backup`, `cms:stats:prune` und `cms:publish-due` gibt es weiterhin; wer sie in einen Cronjob einträgt, schaltet die entsprechende Aufgabe im Plan besser ab, damit nicht beides läuft.
|
||||
|
||||
**Die Grenze gehört dazu:** ohne Besuch und ohne geöffnetes Backend passiert nichts. Eine Aufgabe für 03:00 Uhr läuft auf einer stillen Website erst beim nächsten Aufruf, vielleicht um 07:15 Uhr. Wer Minutengenauigkeit braucht, richtet Weg 3 oder 4 ein und schaltet den Besuchs-Auslöser ab.
|
||||
|
||||
## PHP-Webhosting
|
||||
|
||||
Anforderungen vor dem Pilotbetrieb beim Anbieter bestätigen:
|
||||
@@ -107,6 +145,6 @@ Anforderungen vor dem Pilotbetrieb beim Anbieter bestätigen:
|
||||
|
||||
Serverkonfiguration über echte Umgebungsvariablen oder eine nicht öffentliche `.env.local`: `APP_ENV=prod`, `APP_DEBUG=0`, eigenes zufälliges `APP_SECRET`, `SITE_ORIGIN=https://eigene-domain.example` und `DATABASE_URL=mysql://USER:URL_ENCODED_PASSWORD@HOST:3306/DATABASE?charset=utf8mb4`. Das ist eine Vorlage, keine funktionierende Zugangskonfiguration.
|
||||
|
||||
Deployment später mit `composer install --no-dev --optimize-autoloader`, Backup, Migration und Cacheaufbau. Composer darf auf einem geeigneten Build-Rechner laufen, sodass das Hosting weder Node noch Composer benötigt; CLI-Zugriff oder ein verlässlich dokumentierter Deploymentweg für Datenbankmigrationen bleibt zu klären. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
|
||||
**Composer wird auf dem Hosting nicht vorausgesetzt.** Ein Release wird auf einem Rechner gebaut, der Composer hat: `php scripts/build-release.php` legt `var/releases/kantsites-<Fassung>.zip` samt `.sha256` an. Das Archiv enthält die Anwendung mit fertigem `vendor/` ohne Entwicklungspakete, aber keine Tests, keine Skripte, kein `var/` und kein `.env`; als Vorlage für die eigene Konfiguration liegt `.env.example` bei. Auf dem Hosting wird es entpackt, `.env.local` angelegt, migriert und der Cache gebaut. Node wird dort ebenfalls nicht gebraucht. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
|
||||
|
||||
Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `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.
|
||||
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 `20260914000100` legt die Tabelle des Aufgabenplans an (neue Tabelle, kein Eingriff in Bestehendes); ohne sie läuft der Plan nicht, meldet das aber, statt eine Seite zu zerstören. 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` erledigt jetzt der eingebaute Aufgabenplan (Abschnitt „Wiederkehrende Aufgaben“); ohne Cronjob genügen Besuche und das geöffnete Backend – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore <name>`. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Ändert ein Update die Abhängigkeiten (`composer.lock`) und ist Composer auf dem Server nicht aufrufbar, bricht der Updater ab und verweist auf das Releasearchiv; Updates ohne Abhängigkeitsänderung laufen auch ohne Composer durch. Ablauf und Grenzen stehen in `docs/UPDATE.md`. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt.
|
||||
@@ -11,6 +11,38 @@
|
||||
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||
$file = __DIR__.'/../public'.$path;
|
||||
if ('/' !== $path && !str_contains($path, '..') && is_file($file)) {
|
||||
// Dieselben Antwortkoepfe wie public/.htaccess sie auf einem Webserver setzt: Stylesheets,
|
||||
// Skripte, Schriften und Bilder tragen ihre Fassung oder ihre Kennung in der Adresse und
|
||||
// duerfen deshalb lange im Browser bleiben. Ohne das misst jede Messung hier etwas anderes
|
||||
// als das, was spaeter ausgeliefert wird - und man optimiert an der falschen Stelle.
|
||||
if (1 === preg_match('/\.(css|js|woff2|webp|png|jpe?g|gif|svg)$/', $path, $treffer) && !str_starts_with($path, '/assets/seite/')) {
|
||||
header('Content-Type: '.match ($treffer[1]) {
|
||||
'css' => 'text/css; charset=UTF-8',
|
||||
'js' => 'text/javascript; charset=UTF-8',
|
||||
'woff2' => 'font/woff2',
|
||||
'svg' => 'image/svg+xml; charset=UTF-8',
|
||||
'jpg', 'jpeg' => 'image/jpeg',
|
||||
default => 'image/'.$treffer[1],
|
||||
});
|
||||
header('Cache-Control: public, max-age=31536000, immutable');
|
||||
// Und ebenso die Komprimierung, die mod_deflate auf einem Webserver uebernimmt: ohne sie
|
||||
// misst man hier eine Stylesheetgroesse, die es im Betrieb nie gibt.
|
||||
$inhalt = (string) file_get_contents($file);
|
||||
if (in_array($treffer[1], ['css', 'js', 'svg'], true) && str_contains($_SERVER['HTTP_ACCEPT_ENCODING'] ?? '', 'gzip')) {
|
||||
$inhalt = (string) gzencode($inhalt, 6);
|
||||
header('Content-Encoding: gzip');
|
||||
header('Vary: Accept-Encoding');
|
||||
}
|
||||
header('Content-Length: '.strlen($inhalt));
|
||||
echo $inhalt;
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
return false; // Let the built-in server deliver the static file.
|
||||
}
|
||||
// Auch die Seiten selbst gehen komprimiert raus, wie auf einem Webserver mit mod_deflate.
|
||||
ini_set('zlib.output_compression', '1');
|
||||
ini_set('zlib.output_compression_level', '6');
|
||||
|
||||
require __DIR__.'/../public/index.php';
|
||||
@@ -11,3 +11,19 @@ foreach (['APP_ENV', 'APP_DEBUG', 'APP_SECRET', 'DATABASE_URL', 'SITE_ORIGIN', '
|
||||
}
|
||||
}
|
||||
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
|
||||
|
||||
// A freshly unpacked installation has no secret, and without one nothing boots - not even the
|
||||
// installer that would explain why. So the first request writes one into .env.local and never
|
||||
// touches it again. If the project directory is not writable, the request still works, but only
|
||||
// this one: the installer's system check says so in a sentence instead of leaving a white page.
|
||||
if (!isset($_SERVER['APP_SECRET']) || '' === $_SERVER['APP_SECRET']) {
|
||||
$secret = bin2hex(random_bytes(32));
|
||||
$file = dirname(__DIR__).'/.env.local';
|
||||
$existing = is_file($file) ? (string) file_get_contents($file) : '';
|
||||
if (false === @file_put_contents($file, $existing.(str_ends_with($existing, "\n") || '' === $existing ? '' : "\n").'APP_SECRET="'.$secret.'"'."\n")) {
|
||||
$secret .= '-nicht-gespeichert';
|
||||
} else {
|
||||
@chmod($file, 0o600);
|
||||
}
|
||||
$_SERVER['APP_SECRET'] = $_ENV['APP_SECRET'] = $secret;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ security:
|
||||
ROLE_PUBLISHER: ROLE_EDITOR
|
||||
firewalls:
|
||||
admin:
|
||||
pattern: ^/admin(?:/|$)
|
||||
pattern: ^/%kant.admin_path%(?:/|$)
|
||||
lazy: true
|
||||
provider: cms_users
|
||||
form_login:
|
||||
@@ -25,5 +25,5 @@ security:
|
||||
target: admin_login
|
||||
enable_csrf: true
|
||||
access_control:
|
||||
- { path: ^/admin/login$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/admin, roles: ROLE_EDITOR }
|
||||
- { path: ^/%kant.admin_path%/login$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/%kant.admin_path%, roles: ROLE_EDITOR }
|
||||
@@ -5,3 +5,6 @@ twig:
|
||||
# Reading the decision needs the current request, so the templates ask the gate directly
|
||||
# instead of every controller passing the same three values along.
|
||||
consentGate: '@App\Content\Consent'
|
||||
# Dasselbe für die Einstellungen der Website: der Kopfbereich fragt, ob er beim
|
||||
# Scrollen kleben soll, und das steht in keinem Dokument.
|
||||
siteSettings: '@App\Content\SiteSettings'
|
||||
+9
-1
@@ -1,3 +1,11 @@
|
||||
controllers:
|
||||
# Erst alle benannten Routen, dann die Auffangroute fuer oeffentliche Seiten. Ohne diese
|
||||
# Reihenfolge wuerde `/verwaltung/partials` als Seitenadresse gelesen, sobald die Verwaltung nicht
|
||||
# mehr `/admin` heisst - die Auffangroute schliesst nur den Standardnamen aus.
|
||||
verwaltung:
|
||||
resource: ../src/Controller/
|
||||
type: attribute
|
||||
exclude: '../src/Controller/PageController.php'
|
||||
|
||||
seiten:
|
||||
resource: ../src/Controller/PageController.php
|
||||
type: attribute
|
||||
@@ -1,7 +1,26 @@
|
||||
parameters:
|
||||
# Die Adresse der Verwaltung. Standard bleibt /admin; wer sie aendert, nimmt Scannern und
|
||||
# Anmeldeversuchen das Ziel - erraten laesst sie sich nicht mehr. Sie steht in der Umgebung und
|
||||
# nicht in der Datenbank, weil die Routen beim Start feststehen muessen. Geaendert wird sie
|
||||
# ueber `php bin/console cms:admin:path <neu>`; der Befehl prueft und raeumt den Cache auf.
|
||||
kant.admin_path: '%env(string:default:kant.admin_path_default:ADMIN_PATH)%'
|
||||
kant.admin_path_default: 'admin'
|
||||
# Wo Schriften und Stylesheets liegen. Im Normalfall im oeffentlichen Ordner; ueber
|
||||
# FONT_DIRECTORY und ASSET_DIRECTORY umstellbar - die Tests nutzen das, damit sie nicht in
|
||||
# das Projekt schreiben, und eine Installation mit schreibgeschuetztem public/ ebenfalls.
|
||||
kant.fonts_dir: '%kernel.project_dir%/public/assets/fonts'
|
||||
kant.asset_dir: '%kernel.project_dir%/public/assets'
|
||||
|
||||
services:
|
||||
_defaults:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
# Jede Aufgabe traegt sich selbst in den Plan ein: eine Klasse, die App\Ops\Tasks\Task
|
||||
# implementiert, bekommt das Etikett und liegt beim naechsten Abgleich in der Tabelle. Eine
|
||||
# neue Aufgabe ist damit eine Datei und keine Liste, die man an drei Stellen nachfuehrt.
|
||||
_instanceof:
|
||||
App\Ops\Tasks\Task:
|
||||
tags: ['kant.task']
|
||||
App\:
|
||||
resource: '../src/'
|
||||
exclude: '../src/{Kernel.php,Domain/}'
|
||||
@@ -15,6 +34,21 @@ services:
|
||||
App\Content\MediaLibrary:
|
||||
arguments:
|
||||
$directory: '%env(resolve:MEDIA_DIRECTORY)%'
|
||||
# Die Schriften liegen im oeffentlichen Ordner: sie sollen vom Webserver direkt ausgeliefert
|
||||
# werden, ohne PHP dazwischen - das ist der Punkt der Uebung.
|
||||
# Die Stellen, die wissen muessen, wie die Verwaltung heisst.
|
||||
App\EventSubscriber\AdminHeadersSubscriber:
|
||||
arguments:
|
||||
$adminPath: '%kant.admin_path%'
|
||||
App\Content\Statistics:
|
||||
arguments:
|
||||
$adminPath: '%kant.admin_path%'
|
||||
App\Content\FontLibrary:
|
||||
arguments:
|
||||
$directory: '%env(default:kant.fonts_dir:FONT_DIRECTORY)%'
|
||||
App\Content\StyleBundle:
|
||||
arguments:
|
||||
$assetDirectory: '%env(default:kant.asset_dir:ASSET_DIRECTORY)%'
|
||||
App\Controller\FormController:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
@@ -22,21 +56,61 @@ services:
|
||||
App\Controller\SeoController:
|
||||
arguments:
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
$adminPath: '%kant.admin_path%'
|
||||
App\Command\HealthCommand:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
App\Controller\SystemController:
|
||||
arguments:
|
||||
$adminPath: '%kant.admin_path%'
|
||||
App\Command\AdminPathCommand:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$adminPath: '%kant.admin_path%'
|
||||
$environment: '%kernel.environment%'
|
||||
App\Ops\ArchiveUpdate:
|
||||
arguments:
|
||||
$root: '%kernel.project_dir%'
|
||||
App\Ops\Updater:
|
||||
arguments:
|
||||
$root: '%kernel.project_dir%'
|
||||
$databaseUrl: '%env(resolve:DATABASE_URL)%'
|
||||
App\EventSubscriber\InstallSubscriber:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
App\EventSubscriber\MaintenanceSubscriber:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$adminPath: '%kant.admin_path%'
|
||||
App\Ops\Installation:
|
||||
arguments:
|
||||
$root: '%kernel.project_dir%'
|
||||
$mediaDirectory: '%env(resolve:MEDIA_DIRECTORY)%'
|
||||
$secret: '%kernel.secret%'
|
||||
App\Ops\Backup:
|
||||
arguments:
|
||||
$root: '%kernel.project_dir%'
|
||||
$databaseUrl: '%env(resolve:DATABASE_URL)%'
|
||||
$mediaDirectory: '%env(resolve:MEDIA_DIRECTORY)%'
|
||||
# Der Aufgabenplan. Die Aufgaben kommen ueber ihr Etikett herein, damit diese Stelle nicht
|
||||
# jedes Mal mitwaechst.
|
||||
App\Ops\Scheduler:
|
||||
arguments:
|
||||
$tasks: !tagged_iterator kant.task
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
App\Ops\Tasks\CleanupTask:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
App\EventSubscriber\TaskWorkerSubscriber:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$adminPath: '%kant.admin_path%'
|
||||
App\Controller\TaskController:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
App\Controller\CronController:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
App\Infrastructure\QueryCounter: ~
|
||||
App\EventSubscriber\QueryHeaderSubscriber:
|
||||
arguments:
|
||||
|
||||
+506
-3
@@ -2,6 +2,28 @@
|
||||
|
||||
Stand: 12.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## ADR 075 – Leere Container sind Arbeitsflächen (14.09.2026)
|
||||
|
||||
Leere Spalten hatten keine Höhe; leere Sektionen waren zwar durch Abstände vorhanden, aber weder als Container erkennbar noch mit einem direkten Einfügeweg versehen. Der lokale Flatsome-3.20.9-Quelltext verwendet für solche Zustände eine eigene sichtbare Inhaltsfläche mit Mindesthöhe und mittigem Einfügewerkzeug (konkrete Fundstellen in `docs/FLATSOME-ABGLEICH.md`).
|
||||
|
||||
`preview-containers.js` ergänzt deshalb erst im vollständig geparsten Vorschau-DOM sichtbare Hilfsflächen für leere Sektionen, Zeilen und Spalten. Eine leere Sektion bietet außerdem Zeilenlayouts mit 1–4 Spalten. Die Hilfen sind echte Buttons mit Tastaturbedienung und eindeutigen Namen, etwa „Spalte 2 von 2: Inhalt hinzufügen“. Sie setzen einen genauen Einfügepunkt oder führen dieselbe Modelltransaktion wie der Werkzeugkasten aus. Abbrechen oder eine andere Auswahl hebt den Einfügepunkt auf.
|
||||
|
||||
Die Editor-CSS gibt den Flächen mindestens 130 Layoutpixel Höhe. Der öffentliche Renderer und seine CSS bleiben unverändert; die Mindesthöhe ist eine Bearbeitungshilfe und wird nicht als Designwert gespeichert. Nach dem ersten Inhalt verschwindet die Hilfe automatisch, Undo stellt sie wieder her. Kein Schemawechsel und keine Datenmigration; bestehende leere Container erhalten die Hilfe allein beim Öffnen. `preview.css` und das neue Modul erhalten die Versionskennung 1.6.2, damit ein bestehender Browsercache die Funktion nicht verdeckt.
|
||||
|
||||
Neu eingefügte Elemente werden nach dem Rendern sichtbar gescrollt. Die Vorschau gilt erst mit geparstem DOM und geladenen Hilfsstilen als bereit. Die Drop-Geometrie wird beim Scrollen und beim Loslassen neu ermittelt, Zielcontainer werden benannt, und nebeneinanderstehende Spalten werden horizontal eingeordnet; mobil gestapelte Spalten vertikal. Ein Ziehen nahe dem Vorschaurand scrollt den Frame, bis der Zeiger den Rand verlässt oder das Ziehen endet. Inhalt und Undo bleiben von bloßem Scrollen unberührt.
|
||||
|
||||
Der Offen-/Geschlossen-Zustand von Feinschliff-Gruppen wird direkt aus ihren bisherigen DOM-Knoten übernommen. Ein verzögertes `toggle`-Ereignis allein reicht nicht: Ein schneller Neuaufbau nach einer Wertänderung kann den Knoten entfernen, bevor das Ereignis ankommt, und würde die gerade geöffnete Gruppe wieder schließen.
|
||||
|
||||
## ADR 074 – Editorfläche, Seitenebene und kontextbezogene Gestaltung (14.09.2026)
|
||||
|
||||
Textblöcke sowie H1/H2 hatten im Basis-CSS feste Höchstbreiten von 690/900/760 px. Sie folgen jetzt der Breite ihres Containers; ausdrücklich gespeicherte `maxWidth`-Werte bleiben wirksam. Sektionsbreiten und Theme-Container bleiben bewusste Layoutentscheidungen. Das verändert gegebenenfalls den Zeilenumbruch bestehender Seiten. Vorschau und öffentliche Ausgabe verwenden dieselbe CSS-Quelle. Kein Dokument wird dafür umgeschrieben, keine Schema- oder Datenbankmigration ist erforderlich. Rückweg: vorheriges Basis-CSS oder eine explizite maximale Breite am betreffenden Block.
|
||||
|
||||
Ein Klick auf „Sektion“ erzeugt eine neue Sektion nach dem obersten Block der aktuellen Auswahl. Die dauerhaft sichtbare Aktion „Neue Sektion auf Seitenebene“ verwirft zuvor gesetzte Einfügepunkte. Bewusst gesetzte Plus-Einfügepunkte und Drag-and-drop dürfen weiterhin verschachteln. Die Einfügeauskunft und die Modellaktion verwenden denselben Zielermittler; Änderungen bleiben einzelne Undo-Schritte.
|
||||
|
||||
Die Seitenleisten sind unabhängig zwischen 240/280 und 560 px verstellbar, soweit wenigstens 280 px für die Vorschau bleiben. Ziehen, Pfeiltasten, Home/End und Doppelklick sind unterstützt. Präferenzen liegen lokal im Browser, außerhalb von Inhalt, Revisionen und Undo. Schmalere Fenster reduzieren die sichtbaren Breiten vorübergehend; bis 850 px folgen die Panels der Vorschau, bis 560 px stehen sie untereinander. Die Vorschau hat keinen zusätzlichen Außenabstand; Desktop wächst oberhalb von 1240 px mit dem verfügbaren Platz, Tablet/Mobil behalten ihre definierten Layoutbreiten.
|
||||
|
||||
Der Feinschliff filtert nach Blocktyp und ordnet passende Gruppen zuerst: Bildausschnitt bei Bildern, Typografie bei Text, Layout bei Containern. „Alle Eigenschaften anzeigen“ öffnet den vollständigen Vertrag. Bereits gespeicherte Eigenschaften bleiben auch außerhalb des üblichen Blockprofils auffindbar; Validierung und gespeichertes Schema ändern sich nicht. Beim Auswahlwechsel beginnt das Panel oben. `auto` beziehungsweise `none` sind für die passenden Größenfelder explizit auswählbar. VERSION 1.6.1 erneuert die Cachekennung aller Editor-Module.
|
||||
|
||||
## ADR 001 – PHP-Webhosting, Symfony 7.4
|
||||
|
||||
Umut hat im laufenden Auftrag ausdrücklich herkömmliches PHP-Webhosting gewählt. Deshalb PHP statt des zunächst aus dem Ordnernamen vermuteten Django-Stacks. Symfony 7.4 LTS benötigt PHP ab 8.2 und bringt Routing, Dependency Injection, Konfiguration und später anschließbare Sicherheitskomponenten mit. Twig maskiert Inhalte standardmäßig. Doctrine DBAL und Migrations übernehmen Datenbankzugriff und Migrationen. Keine eigene Authentifizierung erfinden: vor HTTP-Schreibzugriffen Symfony Security, CSRF-Schutz und serverseitige Rollen ergänzen.
|
||||
@@ -95,7 +117,7 @@ Migration `20260912000300` ergänzt ausschließlich die Tabelle `media`: stabile
|
||||
|
||||
Uploads und Metadatenänderungen verlangen `ROLE_EDITOR` und einen eigenen CSRF-Token. Metadaten nutzen erwartete Versionen; konkurrierende Änderungen liefern 409. Bildtitel/Alt-Text im Medienkatalog sind Vorlagen für künftiges Einfügen. Bereits gespeicherte Seiten behalten ihre kontextbezogenen Texte, Beschriftungen und Bildvarianten. Archivieren blendet Bilder aus der normalen Auswahl aus und ist umkehrbar; bestehende URLs, Seiten und Undo-Stände bleiben funktionsfähig. Es gibt noch keine physische Löschung, Nutzungsübersicht, Ordner oder Katalog-Paginierung.
|
||||
|
||||
Nur tatsächlich decodierbare JPEG-, PNG- und WebP-Dateien werden angenommen. Dateinamen und vom Browser gemeldete MIME-Typen bestimmen weder Speicherpfade noch Dateiformat. Maximal 8 MiB, 12 Millionen Pixel und 8000 px pro Kante; zusätzlich konservative Prüfung gegen das PHP-Speicherlimit. GD decodiert und erzeugt WebP-Varianten bis 480/960/1600 px Breite ohne Hochskalieren. Das hochgeladene Original wird nicht übernommen; Metadaten und angehängte Nutzdaten werden nicht in die Varianten kopiert. EXIF-Orientierung wird bei JPEG berücksichtigt, sofern die EXIF-Erweiterung vorhanden ist. Transparenz wird erhalten; animierte Bilder/Dateianhänge/SVG gehören nicht zum unterstützten Uploadvertrag. Grundlage: [PHP-Bilddecodierung](https://www.php.net/manual/en/function.imagecreatefromstring.php).
|
||||
Nur tatsächlich decodierbare JPEG-, PNG- und WebP-Dateien werden angenommen. Dateinamen und vom Browser gemeldete MIME-Typen bestimmen weder Speicherpfade noch Dateiformat. Maximal 8 MiB, 12 Millionen Pixel und 8000 px pro Kante; zusätzlich konservative Prüfung gegen das PHP-Speicherlimit. GD decodiert und erzeugt WebP-Varianten (Qualität 82) bis 480/960/1600/2400 px Breite ohne Hochskalieren; ein schmaleres Original bekommt entsprechend weniger Stufen, die letzte davon in seiner eigenen Breite. Das hochgeladene Original wird nicht übernommen; Metadaten und angehängte Nutzdaten werden nicht in die Varianten kopiert. EXIF-Orientierung wird bei JPEG berücksichtigt, sofern die EXIF-Erweiterung vorhanden ist. Transparenz wird erhalten; animierte Bilder/Dateianhänge/SVG gehören nicht zum unterstützten Uploadvertrag. Grundlage: [PHP-Bilddecodierung](https://www.php.net/manual/en/function.imagecreatefromstring.php).
|
||||
|
||||
Dateien liegen unter `MEDIA_DIRECTORY`, standardmäßig `var/media` außerhalb des Webroots. Nur zufällige IDs und bekannte Variantengrößen gelangen über `/media/{id}/{width}` mit festem `image/webp`, `nosniff` und unveränderlichem Langzeitcache nach außen. Erweiterungslose URLs funktionieren auch über die Frontcontroller-Weiterleitung des lokalen PHP-Servers; Pfade mit nicht vorhandenen `.webp`-Dateien wurden dort als statische 404 behandelt. Bild-URLs sind öffentlich, auch vor Veröffentlichung einer Seite und nach Archivieren; der Uploaddialog benennt dies. Die Katalog-API bleibt geschützt. Dies ist keine private Dokumentablage.
|
||||
|
||||
@@ -281,7 +303,7 @@ Migration `20260912000900` ergänzt `page.publish_at`, `page.deleted_at` und `re
|
||||
|
||||
`revision.seq` behebt einen echten Fehler, den erst ein Test gezeigt hat: `created_at` hat Sekundengenauigkeit, und mehrere Speichervorgänge fallen in dieselbe Sekunde. Die Reihenfolge im Verlauf war dadurch **nicht eindeutig** – sie richtete sich nach der zufälligen Revisions-ID. Jetzt trägt jede Fassung einen je Seite streng aufsteigenden Zähler; die Migration füllt ihn aus den vorhandenen Zeitstempeln.
|
||||
|
||||
**Geplante Veröffentlichung** setzt `publish_at`. Ein Cron-Aufruf von `cms:publish-due` veröffentlicht, was fällig ist, und löscht den Zeitpunkt. Ohne Cron bleibt die Seite ein Entwurf – die sichere Richtung. Zeitpunkte sind UTC; das steht im Formular, weil ein `datetime-local`-Feld sonst die Zeitzone des Browsers unterstellt. Eine Seite im Papierkorb wird nie durch den Zeitplan veröffentlicht.
|
||||
**Geplante Veröffentlichung** setzt `publish_at`. Die Aufgabe `publish-due` veröffentlicht, was fällig ist, und löscht den Zeitpunkt (ADR 073); ohne einen Auslöser – Besuch, geöffnetes Backend, URL-Cron oder Konsole – bleibt die Seite ein Entwurf, die sichere Richtung. Zeitpunkte sind UTC; das steht im Formular, weil ein `datetime-local`-Feld sonst die Zeitzone des Browsers unterstellt. Eine Seite im Papierkorb wird nie durch den Zeitplan veröffentlicht.
|
||||
|
||||
**Eine frei wählbare Startseite ist nicht umgesetzt.** Der Pfad `home` gilt an elf Stellen als Wurzel der Website – öffentliche Routen, Canonical, Sitemap, `llms.txt`, Menüauflösung. Das zu einer Einstellung zu machen ist ein eigener Schritt; bis dahin behält die Startseite ihre Adresse, statt dass die Hälfte des Systems der Änderung folgt und die andere nicht.
|
||||
|
||||
@@ -438,7 +460,488 @@ Vom Verweis bleibt nur der Host. Die vollständige Adresse, aus der jemand kam,
|
||||
|
||||
**Der Preis steht auf dem Bildschirm.** Aufrufe statt Besucher, keine Verweildauer, keine Absprungrate, kein Klickweg, kein Land. Das ist keine Lücke, sondern die Folge der Entscheidung: all das braucht Wiedererkennung. Wer es braucht, braucht eine Einwilligung – die Gruppe `statistics` dafür gibt es schon, sie wäre dann ein eigener Baustein.
|
||||
|
||||
**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.
|
||||
**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; im Normalbetrieb erledigt das die Aufgabe `stats-prune` des Plans (ADR 073).
|
||||
|
||||
## ADR 073 – Wiederkehrende Aufgaben ohne Cronjob: die Anfrage ist der Auslöser (14.09.2026)
|
||||
|
||||
Bis hierher stand in vier Dokumenten derselbe Satz: „gehört in einen Cronjob". Das ist richtig – und auf dem Hosting, für das dieses CMS gebaut ist, oft nicht einlösbar. Kein SSH, kein Supervisor, manchmal nicht einmal ein Cron-Eintrag im Kundenmenü. Die Folge war nicht „läuft seltener", sondern **läuft nie**: keine Sicherung, keine geplante Veröffentlichung, eine Statistiktabelle, die nur wächst.
|
||||
|
||||
**Die Entscheidung: Was ein Hosting immer hat, sind Anfragen.** Also arbeitet eine Anfrage die Aufgaben ab – in `kernel.terminate`, nach der ausgelieferten Antwort. Unter PHP-FPM ruft Symfony dort `fastcgi_finish_request()` auf: der Browser ist fertig, während hier noch gearbeitet wird. Das ist derselbe Weg, den WordPress (`wp-cron`) und Shopware gehen, und aus demselben Grund.
|
||||
|
||||
Vier Auslöser, eine Mechanik:
|
||||
|
||||
| Auslöser | Wer stößt an | Grenzen je Durchlauf |
|
||||
| --- | --- | --- |
|
||||
| Besuch | jeder Seitenaufruf | 2 Aufgaben, 20 s |
|
||||
| Verwaltung | das geöffnete Backend, im Minutentakt | 3 Aufgaben, 45 s |
|
||||
| URL-Cron | der Hoster ruft `/cms-cron/<Schlüssel>` | 20 Aufgaben, 240 s |
|
||||
| Konsole | `cms:tasks:run`, auch mit `--loop` | frei |
|
||||
|
||||
**Was das nicht kann, steht auf dem Bildschirm und nicht im Kleingedruckten:** Ohne Besuch und ohne geöffnetes Backend passiert nichts. Eine Aufgabe für 03:00 Uhr läuft auf einer stillen Website erst um 07:15 Uhr. Für Sitemap, Aufräumen, Updateprüfung und Sicherung ist das brauchbar; für etwas Minutengenaues ist es das nicht, und dann ist einer der beiden unteren Wege die Antwort. Genau dieser Satz steht im Bildschirm „Aufgaben", weil die Alternative ist, dass jemand später an der falschen Stelle sucht.
|
||||
|
||||
Drei Eigenschaften entscheiden darüber, ob das im Betrieb trägt:
|
||||
|
||||
**1. Der Normalfall kostet keine Datenbankabfrage.** In `var/scheduler.json` steht, wann überhaupt das nächste Mal etwas ansteht, dazu die zwei Schalter. Liegt der Zeitpunkt in der Zukunft, ist der Abonnent nach einem `file_get_contents` fertig. Fehlt die Datei – frische Installation, geleertes `var/`, kein Schreibrecht –, wird die Datenbank gefragt und die Datei neu geschrieben: der teure Weg ist der Ausnahmefall, nicht der Normalfall, und ein schreibgeschütztes `var/` macht das System langsamer, aber nicht falsch.
|
||||
|
||||
**2. Zwei Besucher führen eine Aufgabe nicht zweimal aus.** Wer sie ausführen darf, entscheidet ein bedingtes `UPDATE … WHERE next_run_at <= :jetzt AND (locked_until IS NULL OR locked_until <= :jetzt)`. Das ist auf MySQL wie auf SQLite atomar; wer keine Zeile verändert hat, war zu spät und geht weiter. Deshalb ist der Plan eine **Tabelle** und keine Datei: eine Dateisperre ist auf geteiltem Hosting nicht zuverlässig, ein bedingtes UPDATE ist es. Die Sperre verfällt nach 15 Minuten von selbst, denn ein Prozess, den das Hosting mitten im Lauf abschießt, kann sie nicht mehr aufräumen.
|
||||
|
||||
**3. Eine Aufgabe darf niemals die Website mitreißen.** Der Abonnent fängt alles. Ein Fehlschlag wird zum sichtbaren Zustand mit wachsendem Abstand bis zum nächsten Versuch (Verdopplung, gedeckelt bei sechs Stunden) – sonst fängt eine Aufgabe, die nicht laufen *kann*, bei jedem Seitenaufruf neu an. Fehlt die Tabelle, weil Dateien hochgeladen und Migrationen noch nicht ausgeführt wurden, passiert schlicht nichts.
|
||||
|
||||
**Die Sicherung läuft mit, obwohl sie die schwerste Aufgabe ist.** Sie kopiert Datenbank und Medien, und unter Apache mit mod_php belegt sie dabei einen Prozess, weil es dort kein `fastcgi_finish_request()` gibt – die Seite steht längst im Browser, der Prozess bleibt. Der Handel ist trotzdem eindeutig: eine tägliche Sicherung auf einem Hosting ohne Cron ist mehr wert als gar keine. Wer einen echten Cronjob hat, schaltet den Besuchs-Auslöser ab; der Bildschirm sagt auch das.
|
||||
|
||||
**Der Schlüssel des URL-Cronjobs steht in keiner Datenbank.** Er wird aus `APP_SECRET` abgeleitet (`hash_hmac`, 40 Zeichen), ist damit auf jeder Installation ein anderer, überlebt jedes Update und wechselt genau dann, wenn das Geheimnis wechselt. Ein falscher Schlüssel bekommt 404 und nicht 403: wer probiert, soll nicht erfahren, dass es die Stelle gibt.
|
||||
|
||||
**Eine neue Aufgabe ist eine Datei.** Wer `App\Ops\Tasks\Task` implementiert, bekommt über `_instanceof` ein Etikett, liegt beim nächsten Abgleich in der Tabelle und steht im Bildschirm – ohne eine Liste, die an drei Stellen nachzuführen wäre. Verschwindet die Klasse, verschwindet die Zeile. Mitgeliefert sind fünf: geplante Veröffentlichung (5 Minuten), Statistik verdichten, Sicherung, Updateprüfung und Aufräumen (je täglich).
|
||||
|
||||
## ADR 072 – Drei Fehler einer Familie: dieses Hosting ist nicht mein Rechner (13.09.2026)
|
||||
|
||||
Umut, während er 1.4.4 in Betrieb nahm: „Das buggt doch immer noch" und „Bei Sicherung anlegen kommt auch Fehler 500 – wieso ist das alles verbuggt?" Drei Befunde an einem Abend, und sie gehören zusammen: **Jeder einzelne war auf meinem Rechner unsichtbar.**
|
||||
|
||||
**1. `git` installiert ist nicht dasselbe wie „Arbeitskopie".** `Updater::state()` gab unter dem Schlüssel `git` zurück, ob der *Befehl* `git` aufrufbar ist. Auf Umuts Hosting ist er das (Version 1.8.3.1), nur ist das Verzeichnis eine Installation aus dem Releasearchiv. Also lief jeder Git-Aufruf ins Leere, und in der Verwaltung stand dort, wo eine Fassungsnummer hingehört: `fatal: Not a git repository`. Dazu eine Vorprüfung voller roter Ausrufezeichen für Bedingungen, die für diese Installation gar nicht gelten. Die Software war in Ordnung und sah kaputt aus – für den Betreiber ist das dasselbe. Der Zustand heißt jetzt `worktree` und wird getrennt von `git` erhoben; ohne Arbeitskopie verschwinden die Git-Karten vollständig, und an ihrer Stelle steht ein Satz: „Eingerichtet über das Releasearchiv."
|
||||
|
||||
**2. `VACUUM INTO` gibt es erst seit SQLite 3.27.** Die Sicherung benutzt es, weil eine Dateikopie während eines Schreibvorgangs unbrauchbar sein kann (ADR 038). Auf Umuts Hosting läuft eine ältere Fassung: alt genug für die Installation (die braucht 3.16, ADR aus 1.4.2), zu alt für die Sicherung. Ergebnis: 500 beim Knopf „Sicherung jetzt anlegen". Jetzt wird bei älteren Fassungen unter einer Schreibsperre kopiert – `PRAGMA wal_checkpoint`, `BEGIN IMMEDIATE`, kopieren, `COMMIT` –, und der Test setzt die Schwelle künstlich hoch, damit dieser Weg wirklich läuft und nicht nur auf fremden Servern.
|
||||
|
||||
**3. Ein unerwarteter Fehler darf keine 500-Seite sein.** Der Sicherungsknopf fing `InvalidPage` ab, also die *erwarteten* Fälle. Alles andere – alte Datenbankfassung, fehlendes Schreibrecht, voller Datenträger – fiel durch. Genau das sind aber die Fälle, die nur auf fremden Servern auftreten. Der Knopf fängt jetzt jeden Fehler und sagt, woran es lag.
|
||||
|
||||
**Was ich daraus mitnehme:** Jeder dieser Fehler war eine *Umgebungsdifferenz*, und keiner davon war durch mehr Tests auf meinem Rechner zu finden. Was hilft, ist zweierlei – eine nachgestellte Installation aus dem echten Releasearchiv (so wurde ADR 071 gefunden) und die Regel, dass ein Knopf in der Verwaltung **niemals** mit einer 500-Seite enden darf, sondern mit einem Satz. Das Zweite ist billiger und fängt auch die Fälle, an die ich nicht gedacht habe.
|
||||
|
||||
## ADR 071 – Ein Buchstabe, der die ganze Funktion unbrauchbar machte (13.09.2026)
|
||||
|
||||
Umut hat 1.4.3 installiert, den Updateknopf gedrückt und eine **500-Seite** bekommen. Sein Satz dazu: „Das gefällt mir alles ehrlich gesagt nicht. Ich möchte es so einfach wie WordPress." Beides berechtigt.
|
||||
|
||||
Nachgestellt wurde es mit einer echten 1.4.3-Installation aus dem Releasearchiv, eigener Datenbank, eigenem Port. Der Fehler stand dann im Protokoll:
|
||||
|
||||
```
|
||||
Twig\Error\RuntimeError: Key "step" for sequence/mapping with keys
|
||||
"label, ok, detail" does not exist in "admin/system.html.twig" at line 137
|
||||
```
|
||||
|
||||
Der Updater über Git nennt einen Schritt `step`, der neue über das Archiv nannte ihn `label`, und beide legen ihr Ergebnis unter demselben Sitzungsschlüssel ab. Die Vorlage liest `step`. **Das Update lief vollständig durch** – alle neun Schritte in Ordnung, Dateien getauscht, `var/` und `.env` unberührt – und dann starb die Seite, die das berichten sollte. Damit war auch der zweite Knopf unerreichbar, der die Migrationen ausführt, und die Website blieb im Wartungszustand.
|
||||
|
||||
**Warum kein Test das gefunden hat.** Zehn Tests prüfen den Archiv-Updater, und alle prüfen sein Rückgabe-Array. Keiner hat die Seite gerendert, die dieses Array anzeigt. Ein Ergebnis, das stimmt, und eine Anzeige, die es nicht lesen kann – das ist dieselbe Familie wie das `og:image`, das gespeichert wurde und nie ankam (ADR 056), und wie das Stylesheet, das nichts mehr tat (ADR 064). **Die Naht zwischen zwei richtigen Hälften ist die Stelle, an der es bricht.** Der Test dazu rendert jetzt `/admin/system` mit den Schritten eines echten Laufs in der Sitzung; gegengeprüft, indem der Fehler noch einmal eingebaut wurde – der Test wird rot.
|
||||
|
||||
Der Schlüssel heißt jetzt in beiden Updatern `step`. Nicht die Vorlage wurde nachsichtig gemacht: Zwei Dinge derselben Art beschreiben sich gleich, sonst wiederholt sich das beim nächsten Anzeigeort.
|
||||
|
||||
**Und der zweite Teil von Umuts Satz.** Nach dem Tausch stand die Verwaltung mit zwei Karten da, die beide dasselbe wollten – „Migrationen ausführen und freigeben" und „Migrationen stehen aus". Solange das Archiv-Update auf seinen zweiten Schritt wartet, gehört der Knopf an eine Stelle. Zwei Knöpfe, die dasselbe tun, sind eine Frage zu viel.
|
||||
|
||||
## ADR 070 – Die zwei Lücken, die erst das Veröffentlichen zeigte (13.09.2026)
|
||||
|
||||
Umut, kurz nach dem Release: „Wie spiele ich das Update manuell ein?" Die Frage war berechtigt, und die Antwort hatte zwei Lücken.
|
||||
|
||||
**Erstens: Es gab keinen Weg, Migrationen im Browser auszuführen.** Wer die Dateien über FTP hochlädt – und das muss genau einmal jeder tun, dessen Installation die Updatekarte noch nicht kennt –, hat danach neuen Code und eine alte Datenbank. Dafür gab es nur die Konsole, also genau das, was auf geteiltem Hosting fehlt. Der Bildschirm „System" zeigt jetzt eine Karte, **sobald etwas aussteht**, nennt die Namen der Migrationen und führt sie auf Knopfdruck aus. Das Lesen der Liste ist gekapselt: Eine Datenbank, die die Migrationstabelle noch nicht hat, darf nicht der Grund sein, dass dieser Bildschirm gar nicht mehr erscheint.
|
||||
|
||||
**Zweitens: „Die Releaseseite antwortet nicht" war drei Fehler in einem Satz.** Beim Gegenprüfen des frischen Releases holte der Updater von diesem Rechner gar nichts – nicht weil die Leitung zu wäre, sondern weil diesem PHP ein **Zertifikatsspeicher** fehlt (`curl.cainfo` leer). Auf einem Webhosting ist das der häufigste der drei Gründe und der einzige, den man in einer Minute behebt; er muss also dastehen. `Download` merkt sich seither, woran es lag, und der Schritt in der Verwaltung trägt den Grund mit.
|
||||
|
||||
**Und daraus folgte gleich noch etwas:** Bei genau diesem Fehler lohnt der zweite Weg. Der Dateizeiger benutzt die Einstellungen von OpenSSL, und die sind manchmal gesetzt, wo curl nichts findet – auf diesem Rechner **funktioniert der Abruf dadurch jetzt**. Was nicht passiert, ist das Naheliegende: Die Prüfung wird nicht abgeschaltet. Ein Update, das ohne Zertifikatsprüfung geladen wird, kann unterwegs ausgetauscht worden sein, und dann installiert der Knopf fremden Code.
|
||||
|
||||
## ADR 069 – Das größte Bild über der Falz ist gar kein Bild (13.09.2026)
|
||||
|
||||
Nachgemessen vor dem Livegang, auf der Startseite des Piloten statt auf einer Testseite: **Leistung 96 (Desktop) und 98 (Mobil)**, alles andere 100. Der Grund stand im Bericht: `LCP request discovery` – das größte Element über der Falz ist dort kein `<img>`, sondern der **Hintergrund des ersten Abschnitts**.
|
||||
|
||||
Das ist ein struktureller Unterschied, kein Zufall: Ein `<img>` steht im HTML, der Browser sieht es beim ersten Durchlesen und beginnt zu laden. Ein Hintergrundbild steht im Stylesheet – der Browser erfährt erst von ihm, wenn er das CSS geholt und geparst hat. Die Regel aus ADR 039 („das erste Bild einer Seite lädt sofort") griff genau deshalb nicht: Sie sucht nach einem Bildbaustein, und es gab keinen.
|
||||
|
||||
**Drei Vorabverweise mit Medienabfragen, nicht ein `imagesrcset`.** Das war die einzige Entscheidung, die wirklich eine war. Ein `srcset` wählt nach Breite, die Regeln des Compilers wählen nach Gerät – bei einem 1200 px breiten Fenster nähme das eine die 1600er Datei und das andere die 1920er, und der Browser lüde **zwei** Bilder statt einem. Dann kostet das Vorabholen mehr, als es bringt – genau der Fehler, der in diesem Projekt schon einmal fünf Punkte gekostet hat. Die drei Verweise tragen deshalb dieselben Grenzen wie der Compiler (`StyleCompiler::PRELOAD_DEVICES`), und die Variantenwahl ist eine einzige öffentliche Methode, die beide Seiten benutzen. Der Test vergleicht nicht nur, dass Verweise da sind, sondern dass **jeder Verweis dieselbe Datei meint wie die Regel**.
|
||||
|
||||
**Gemessen danach: Desktop 97, Mobil 99**, LCP 1,3 s → 1,1 s. Kein Sprung auf 100 – was bleibt, ist das blockierende Stylesheet (300 ms FCP), und das ist zum großen Teil der Entwicklungsserver ohne Kompression, siehe `docs/QUALITY.md`.
|
||||
|
||||
**Und ein Nebenfund, der mehr brachte als der Hauptpunkt.** Der Kopf fragte die Mediathek nach demselben Bild, das der Compiler ohnehin schon dreimal gesucht hatte – je Gerät eine Regel, je Regel eine Abfrage. `MediaLibrary::find()` merkt sich jetzt, wonach es in dieser Anfrage schon gefragt wurde: **Startseite 19 → 15 Abfragen**, und zwar einschließlich der Abfrage, die der Vorabverweis neu gebracht hat. Der Merkzettel lebt nur so lange wie die Anfrage, also kann er nicht veralten; beim Hochladen und Ändern wird er trotzdem geleert, weil derselbe Aufruf danach noch liest.
|
||||
|
||||
## ADR 068 – Update aus dem Releasearchiv, in zwei Schritten (13.09.2026)
|
||||
|
||||
Umut: „Dann Updater für Hoster optimieren?!" Der Updater aus ADR 035 setzt eine Git-Arbeitskopie voraus. Auf geteiltem Hosting gibt es die nicht – dort wird über das Releasearchiv installiert, und die Verwaltung sagte danach „Dieses Verzeichnis ist keine Git-Arbeitskopie". Also kein Update, außer von Hand über FTP. Das ist genau der Fall, für den dieses CMS gedacht ist.
|
||||
|
||||
`ArchiveUpdate` geht denselben Weg wie die Erstinstallation: Archiv laden, Prüfsumme vergleichen, entpacken, Dateien ersetzen, migrieren. Drei Unterschiede trennen ein Update von einer Installation, und jeder davon ist eine Entscheidung:
|
||||
|
||||
**Was dem Betreiber gehört, wird nicht angefasst.** `var/`, `.env`, `.env.local`, die hochgeladenen Schriften und das erzeugte Stylesheet-Bündel bleiben stehen. Deshalb wird `public/` als einziges Verzeichnis **Datei für Datei zusammengeführt** statt ersetzt: dort liegen die Dateien der Anwendung und die des Betreibers nebeneinander. Ein `rename()` des ganzen Ordners wäre eine Zeile weniger Code und hätte die Schriften gelöscht.
|
||||
|
||||
**Der alte Stand wird beiseitegelegt, nicht gelöscht.** Er liegt unter `var/update/<Zeitstempel>/alt/`, bis jemand ihn entfernt. Scheitert das Ersetzen mittendrin, schiebt der Updater zurück, was er schon bewegt hat, und nimmt den Wartungszustand wieder heraus.
|
||||
|
||||
**Es sind zwei Knöpfe, und das ist kein Umweg.** Der erste ersetzt die Dateien, der zweite führt die Migrationen aus. Dazwischen liegt eine neue Anfrage – denn ein PHP-Prozess, der sich selbst ausgetauscht hat, arbeitet mit den Klassen weiter, die er beim Start geladen hat. Migrationen im selben Durchgang liefen mit dem Code der alten Fassung. Der Wartungszustand bleibt zwischen beiden Schritten an, und die Oberfläche sagt, dass er es tut.
|
||||
|
||||
**Geprüft wird das ohne Netz.** Das Herunterladen ist in `Download` gewandert – eine Klasse, die genau eine Sache tut und die HTTPS-Pflicht an einer einzigen Stelle durchsetzt. Der Test stellt eine Releaseseite in den Speicher und lässt den Rest echt laufen: echte Zip-Archive, echte Prüfsummen, echte Umbenennungen. Die Frage, um die es dabei geht, ist nicht „läuft der Download", sondern: Steht die hochgeladene Schrift danach noch da? Ein Test, der wirklich `git.kant-it.de` abfragt, wäre rot, wenn der Server umzieht, und grün, wenn im Archiv Unsinn steht.
|
||||
|
||||
Dazu kommt ein Test, der die Ersetzungsliste des Updaters gegen die Mitnahmeliste von `scripts/build-release.php` hält. Denselben Fehler gab es schon einmal andersherum: `migrations.php` fehlte im Archiv, und es fiel erst auf Umuts Hosting auf.
|
||||
|
||||
## ADR 067 – Die Verwaltung auf dem Telefon, und eigene Symbole (13.09.2026)
|
||||
|
||||
Umut: „Bitte Backend mobiloptimieren, das Menü ist schlecht." Und kurz darauf: „Machst du auch gleich eigene Icons statt die Web-Dinger zu nutzen – auf dem Smartphone kommen sonst Emojis."
|
||||
|
||||
Zwei Beobachtungen, eine Ursache: Die Verwaltung war für einen breiten Bildschirm gebaut. Zwölf Navigationspunkte nebeneinander ergeben auf 390 px eine Zeile, die seitlich hinausläuft.
|
||||
|
||||
**Das Menü ist jetzt ein `<details>`**, das ab 760 px Breite aufgeklappt und darunter zugeklappt startet; die Zusammenfassung nennt den Bereich, in dem man gerade steht. Kein Skript, kein Zustand, der verloren gehen kann – und mit Tastatur bedienbar, weil der Browser das mitbringt. Die Ziele sind 44 px hoch, was keine Gestaltungsfrage ist, sondern die Größe einer Fingerkuppe.
|
||||
|
||||
**Die Symbole waren Unicode-Zeichen** (▤ ◫ ☰ ▩ ⚙). Auf dem Schreibtisch sehen sie aus wie Symbole; ein Telefon ersetzt sie durch **Emoji** – buntes Zahnrad, bunter Briefumschlag, jedes in einem anderen Stil, und nichts davon in der Schrift der Oberfläche. Jetzt sind es zwölf eigene SVG-Pfade in einer Vorlage: eine Linienstärke, `currentColor`, kein zusätzlicher Request, keine Symbolschrift. Ein Test sucht in der Navigationsvorlage nach Zeichen aus den Emoji-Bereichen und hält jeden verwendeten Namen gegen die Sammlung – ein fehlender Name würde sonst still das Ersatzsymbol zeichnen, und zwei Einträge sähen gleich aus. Genau das war der Fall: „Formulare" und „Benutzer" trugen denselben Pfad.
|
||||
|
||||
## ADR 066 – Ein Farbfeld, das beides kann (13.09.2026)
|
||||
|
||||
Umut: „Wieso kein Colorpicker?" Berechtigte Frage. Die Farbfelder im Designbereich waren Textfelder – wer Grün wollte, musste `#2e7d5b` wissen.
|
||||
|
||||
Der Grund dafür war echt: Ein `<input type="color">` kann **nur** eine Farbe. Die Gestaltung dieses CMS kennt aber auch `@akzent` – einen Verweis auf die Palette, der sich ändert, wenn die Palette sich ändert. Ein Farbwähler hätte daraus beim ersten Klick einen festen Wert gemacht und die Verbindung stillschweigend gekappt.
|
||||
|
||||
**Das Textfeld bleibt deshalb maßgeblich**, und daneben stehen ein Farbfeld und eine Auswahl der Palettenfarben. Wer im Farbfeld zieht, schreibt einen Hexwert in das Textfeld; wer `@akzent` tippt, behält den Verweis, und das Farbfeld zeigt daneben nur an, wie diese Farbe gerade aussieht. Aufgebaut wird das von einem kleinen Skript über das vorhandene Feld: Ohne JavaScript bleibt das Textfeld übrig und die Seite vollständig bedienbar.
|
||||
|
||||
## ADR 056 – Das Vorschaubild beim Teilen, und die robots.txt als Bot-Steuerung (13.09.2026)
|
||||
|
||||
Umut: „Hast du auch schon SEO und Siteindex usw. übernommen?“ Der Abgleich mit dem Original war die Antwort – und er förderte einen Fehler der eigenen Software zutage.
|
||||
|
||||
**Das Feld „Vorschaubild beim Teilen“ gab es seit dem SEO-Paket, ausgegeben wurde es nie.** Im Kopf stand `{% if socialImage %}`, gesetzt hat `socialImage` kein Controller. Damit hatte **keine** mit KantSites gebaute Seite ein `og:image` – beim Teilen in WhatsApp, LinkedIn oder Slack erschien eine Karte ohne Bild. Jetzt wird es aufgelöst: erst das eigens gesetzte Vorschaubild, sonst bei einem Beitrag sein Beitragsbild, als absolute Adresse und in der Variante bis 1600 px. Dasselbe Bild steht im strukturierten Datensatz des Beitrags. Ohne Bild gibt es `twitter:card: summary` statt der großen Karte, die sonst eine leere Fläche wäre. Dazu `og:site_name` und `og:locale`, die schlicht fehlten.
|
||||
|
||||
**Die robots.txt war zu eng gefasst.** Das Original pflegt eine Liste, welche KI-Crawler die Website lesen dürfen (GPTBot, ClaudeBot, PerplexityBot …) und welche nicht (Bytespider, Diffbot, omgili, ImagesiftBot) – rund siebzig Zeilen in Blöcken je Bot. Das Feld für eigene Zeilen nahm dreißig und warf Leerzeilen weg, machte also aus Blöcken einen Klumpen. Jetzt 200 Zeilen, Leerzeilen bleiben als Trenner erhalten. Die erlaubten Anweisungen bleiben dieselben: Kommentar, `User-agent`, `Allow`, `Disallow`, `Crawl-delay`.
|
||||
|
||||
**Kein Sitemap-Index.** Das Original liefert einen (drei Teilkarten von Yoast), KantSites eine einzelne Datei mit allen Adressen. Ein Index ist erst ab 50.000 Adressen oder 50 MB vorgeschrieben; bei 35 Adressen wäre er eine Datei mehr ohne jeden Unterschied für eine Suchmaschine. Aufgeteilt wird, wenn es eine Website gibt, die das braucht – und dann automatisch, nicht als Einstellung.
|
||||
|
||||
## ADR 059 – Cloudflare Turnstile: optional, und der Preis steht dabei (13.09.2026)
|
||||
|
||||
Umut: „bei Formularen hätte ich gerne optional Cloudflare." Eingebaut sind drei Prüfungen ohne Dritte – ein verstecktes Feld, ein signierter Zeitstempel, fünf Absendungen je Absender und Stunde. Sie halten ab, was ein Skript ausfüllt; gegen jemanden, der einen echten Browser fernsteuert, helfen sie nicht.
|
||||
|
||||
**Turnstile und nicht reCAPTCHA:** keine Cookies, keine Google-Anmeldung, keine Verhaltensauswertung über Websites hinweg. Ein Dritter bleibt es trotzdem – die IP-Adresse des Absenders geht an Cloudflare, und das gehört in die Datenschutzerklärung.
|
||||
|
||||
Drei Entscheidungen, die nicht offensichtlich sind:
|
||||
|
||||
**Aus, bis jemand sie einschaltet.** Eine Website ohne Spamwelle soll keinen Dritten einbinden. Eingeschaltet ohne Schlüssel wird abgelehnt: das wäre ein Formular, das jede Anfrage verschluckt.
|
||||
|
||||
**Antwortet Cloudflare nicht, gilt die Absendung als bestanden.** Ein Ausfall bei einem Dritten darf keine Kundenanfrage kosten. Spam ist ärgerlich, eine verlorene Anfrage ist teurer.
|
||||
|
||||
**Die Sicherheitsrichtlinie öffnet sich nur dort, wo ein Formular steht** – Skript und Rahmen von `challenges.cloudflare.com`, und das auch nur auf den betroffenen Seiten. Jede andere Seite behält `script-src 'none'`.
|
||||
|
||||
Der Preis steht in der Verwaltung: das Widget braucht JavaScript. Ohne Skript ist das Formular dann nicht absendbar – genau die Art von Verschlechterung, die dieses Projekt sonst vermeidet. Deshalb steht im Formular ein `<noscript>`-Hinweis mit dem Weg über E-Mail.
|
||||
|
||||
## ADR 065 – Die Verwaltung muss nicht /admin heißen (13.09.2026)
|
||||
|
||||
Umut: „Erstelle mal Möglichkeit, Backend anzupassen. Also URL, Admin auf irgendwas."
|
||||
|
||||
`/admin` ist die erste Adresse, die ein Scanner probiert, und jeder Anmeldeversuch, der dort ankommt, kostet Rechenzeit und Logzeilen. Ein anderer Name nimmt der Maschinerie das Ziel. **Er ersetzt kein gutes Passwort** – die Anmeldesperre nach fünf Versuchen bleibt die eigentliche Verteidigung –, aber er macht die Website für automatisierte Angriffe uninteressant.
|
||||
|
||||
**Der Name steht in der Umgebung, nicht in der Datenbank.** Routen stehen fest, bevor die erste Datenbankabfrage läuft; eine Adresse, die erst beim Rendern bekannt wird, käme zu spät. `ADMIN_PATH` füllt einen Parameter, der als Präfix in jeder Verwaltungsroute steht (`#[Route('/%kant.admin_path%/media')]`), in der Firewall, in den Schutzkopfzeilen und in der Statistik.
|
||||
|
||||
Drei Dinge, die dabei nicht offensichtlich sind:
|
||||
|
||||
**Die robots.txt nennt den Namen nicht mehr.** Sie ist öffentlich und das erste, was ein Angreifer liest – ein `Disallow: /stiller-eingang` wäre ein Wegweiser. Beim Standardnamen bleibt die Zeile, weil diese Adresse ohnehin jeder kennt; bei jedem anderen entfällt sie. Vom Indexieren hält die Verwaltung ihr eigener Kopf `X-Robots-Tag: noindex` ab.
|
||||
|
||||
**Die Auffangroute für Seiten steht jetzt zuletzt.** Sie schließt per Regex `/admin` aus – einen frei wählbaren Namen kann sie nicht kennen. Statt die Regex dynamisch zu machen, lädt `routes.yaml` erst alle benannten Routen und dann die Auffangroute. Das ist ohnehin die richtige Reihenfolge.
|
||||
|
||||
**Der Befehl räumt den Zwischenspeicher auf, aber nicht den, in dem er selbst läuft.** Die Routen tragen den Namen einbetoniert; ohne Aufräumen gälte der alte weiter. Wer sich dabei den Boden unter den Füßen wegzieht, endet mit einem Fehler, obwohl alles geklappt hat – also bleibt die laufende Umgebung stehen, und der Befehl sagt, wie man sie leert.
|
||||
|
||||
`php bin/console cms:admin:path` zeigt die aktuelle Adresse, mit Argument ändert er sie – nach einer Prüfung, dass der Name frei ist und keine Seite verdeckt.
|
||||
|
||||
## ADR 064 – Ein Stylesheet, das nichts mehr tat (13.09.2026)
|
||||
|
||||
Umut: „ganz unten Einwilligung ändern, Datenschutz usw. ist links, komplett bitte besser lösen." Das war kein Gestaltungswunsch, sondern ein Fehlerbericht: Der **Einwilligungsdialog stand ungestaltet unter dem Fuß**, linksbündig, ohne Rand, ohne Hintergrund.
|
||||
|
||||
Die Ursache lag drei Veröffentlichungen zurück. Beim Entfernen alter Regeln war zweimal der Selektorrest stehen geblieben:
|
||||
|
||||
```css
|
||||
.site-header
|
||||
.site-footer
|
||||
/* Kommentar */
|
||||
.legal-bar{padding:16px 20px;text-align:center;…}
|
||||
```
|
||||
|
||||
CSS liest darüber hinweg: Der offene Selektor nimmt den nächsten mit, und aus zwei Regeln wird eine, die nirgends greift. Dasselbe war der `.consent-bar`-Regel passiert. **Ein Stylesheet beschwert sich nicht – es wirkt nur nicht mehr**, und beides blieb unbemerkt, weil kein Test das Aussehen dieser beiden Leisten prüft.
|
||||
|
||||
Repariert, und ein Test liest jetzt jede CSS-Datei zeilenweise: Was wie ein Selektor beginnt, muss auch wie einer enden. Er findet den Fehler in beiden Fassungen, geprüft gegen den alten Stand.
|
||||
|
||||
## ADR 063 – Der Bildbetrachter: ein Verweis, den ein Skript veredelt (13.09.2026)
|
||||
|
||||
Umut: „Bilder vergrößern Funktion, ein Popup-Viewer – gerne auch auswählbar wie bei WordPress, ob man bei Klick Galerie anschauen möchte."
|
||||
|
||||
Auswählbar heißt hier: Jedes Bild hat unter „Beim Klick" drei Möglichkeiten – nichts tun, vergrößern, zu einer Adresse führen –, und eine Galerie hat einen Schalter „Bilder vergrößerbar".
|
||||
|
||||
**Die Grundlage ist ein gewöhnlicher Verweis auf die große Bildfassung.** Ohne JavaScript öffnet ein Klick sie, zurück führt die Zurück-Taste; nichts ist kaputt. Das Skript – geladen nur auf Seiten, die tatsächlich ein vergrößerbares Bild enthalten – fängt den Klick ab und zeigt das Bild über der Seite, mit Blättern innerhalb einer Galerie.
|
||||
|
||||
Benutzt wird `<dialog>`: Fokusfang, Schließen mit Escape und die Auszeichnung als Dialog bringt der Browser mit. Eine nachgebaute Fassung aus `<div>` und Tastaturabfragen wäre mehr Code und weniger zugänglich. Mit gedrückter Zusatztaste bleibt es ein normaler Verweis – wer ein Bild in einem neuen Reiter öffnen will, soll das können.
|
||||
|
||||
## ADR 062 – Zwei Dinge, die am Rand kleben: Knopf nach oben und Rechtszeile (13.09.2026)
|
||||
|
||||
**Der Knopf „nach oben" ist ein Anker, kein Skript.** `<a href="#seitenanfang">` springt an den Seitenanfang – das kann ein Browser seit jeher allein. Eingeblendet wird er über eine Scroll-Zeitachse (`animation-timeline: scroll(root)`), dieselbe Technik wie bei den Einblendungen; wo es sie nicht gibt, steht er von Anfang an da. Sichtbar und nutzbar ist besser als unsichtbar. Alles daran ist einstellbar – Seite, Form, Größe, Farben, Schwelle, Beschriftung –, weil er auf jeder Seite in der Ecke klebt und deshalb zur Gestaltung passen muss. Solange der Einwilligungsdialog offen steht, tritt er zurück.
|
||||
|
||||
**Die Rechtszeile wird ein Baustein.** Bisher hängte das System sie als eigene Leiste unter den Fuß: auf einer Website mit dunklem Fuß ein heller Streifen, den niemand dort haben wollte. Als Baustein steht sie im Fußbereich, erbt dessen Farben und lässt sich wie jeder andere Baustein gestalten. Die eingebaute Leiste erscheint weiterhin – aber nur, wenn die Seite den Baustein nicht enthält. Die Pflicht, Impressum und Datenschutz von jeder Seite aus erreichbar zu halten, bleibt damit erfüllt, ohne dass zweimal dasselbe dasteht.
|
||||
|
||||
## ADR 061 – Die Vorschau zeigt nie eine Seite ohne ihre Gestaltung (13.09.2026)
|
||||
|
||||
Umut, mit Blick auf einen Screenshot des Editors: „im Screenshot sind die Buttons nicht gleiche Farbe, es sollte exakt nachladen, die Vorschau soll wie das echte aussehen."
|
||||
|
||||
**Nachgemessen: die Farben sind identisch.** Knopf, Hero-Hintergrund, Hintergrundbild und Textfarbe geben in der Vorschau und auf der veröffentlichten Seite exakt dieselben berechneten Werte. Was auf dem Bild zu sehen war, war ein Zwischenzustand – links oben stand „Vorschau wird aktualisiert …".
|
||||
|
||||
Der Zwischenzustand war trotzdem ein Fehler, und ein langer: **1440 Millisekunden** lang war die Vorschau sichtbar und ungestaltet, gemessen an der nachgebauten Startseite. Der Grund liegt in einer Zeile: die erzeugten Stile wurden im `load`-Ereignis des Rahmens angewandt – und das feuert erst, wenn **das letzte Bild** geladen ist. Bei sechzehn Bildern sieht man bis dahin die Seite ohne eine einzige Farbe.
|
||||
|
||||
Drei Änderungen, jede aus einem eigenen Fehlversuch:
|
||||
|
||||
1. **Angewandt wird, sobald das Dokument geparst ist**, nicht wenn es fertig geladen ist. Für `srcdoc` gibt es dafür kein verlässliches Ereignis, also wird Bild für Bild nachgesehen, ob der Kopf schon da ist.
|
||||
2. **Der Rahmen bleibt verdeckt, bis die Gestaltung sitzt.** Eine leere Fläche für einen Moment ist besser als eine falsche Seite.
|
||||
3. **Nur das neue Dokument wird gestaltet.** Der erste Anlauf sah beim Warten das *alte* Dokument – es trägt dieselben Kennzeichen –, gestaltete es, schaltete sichtbar, und `srcdoc` ersetzte es gleich darauf durch den ungestalteten neuen Inhalt. Der Fehler war damit derselbe wie vorher, nur schneller.
|
||||
|
||||
Gegen einen Rückfall prüft die Editorkontrolle nicht die Zeit, sondern das Verhalten: der Rahmen **muss** beim Aufbau verdeckt werden und danach wieder sichtbar. Eine Zeitmessung hätte auf der kleinen Testseite nichts gefunden – das Fenster ist dort zu kurz.
|
||||
|
||||
## ADR 060 – Die Fehlerseite eines Formulars ist dieselbe Seite (13.09.2026)
|
||||
|
||||
Beim Einbau von Turnstile gefunden: wer ein Pflichtfeld vergaß, bekam die Seite **ohne Gestaltung** zurück. Der Formularcode baute sie selbst ein zweites Mal auf und ließ dabei alles weg, was er nicht kannte – Theme, Blockstile, Schriften, das Stylesheet-Bündel. Zwei Renderwege für dieselbe Seite, und der zweite hinkte hinterher, seit es den ersten gibt.
|
||||
|
||||
Jetzt geht auch dieser Weg durch den Seitencontroller. Die Meldungen und die eingetippten Werte reisen als Anfrageattribut mit; die Seite entsteht genau einmal, mit allem, was dazugehört. Eine Sonderregel blieb nötig: die Startseite leitet von ihrem eigenen Pfad auf `/` um, und diese Umleitung hätte die Meldungen verworfen – sie gilt für diesen Weg nicht.
|
||||
|
||||
## ADR 058 – Bilder in der Größe, in der sie erscheinen (13.09.2026)
|
||||
|
||||
Gemessen auf der nachgebauten Startseite: ein Telefon lud das Hintergrundbild des Hero mit **1920 Pixeln und 189 KB**. Zu sehen war davon nie mehr als ein Viertel. Die Ursache ist bauartbedingt: wer im Editor ein Hintergrundbild wählt, sitzt am Schreibtisch und setzt es unter „Desktop“; kleine Geräte erben diese Regel, und CSS kennt kein `srcset`.
|
||||
|
||||
Die Regeln stehen aber ohnehin je Gerät da. Der Kompilierer ergänzt deshalb für Tablet und Telefon dieselbe Bildwahl, die entstünde, wenn jemand sie einzeln gesetzt hätte – samt Verlauf und Abdunkelung, denn ohne sie stünde heller Text plötzlich auf einem ungedämpften Bild. Ein ausdrücklich gesetztes Bild wird nie ersetzt. **189 KB → 40 KB auf dem Telefon.**
|
||||
|
||||
Dasselbe Thema bei `<img>`: jedes Bild behauptete, 800 Pixel breit zu erscheinen – auch ein Logo, das 190 Pixel breit ist. Jetzt gilt: eine im Block gesetzte feste Breite ist die Antwort; sonst steht bei verzögert geladenen Bildern `sizes="auto"`, womit der Browser selbst nachmisst (Rückfallangaben dahinter für Safari). Das vorgezogene Bild behält die Annahme, weil seine Größe zum Zeitpunkt der Anfrage noch nicht feststeht.
|
||||
|
||||
Und die Eile lag auf der falschen Stelle: `fetchpriority="high"` bekam das erste Bild überhaupt – das Logo im Kopfbereich. Es bekommt es jetzt das erste Bild im **ersten Abschnitt des Inhalts**; Bilder im Kopfbereich laden weiterhin sofort, aber ohne Vorrang.
|
||||
|
||||
**Was nicht blieb:** ein Vorabladen des Hero-Hintergrunds (`<link rel="preload" as="image">`). Der Gedanke stimmt – ein Bild im Stylesheet wird spät entdeckt –, die Messung nicht: mobil **95 → 90**. Das Bild nimmt genau die Bandbreite weg, die der Text für den größten Aufbauschritt braucht. Wieder entfernt.
|
||||
|
||||
## ADR 057 – Schriften vom eigenen Server (13.09.2026)
|
||||
|
||||
Eine Schrift von `fonts.googleapis.com` kostet zweierlei. Technisch: der Browser holt erst das Stylesheet, findet darin die Adresse der Schriftdatei und holt dann diese – zwei fremde Verbindungen nacheinander, gemessen 820 ms, in denen keine Zeile in der richtigen Schrift steht. Rechtlich: ein Dritter erfährt die IP-Adresse jedes Besuchers, und dafür gibt es in Deutschland Abmahnungen (LG München I, 3 O 17493/20).
|
||||
|
||||
Deshalb: **einmal holen, selbst ausliefern.** Ein Knopf im Designbereich (und `cms:fonts:fetch` auf der Konsole) lädt die gewählten Schnitte als WOFF2 nach `public/assets/fonts`, schreibt die `@font-face`-Regeln daneben und meldet, was geladen wurde. Die Lizenz erlaubt das ausdrücklich – Google Fonts stehen unter OFL oder Apache 2.0.
|
||||
|
||||
Drei Entscheidungen dabei: **Kein neues Paket** – geholt wird mit curl, ersatzweise über `allow_url_fopen`, genau wie der Installer es tut; eine zusätzliche Abhängigkeit würde jedes Update auf einem Hosting ohne Composer scheitern lassen. **Nur Latein** – Kyrillisch und Vietnamesisch wären zwei Drittel der Bytes für nichts. **Nicht beim Seitenaufruf** – ein Besucher wartet nie auf einen fremden Server, auch nicht mittelbar. Liegen die Dateien nicht, bleibt es beim Verweis auf Google statt einer Seite ohne Schrift.
|
||||
|
||||
## ADR 056 – Vier Stylesheets werden eines, und der Webserver bekommt Anweisungen (13.09.2026)
|
||||
|
||||
Vor dem Livegang gemessen, nicht geschätzt. Jede der vier CSS-Dateien hält das Zeichnen der Seite auf, zusammen 46 KB. Sie sind getrennt, weil sie beim Schreiben Verschiedenes tun; beim Ausliefern ist das kein Argument. Ein Bündel trägt die Prüfsumme seines Inhalts im Namen, wird beim ersten Aufruf nach einer Änderung erzeugt und ohne Kommentare geschrieben. Minifiziert wird bewusst nur das: Kommentare und Einrückung. Ein Minifier, der Selektoren umbaut, spart Zeichen und kostet einen Abend Fehlersuche – in `calc(100% - 20px)` und `.a :hover` trägt ein Leerzeichen Bedeutung. Fehlt das Schreibrecht, werden die Quellen einzeln geladen: langsamer, aber heil.
|
||||
|
||||
Dazu eine `public/.htaccess`, die diesen Namen verdient: Kompression (mod_deflate/mod_brotli), ein Jahr Cache für alles, was seine Fassung oder Kennung in der Adresse trägt – Stylesheets und Skripte bekommen dafür `?v=` mit der Programmfassung –, und ein Riegel vor Dateien, die niemanden angehen. HTTPS-Umleitung und HSTS stehen auskommentiert mit dem Grund dabei: eine Umleitung ohne Zertifikat ist eine Endlosschleife, und HSTS lässt sich ein halbes Jahr lang nicht zurücknehmen.
|
||||
|
||||
**Der Entwicklungsserver sendet jetzt dieselben Kopfzeilen und komprimiert ebenso.** Ohne das misst man hier Größen, die es im Betrieb nie gibt: dieselbe Startseite ging von 90 auf 96 Punkte, ohne dass sich an ihr etwas geändert hätte.
|
||||
|
||||
## ADR 055 – Das Symbol der Website entsteht aus einem Bild der Mediathek (13.09.2026)
|
||||
|
||||
Umut: „dann brauchen wir Favicon-Möglichkeit zum Festlegen“. Ein Favicon ist der einzige Teil der Gestaltung, den ein Besucher sieht, bevor die Seite geladen ist – und bisher war es das mitgelieferte Zeichen, für jede mit KantSites gebaute Website dasselbe.
|
||||
|
||||
Naheliegend wäre gewesen, die WebP-Varianten der Mediathek zu verwenden. Dagegen sprechen zwei Dinge: die kleinste Variante ist 480 px breit (für ein 32-px-Symbol zwei Größenordnungen zu viel), und ein Favicon ist **quadratisch** – ein breites Logo würde gestaucht oder zur Briefmarke.
|
||||
|
||||
**Deshalb entstehen die Symboldateien beim Speichern der Einstellung, nicht beim Aufruf**: ein mittiger quadratischer Ausschnitt in 32 px (Reiter) und 180 px (Startbildschirm), als PNG, weil das jeder Browser liest. Schlägt das fehl, wird die Einstellung gar nicht erst gespeichert – ein Verweis auf ein Symbol, das es nicht gibt, wäre schlechter als das alte Symbol. Ausgeliefert werden sie unter `/favicon.png` und `/apple-touch-icon.png`, mit der Bildkennung als Version am Verweis, damit ein Wechsel im Browser ankommt.
|
||||
|
||||
Nebenwirkung, die die Tests fanden: `SiteSettings` fragt die Datenbank jetzt auch aus der Nicht-gefunden-Seite heraus – und **vor** der Installation gibt es die Tabelle noch nicht. Eine gezielt gefangene `TableNotFoundException` liefert dort die Standardwerte; jeder andere Datenbankfehler bleibt ein Fehler.
|
||||
|
||||
## ADR 054 – Unterstrichene Links: nur im Fließtext, und dort abschaltbar (13.09.2026)
|
||||
|
||||
Umut: „und links haben alle underlined“. Stimmte: `a` hatte keine eigene Textdekoration, also entschied der Browser – und der unterstreicht alles. Sichtbar wurde das an Stellen, wo ein Unterstrich nur wie ein Fehler aussieht: im „Ansehen“-Streifen über einem Kartenbild, im Menü bei der aktuellen Seite, in der Rechtszeile des Fußes, neben dem Telefonsymbol im Kontaktblock.
|
||||
|
||||
Die Trennlinie ist nicht Geschmack, sondern Funktion: **ein Link mitten im Fließtext ist ohne Unterstrich nur an seiner Farbe zu erkennen** – für Menschen mit Farbsehschwäche also gar nicht (WCAG 1.4.1). Ein Link, der als Karte, Knopf, Menüeintrag oder Kontaktzeile mit Symbol auftritt, ist an Form und Ort erkennbar und braucht keinen.
|
||||
|
||||
Also: strukturelle Links ohne Unterstrich (beim Überfahren erscheint er), Fließtext mit – und für den Fließtext **ein Schalter im Designbereich** statt einer stillen Entscheidung, mit dem Hinweis, was man sich damit einhandelt. Die aktuelle Seite im Menü wird über das Schriftgewicht kenntlich und nicht mehr über die Akzentfarbe: die reichte im dunklen Fußmenü nicht für den Kontrast – gefunden von der Browserprüfung, nicht von mir.
|
||||
|
||||
## ADR 053 – Eine Website ohne Beiträge redet nicht von Beiträgen (13.09.2026)
|
||||
|
||||
Umut: „Können wir Beiträge optional machen? Wenn's keine gibt, überall ausblenden – ist das schon so?“ Teilweise: die Sitemap ließ das Archiv weg, `llms.txt` auch. Der Rest nicht – `/beitraege` antwortete mit einer leeren Seite, `/feed.xml` mit einem Feed ohne Einträge, jede Seite verwies im Kopf auf diesen Feed, und ein Beitragslisten-Baustein zeigte öffentlich den Kasten „Hier erscheinen die Beiträge, sobald welche veröffentlicht sind“.
|
||||
|
||||
**Eine Adresse, die nichts zeigt, ist kein Inhalt, sondern eine Baustelle mit eigener Adresse.** Archiv und Feed antworten deshalb mit 404, solange sie nichts zu zeigen haben – das gilt auch für eine einzelne Kategorie, deren letzter Beitrag gelöscht wurde: der Begriff bleibt als wiederverwendbare Beschriftung bestehen, sein Archiv verschwindet. Der Verweis auf den Feed steht nur im Kopf, wenn es ihn gibt. Der Hinweiskasten bleibt – aber **nur im Editor**, denn dort erklärt er die leere Stelle, während er öffentlich nur ein Versprechen wäre.
|
||||
|
||||
Beim Nachmessen fiel eine zweite Sache auf, die nichts mit Beiträgen zu tun hat: die Beitragsliste fragte **je Beitrag** nach seinem Bild und seinen Begriffen. Zwölf Beiträge kosteten 24 Abfragen, und `neighbours()` (Vor/Zurück) lädt bis zu 500 Beiträge. Bilder und Begriffe kommen jetzt in je einer Abfrage für die ganze Liste: die Startseite des Nachbaus fiel von 22 auf 16 Abfragen.
|
||||
|
||||
## ADR 052 – Einblenden beim Scrollen, ohne eine Zeile JavaScript (13.09.2026)
|
||||
|
||||
Umut: „Effekte gibt es keine, sind die aktiv aktuell?“ Nachgemessen statt geraten: die Zeigereffekte liefen alle (Karte −8 px, Bild 1,06×, Symbolkarte −4 px, Knopf −2 px, Titel in Akzentfarbe). Die **Einblendungen beim Laden** gab es seit Dokumentschema v3 – und **benutzt hatte sie niemand**. Ein Werkzeug, das im Kasten bleibt, ist kein Werkzeug.
|
||||
|
||||
Der Grund, warum es im Kasten blieb, war ein echter: eine Animation, die beim *Laden* läuft, ist unterhalb der Falz wertlos – sie ist vorbei, bevor jemand hinsieht. Öffentliche Seiten laufen ohne JavaScript, also gab es bisher nichts, was den Scrollstand kennt.
|
||||
|
||||
**Inzwischen kennt CSS ihn.** `animation-timeline: view()` bindet den Ablauf einer Animation an die Sichtbarkeit des Elements: dieselben Keyframes, aber sie laufen, wenn der Abschnitt in den Blick kommt. Dreifach abgesichert: hinter der Bewegungsschranke, hinter `@supports` (Firefox und Safari behalten heute die Fassung von vorher – Einblenden beim Laden, nichts fehlt), und mit einem Bereich, der endet, bevor der Abschnitt in der Bildschirmmitte steht.
|
||||
|
||||
**Und dann hat die Prüfung etwas gefunden, das ich selbst gebaut hatte.** Eine Scroll-Zeitachse ist **umkehrbar**: ein Abschnitt außerhalb des Blicks steht wieder am Anfang der Kurve. Mit der ursprünglichen Kurve (`opacity: .35`) hieß das: alles unterhalb der Falz ist dauerhaft blass – und blasser Text ist kein Effekt, sondern schlechterer Kontrast. axe meldete prompt fünfzehn Verstöße. Der Versuch mit 0,85 half nicht: aus `#3d4d5c` wird bei 85 Prozent `#78838d`, also 3,86:1 statt 7,1:1.
|
||||
|
||||
**Auf dem Scrollweg wird deshalb keine Deckkraft animiert, nur Bewegung.** 20 px für „rise“, 10 px für „fade“, Deckkraft bleibt 1. Der Effekt trägt trotzdem, und der Text ist an jeder Stelle lesbar – auch wenn er gerade nicht zu sehen ist. Beim Laden (ohne Scroll-Zeitachse) bleiben die alten Kurven mit Deckkraft, weil sie dort einmalig laufen und nicht zurückspulen.
|
||||
|
||||
**Benutzt wird es jetzt auch:** alle drei Beitragsvorlagen bringen die Einblendung je Abschnitt mit, und der Nachbau von kant-it.de hat sie auf jeder Seite – im Hero gestaffelt, darunter je Abschnitt.
|
||||
|
||||
## ADR 051 – Vier Darstellungen für dieselbe Beitragsliste (13.09.2026)
|
||||
|
||||
Umut wollte auch für die Auflistung mehrere Formen. Dieselben Daten, dasselbe Markup – nur eine Klasse wechselt. Eine Darstellung kostet dadurch ein paar Regeln CSS und keine Zeile PHP.
|
||||
|
||||
- **Karten** – Raster mit Bild oben. Für eine Übersicht.
|
||||
- **Liste** – Bild links, Titel und Auszug rechts. Für ein Archiv.
|
||||
- **Kompakt** – nur Titel und Datum, durch Linien getrennt, ohne Bilder. Für eine Randspalte oder ein langes Verzeichnis.
|
||||
- **Wechselseitig** – großes Bild, Text daneben, und bei jedem zweiten Beitrag die Seiten getauscht.
|
||||
|
||||
**Die erste Fassung von „Wechselseitig" war ein umgedrehter Flexkasten und sah auch so aus.** Umut hat es zu Recht hässlich genannt. Was gefehlt hat, war nicht die Umkehrung, sondern alles andere: das Bild sitzt jetzt in einer Karte mit Ecken und weichem Schatten in festem Verhältnis 16:10, damit die Reihen gleich hoch sind; der Text bekommt eine Nummer aus einem CSS-Zähler, eine begrenzte Zeilenlänge und einen sichtbaren Verweis; zwischen den Reihen liegt eine feine Linie. Beim Zeigen zieht das Bild auf und der Titel nimmt die Akzentfarbe – die Reihe selbst bleibt stehen, denn sie ist keine Karte.
|
||||
|
||||
**Zwei Details, die der Bau gelehrt hat:** die Karte *ist* das Listenelement, also zählt `:nth-child` auf ihr und nicht eine Ebene tiefer – die erste Fassung wechselte deshalb gar nicht. Und die Umkehrung läuft über `order` der beiden Spalten statt über `row-reverse`, weil sich damit auf schmalen Bildschirmen sauber wieder Bild-über-Text ergibt.
|
||||
|
||||
**Die Nummer kommt aus einem Zähler, nicht aus dem Inhalt.** Auf Seite zwei beginnt sie wieder bei 01. Das ist ehrlich: der Beitrag weiß nicht, der wievielte er ist, und eine Nummer, die das behauptet, wäre erfunden.
|
||||
|
||||
## ADR 050 – Drei Aufbauten für einen Beitrag (13.09.2026)
|
||||
|
||||
Umut: „ich hätte gerne mehrere Designs zur Auswahl, 2–3 Stück für Beiträge.“
|
||||
|
||||
**Warum das bei Beiträgen sinnvoll ist und bei Seiten nicht:** bei einer Seite weiß vorher niemand, was daraus wird – ein leeres Dokument ist dort die ehrliche Voreinstellung. Ein Beitrag hat dagegen fast immer dieselbe Form: Kopf, Text, Bilder, ein Weg weiter. Wer jedes Mal von vorn anfängt, baut jedes Mal etwas leicht anderes, und nach zwanzig Beiträgen sieht ein Archiv aus wie zwanzig Websites.
|
||||
|
||||
**Die drei:** *Bildkopf* (Bild über die volle Breite, Titel darauf – für Projekte), *Klassisch* (Titel und Vorspann schmal, Bild darunter – für lange Texte), *Magazin* (Titel neben den Eckdaten – wenn Kunde, Leistung und Jahr zählen). Gewählt wird beim Anlegen, im selben Formular wie Titel und Adresse.
|
||||
|
||||
**Es sind Startpunkte, keine Schablonen.** Jeder Block lässt sich danach verschieben, ändern und löschen wie jeder andere. Festgelegt wird nur die erste Entscheidung – und die ist die, die am schwersten nachzuholen ist.
|
||||
|
||||
**Kein Aufbau verlässt sich auf ein Bild, das es noch nicht gibt.** Wo ein Beitragsbild hingehört, steht ein Platzhalter; der dunkle Kopf ohne Bild sieht schlicht aus statt kaputt. Ein Aufbau, der erst nach dem ersten Upload funktioniert, wäre beim Anlegen wertlos.
|
||||
|
||||
**Und die Regel, die dabei gefallen ist:** der erste Textblock eines Abschnitts wurde automatisch zur Kleinbeschriftung in Akzentfarbe. Das gestaltet nach **Position statt nach Absicht** – im Fußbereich wurde daraus roter Versalientext für den Firmennamen, in der Magazin-Vorlage aus dem Einstiegsabsatz eine Kleinbeschriftung. Beides sah aus wie ein Fehler, weil es einer war. Wer eine Beschriftung will, stellt sie ein; dafür gibt es den Designbereich. Die Demoseite verliert dadurch ihre Versalienzeilen und wirkt ruhiger – dasselbe Ergebnis wie beim Rahmen der Spalten.
|
||||
|
||||
## ADR 049 – Die Beitragsseite, und warum das kein Plugin wurde (Dokumentschema v18, 13.09.2026)
|
||||
|
||||
Umut: „Beitragsseite gefällt mir nicht, sollte wie bei kant-it.de sein oder besser. Falls nötig Plugin bauen.“
|
||||
|
||||
Nachgesehen: die Originalseite besteht aus Kopfbild, Text, **Bildergalerie**, **Vor/Zurück zwischen Projekten** und einem **Abschlussaufruf**. Galerie und Aufruf gab es als Bausteine längst – sie waren nur nicht benutzt. Die Navigation zwischen Beiträgen gab es nicht.
|
||||
|
||||
**Warum das kein Plugin geworden ist.** Ein Plugin ist in diesem CMS ein Bausteintyp mit Namensraum, dessen Eigenschaften der Kern *aufbewahrt, aber nicht rendert* – genau damit eine abgeschaltete Erweiterung keine Seite unlesbar macht. Ein Plugin kann also nichts ausgeben, solange es keine Laufzeit dafür gibt (Arbeitspaket H). Und die Nachbarn eines Beitrags sind keine Fremdfunktion: sie stehen in derselben Tabelle wie die Beitragsliste, die der Kern schon füllt. Ein Plugin dafür wäre eine Hülle um eine Kernabfrage gewesen. Also ein Baustein: `postnav`, Dokumentschema v18.
|
||||
|
||||
**Die Regel der Beitragsliste gilt auch hier: der gespeicherte Inhalt kennt keinen Beitrag.** Gespeichert sind zwei Beschriftungen, mehr nicht. Welcher Beitrag vorher und nachher kommt, entscheidet der Renderer beim Ausliefern – sonst könnte ein altes Dokument auf einen Beitrag zeigen, den es nicht mehr gibt oder der nie veröffentlicht wurde.
|
||||
|
||||
**Gesucht wird innerhalb derselben Kategorie:** von einem Projekt führt man zum nächsten Projekt, nicht zur nächsten Pressemitteilung. **Am Rand wird nicht umgebrochen** – der neueste Beitrag hat keinen Vorgänger. Ein Ring wäre bequem zu programmieren und eine Falle für den Leser, der glaubt, er sei am Anfang.
|
||||
|
||||
**Dabei zwei Dinge gelernt:** `present()` gibt für eine Liste bewusst keine Kennung heraus, nur Titel und Adresse – die Nachbarsuche vergleicht deshalb über die Adresse. Und die Abfrage kostet etwas, also läuft sie nur, wenn die Seite den Baustein wirklich enthält.
|
||||
|
||||
**Was ich zusätzlich geändert habe, ohne gefragt zu werden:** die Absätze der übernommenen Projekttexte begannen mit dem wiederholten Titel und der Zwischenüberschrift, weil beides im selben Absatz stand – 20 Beiträge betroffen. Und schmale Abschnitte standen am linken Rand statt in der Mitte: `max-width` ohne `margin:auto` ist eine halbe Einstellung.
|
||||
|
||||
## ADR 047 – Ein Slider, der von selbst weiterblättert (13.09.2026)
|
||||
|
||||
Umut wollte die Autorotation. Die Regel „öffentliche Seiten laufen ohne JavaScript“ steht seit dem ersten Tag, und sie bleibt – **mit einer Ausnahme, die es schon gab**: die Tabs laden ein kleines Skript, aber nur auf Seiten, die Tabs enthalten. Der Slider folgt genau diesem Muster.
|
||||
|
||||
**Die Einstellung sind Sekunden, kein Häkchen.** 0 heißt aus, unter drei Sekunden wäre es keine Hilfe mehr, sondern Hektik. Die Zahl steht im Baustein, nicht im Skript.
|
||||
|
||||
**Was das Skript nicht tut, ist der interessantere Teil:**
|
||||
- Es läuft **gar nicht**, wenn das System weniger Bewegung verlangt.
|
||||
- Es hört **endgültig** auf, sobald jemand tippt, klickt, wischt oder von Hand scrollt. Nicht „pausiert kurz“ – aus. Wer selbst blättert, hat entschieden.
|
||||
- Es pausiert, solange der Zeiger darin ist, solange etwas darin den Fokus hat, solange der Tab im Hintergrund liegt und solange der Slider nicht zu sehen ist.
|
||||
|
||||
**Ohne das Skript bleibt alles benutzbar**: wischen, scrollen, Tastatur, Punkte. Es kommt nur eine Bequemlichkeit dazu, es hängt nichts davon ab.
|
||||
|
||||
**Und ein Fehler beim Bauen, der zeigt, warum man so etwas nachmisst:** die erste Fassung rechnete das Sprungziel aus `offsetLeft` und zog zusätzlich den Offset des Sliders ab – bei einem Slider, der selbst der Bezugsrahmen ist, kam dabei −78 heraus, also gar keine Bewegung. Gemessen wird jetzt über die Bildschirmrechtecke. Der Test dazu ist kein Schnappschuss, sondern ein Verlauf: 0 → 666 → 1332 → 1998 in drei Schritten, und nach einem Mausrad-Ereignis bleibt der Wert stehen.
|
||||
|
||||
## ADR 048 – Eine Beitragsliste darf blättern (13.09.2026)
|
||||
|
||||
Umut: „fehlt die Pagination bei Projekte? Es werden nur 12 angezeigt.“ Richtig, und es widersprach der eigenen Regel aus Arbeitspaket M: *eine feste Obergrenze ist kein Blättern, sie verschweigt, dass etwas fehlt.* Für die Verwaltung war das behoben, für die Beitragsliste einer Seite nicht.
|
||||
|
||||
Der Unterschied ist absichtlich und heißt jetzt `paginate`: eine **Auswahl** auf der Startseite zeigt die neuesten vier und hört auf – dort will niemand blättern, dort will man weiter zur Übersicht. Eine **Übersichtsseite** blättert und sagt dabei, wie viele es insgesamt sind.
|
||||
|
||||
Zwei Kleinigkeiten, die dabei wichtig waren: eine blätternde Liste nimmt die Seitenzahl aus der Anfrage, eine Auswahl immer die erste – sonst würden zwei Listen auf derselben Seite einander umblättern. Und eine Seitenzahl jenseits des Endes landet auf der letzten Seite statt im Leeren.
|
||||
|
||||
**Beim Bauen zum zweiten Mal dieselbe Falle:** die Seitenzahl war in der Schließung, die die Blöcke durchläuft, nicht gebunden – genau wie vorgestern beim Filter für Überschriften. Diesmal fiel es sofort auf, weil die Seite mit 500 antwortete statt still das Falsche zu tun.
|
||||
|
||||
## ADR 046 – Der eigene Blick: was ich vor einem Livegang ändern würde (13.09.2026)
|
||||
|
||||
Umut: „schau selbst drüber, ohne zu vergleichen – würdest du so online gehen?“ Antwort: **so nicht.** Fünf Dinge sind mir aufgefallen, und drei davon waren Fehler im CMS, nicht im Inhalt.
|
||||
|
||||
**1. Verborgener Text stand sichtbar auf der Seite.** `.visually-hidden` gibt es in `admin.css`, aber **nicht in `site.css`** – auf jeder öffentlichen Seite waren damit die Zusätze zu lesen, die nur für Vorlesesoftware gedacht sind: „Bewertung: 5 von 5“ neben den Sternen, „(Pflichtfeld)“ hinter jedem Formularfeld. Das ist der peinlichste Fund der Reihe, weil er seit dem ersten Formular dasteht und in keinem Test auffiel: axe prüft, ob etwas **angesagt** wird, nicht ob es fälschlich **sichtbar** ist.
|
||||
|
||||
**2. Jede Spalte war ein Kasten.** `.block-column` brachte Rahmen, Ecken und 30 px Innenabstand mit. Eine Spalte ist aber ein Layoutmittel – wer damit zwei Textblöcke nebeneinander stellt, bekam zwei umrandete Kästchen und fragt sich, warum seine Seite aussieht wie ein Formular. Die Karte ist jetzt das, was sie sein sollte: eine Einstellung, kein Standard. Die Demoseite sieht dadurch ruhiger aus, nicht ärmer.
|
||||
|
||||
**3. Der Knopf im Aufruf-Band war unsichtbar.** Er nahm die Tintenfarbe – auf einem dunklen Band also Tinte auf Tinte, ausgerechnet beim Handlungsaufruf. Aufruf-Band und Formular nehmen jetzt die Akzentfarbe.
|
||||
|
||||
**4. Gestapelte Karten klebten aneinander.** In einer Zeile sorgt das Raster für Abstand, untereinander gab es keinen. Aufeinanderfolgende Karten bekommen 18 px.
|
||||
|
||||
**5. Und mein eigener Inhalt:** die linke Spalte im Kontaktbereich war zur Hälfte leer, weil sie sich auf die Höhe des Formulars zog. Sie steht jetzt oben und trägt drei Karten, die die Frage beantworten, die man an dieser Stelle hat: was passiert, wenn ich abschicke?
|
||||
|
||||
**Was ich bewusst gelassen habe:** die Einwilligung liegt weiterhin als Kasten mitten auf der Seite. Das ist richtig so – sie ist eine Entscheidung, keine Verzierung –, aber sie verdeckt auf jedem Bildschirmfoto den Inhalt dahinter. Wer die Seite abnimmt, sollte sie einmal wegklicken, bevor er urteilt.
|
||||
|
||||
## ADR 045 – Der Zeigereffekt, und warum Feinschliff kein Zusatz ist (13.09.2026)
|
||||
|
||||
Umut: „es gibt keinen Mausover-Effekt wenn man auf einen Beitrag geht, das fehlt … grob siehts ähnlich aus, aber im Detail fehlen viele Kleinigkeiten.“ Das ist die richtige Beobachtung: eine Karte, die sich beim Zeigen nicht rührt, wirkt kaputt, und zwar bevor jemand sagen kann, warum.
|
||||
|
||||
**Die Beitragskarte reagiert jetzt**, und zwar wie im Original nachgemessen: die Karte hebt sich um 8 px, bekommt einen weichen Schatten, das Bild zieht auf 1,06 auf, und über dem Bild blendet ein Hinweis ein. Dauer und Kurven sind aus dem Original übernommen (0,28 s für die Karte, 0,5 s für das Bild, 0,3 s für den Hinweis).
|
||||
|
||||
**Drei Entscheidungen dabei:**
|
||||
|
||||
- **Die Bewegung steht vollständig hinter der Bewegungsschranke.** Wer im Betriebssystem weniger Bewegung eingestellt hat, bekommt keine sanftere Fahrt, sondern keine – aber den Hinweis über dem Bild trotzdem, denn der ist Information und keine Verzierung. Ein Test prüft genau diese Trennung: kein `translateY` außerhalb der Schranke.
|
||||
- **Auch mit der Tastatur.** Alles hängt zusätzlich an `:focus-within`, sonst wäre der Effekt eine Belohnung für Mausbenutzer.
|
||||
- **Der Hinweis ist für Vorlesesoftware ausgeblendet.** Er wiederholt nur, wohin der Titel darunter ohnehin führt; zweimal dasselbe Ziel anzusagen hilft niemandem.
|
||||
|
||||
**Der Hero war das falsche Bild.** Im Original ist er kein Foto, sondern Marineblau mit einer Binärtextur bei 10 Prozent und einem roten Schein oben rechts. Nachgebaut mit den Mitteln, die der Designer hat: dasselbe Bild als Textur, die Überlagerung auf 93 Prozent, und ein Verlauf schräg darüber für den Schein. Die Augenbraue ist im Original eine **Pille** mit Rahmen, kein nackter Text – auch das ließ sich ohne neue Funktion bauen.
|
||||
|
||||
**Und die Effekte, die es längst gibt, waren schlicht nicht benutzt.** `layout.effects` kennt Anheben, Zoom, Schatten und Einblenden seit Dokumentschema v3; im Nachbau standen sie auf „nichts“. Jetzt heben sich Leistungskarten und Lösungskarten beim Zeigen. Das ist keine Änderung am CMS, sondern die Antwort auf die Frage, warum es „im Detail“ fehlte: ein Werkzeug, das man nicht in die Hand nimmt, ist so gut wie nicht vorhanden.
|
||||
|
||||
**Nebenbei entfernt:** der Leuchteffekt (`fx-hover-glow`) trug noch das Grün der Vorlage statt der Akzentfarbe der Website.
|
||||
|
||||
**Nachgetragen am 13.09.2026:** Umut meinte den Kopf **beim Öffnen eines Beitrags**, nicht den der Startseite. Im Original füllt dort das Projektbild den ganzen Kopf (75 vh), darüber liegt ein Verlauf von fast durchsichtig oben nach fast deckend unten, und darin stehen ein Badge in der Akzentfarbe, der Titel und eine 60 × 4 Pixel kurze Linie. Nachgebaut mit den vorhandenen Mitteln – Hintergrundbild, Verlauf, Flexausrichtung –, der Text mittig gesetzt. Dabei fiel auf, dass die Zeile mit Datum, Autor und Kategorie oberhalb davon stand: bei einem Beitrag, der seinen Titel im Bild trägt, ist sie doppelt. Sie ist jetzt abschaltbar (`postMetaHeader`), Voreinstellung bleibt an.
|
||||
|
||||
## ADR 044 – Genauer hinsehen: Punkte statt Rollleiste, Karten statt Zeilen (13.09.2026)
|
||||
|
||||
Umut hat den Nachbau Detail für Detail mit dem Original verglichen, und er hatte in jedem Punkt recht. Was dabei herauskam, sind nicht Geschmacksfragen, sondern vier Stellen, an denen das CMS etwas nicht konnte oder etwas Falsches tat.
|
||||
|
||||
**Ein Slider ist kein Schiebefeld.** Der Slider war ein Scrollbereich mit sichtbarer Rollleiste – im Hero sah das aus wie ein Versehen. Das Original hat Punkte. Punkte gibt es jetzt, und zwar **ohne Skript**: `scroll-marker-group` und `::scroll-marker` lassen den Browser je Folie einen Punkt zeichnen, der aktive ist über `:target-current` erkennbar, und ein Klick scrollt. Wo ein Browser das noch nicht kennt (heute Firefox und Safari), bleibt ein wisch- und tastaturbedienbares Schiebefeld – also kein Ausfall, nur weniger Komfort. Die Rollleiste ist dort ausgeblendet, wo Punkte die Orientierung übernehmen.
|
||||
|
||||
**Pfeile gibt es bewusst nicht.** `::scroll-button()` existiert, aber Chrome legt die Knöpfe weder absolut noch klebend in den Slider, sondern an den Rand der Seite. Lieber keine Pfeile als ein Knopf, der am Seitenrand schwebt.
|
||||
|
||||
**Was der Slider nicht kann, steht hier:** er rotiert nicht von selbst. Eine öffentliche Seite lädt kein JavaScript, und eine CSS-Animation, die sich nicht anhalten lässt, wäre ein Barrierefreiheitsproblem statt einer Lösung.
|
||||
|
||||
**Eine Beitragskarte war keine Karte.** Sie war eine Spalte aus Bild, Überschrift und Text ohne Rahmen. Im Original ist sie eine Karte: weiß, 18 px Ecken, Bild bündig oben im Verhältnis 16:10, Text in einem eigenen Feld darunter. Genau so sieht sie jetzt aus – und zwar für jede Website, weil es die Darstellung des Bausteins ist und keine Einstellung dieser einen Seite.
|
||||
|
||||
**Die Schriftgrößen der Bausteine verloren gegen die Theme-Skala.** `.kant h3` aus dem Theme und `.block-iconcard h3` aus `site.css` hatten dieselbe Spezifität, und der Themeblock steht später im Dokument – also gewann er. Jede Kartenüberschrift war so groß wie eine Abschnittsüberschrift, auf jeder Website mit eigener Schriftskala. Die Bausteinregeln tragen jetzt `.kant` davor und gewinnen wieder.
|
||||
|
||||
**Eine Überschrift darf ein Wort hervorheben.** Im Original ist die zweite Zeile der Hauptüberschrift rot. Dafür dürfen Überschriften ab v17 Auszeichnungen tragen – durch denselben Filter wie jeder Text. **Dabei wäre beinahe eine Lücke entstanden:** die Fassung des Dokuments war in der Schliessung, die den Filter läuft, gar nicht vorhanden; der Filter lief nie, und der Inhalt wird unescaped ausgegeben. Der Test, der ein `<script>` in eine Überschrift schreibt, hat es sofort gezeigt – und er bleibt genau dafür stehen. Zusätzlich entscheidet die Ausgabe jetzt an einer eigenen Fahne (`richHeadings`), damit eine ältere Seite ihre Überschrift weiterhin wörtlich zeigt.
|
||||
|
||||
## ADR 043 – Was beim Nachbauen noch fehlte (Dokumentschema v17, 13.09.2026)
|
||||
|
||||
Umut hat den Nachbau angesehen und vier Dinge benannt: der Abstand zwischen den Lösungskarten und der Projektliste fehlt, die Karten sind zu hoch, die Beiträge sehen nicht aus wie im Original, und später: im Hero fehlen die letzten Projekte als Slider, der Kopfbereich klebt nicht beim Scrollen, das Logo verlinkt nicht auf die Startseite. Zwei davon waren mein Aufbau, vier waren fehlende Fähigkeiten.
|
||||
|
||||
**Eine Beitragsliste kann jetzt ein Projektraster sein.** Im Original zeigt eine Projektkarte Bild und Titel. Die Beitragsliste zeigte immer zusätzlich Datum, Autor, Auszug und Kategorie – richtig für ein Blogarchiv, falsch für eine Projektseite, und nicht abstellbar. Drei Schalter (`showMeta`, `showExcerpt`, `showTerms`) beantworten das, mit den bisherigen Werten als Voreinstellung, damit bestehende Seiten sich nicht ändern.
|
||||
|
||||
**Dabei wäre es beinahe schiefgegangen, und zwar lautlos:** `{{ block.props.showMeta|default(true) }}` gibt in Twig auch dann `true` zurück, wenn der Wert `false` ist – der `default`-Filter greift auf jeden leeren Wert, nicht nur auf einen fehlenden. Die Einstellung hätte sich speichern lassen und nichts getan. Mit `??` statt `|default()` bleibt `false` ein `false`. Der Test prüft beide Richtungen, weil nur die Gegenprobe diesen Fehler findet.
|
||||
|
||||
**Ein Bild darf verlinken.** Klingt selbstverständlich und fehlte: ein Logo im Kopfbereich ohne Verweis auf die Startseite ist der erste Klick, den jeder sucht. Die Adresse geht durch dieselbe Prüfung wie jeder andere Link, also kein `javascript:`, kein `data:`, kein schemaloses `//fremd.test`.
|
||||
|
||||
**Der Kopfbereich kann beim Scrollen oben bleiben.** Das ist bewusst eine Einstellung der Website und keine Eigenschaft eines Blocks: kleben kann nur das Element, das den Bereich umschließt (`.site-header`), und ein Abschnitt *innerhalb* davon würde nur solange kleben, wie sein Elternelement sichtbar ist – also gar nicht. Wer es im Designer an einem Abschnitt versucht hätte, hätte eine Einstellung gefunden, die nichts tut.
|
||||
|
||||
**Der Slider im Hero** brauchte nichts Neues: vier Folien mit Bild und Titel im vorhandenen Slider-Baustein. Ehrlich dazugesagt: **er rotiert nicht von selbst.** Öffentliche Seiten laufen ohne JavaScript, und daran ändert eine Animation nichts – gewischt, gescrollt und mit der Tastatur bedient wird er, und er rastet je Folie ein. Eine automatische Rotation wäre als reine CSS-Animation möglich, aber sie wäre nicht anhaltbar, und ein Karussell, das sich nicht anhalten lässt, ist ein Barrierefreiheitsproblem.
|
||||
|
||||
**Und ein Fehler, der größer ist als alles andere in diesem Paket:** die Liste bekannter Schemafassungen im Editor endete bei **12**, während Bausteine längst v13 bis v15 verlangten. Folge: **in einem aktuellen Dokument ließ sich im Editor gar kein Baustein mehr einfügen** – jeder Versuch endete mit „Unbekanntes Dokumentschema“ – und Symbol, Fortschritt, Slider und eingebetteter Inhalt nirgends. Der Playwright-Editortest hat es nicht gefunden, weil er auf einem alten Dokument arbeitet und die betroffenen Typen nicht einfügt. Jetzt steht die höchste Fassung als **eine Zahl an einer Stelle** (`SCHEMA_VERSION`), und ein Test fügt jeden Bausteintyp in Dokumente von v1 bis v17 ein.
|
||||
|
||||
## ADR 042 – Der Nachbau von kant-it.de, und was er gefunden hat (12.09.2026)
|
||||
|
||||
Umut: „Baue mal lokal kant-it.de komplett nach. Ich möchte sehen ob das funktioniert.“ Gebaut ist die Startseite mit allen neun Abschnitten, die Projekteseite, vier Projektbeiträge, Impressum und Datenschutzgerüst, Kopf- und Fußbereich, zwei Menüs, das Kontaktformular, zwölf Bilder und die Palette der echten Website – in einer eigenen Datenbank, damit die Entwicklungsinstallation unberührt bleibt.
|
||||
|
||||
**Das Ergebnis der Übung sind nicht die Bilder, sondern fünf Fehler, die sonst ein Kunde gefunden hätte.** Eine echte Website zu bauen ist eine andere Prüfung als ein Test: sie benutzt alles gleichzeitig.
|
||||
|
||||
**1. Die Gestaltung färbte die Bausteine nicht.** Symbole, Knöpfe, Rahmen, ruhiger Text und der Einwilligungsdialog kamen aus festen Token in `site.css` – eine Website mit roter Palette behielt das Grün der Vorlage. Die Farben ließen sich einstellen und taten nichts, dieselbe Familie wie Rahmenstärke und Bildrundung (ADR 036). Das Theme setzt jetzt `--ink`, `--paper`, `--accent`, `--muted`, `--line` und `--mint` selbst. Als Akzent dient die **Linkfarbe**, nicht ein Palettenname: dieses Token färbt auch kleine Beschriftungen, und eine helle Akzentfarbe wäre dort nicht mehr lesbar. `--muted` und `--line` werden aus Textton und Hintergrund gemischt; eine Palettenfarbe gleichen Namens gewinnt.
|
||||
|
||||
**2. Der Einwilligungsdialog war grün**, unabhängig von der Website. Er nimmt jetzt die Primärfarbe – und lässt sich eigens färben, indem im Design eine Farbe mit dem Schlüssel `consent` angelegt wird. Kein zusätzlicher Schalter, nur eine Regel, die eine Palettenfarbe bevorzugt.
|
||||
|
||||
**3. „Di-gitale Exzellenz“.** `hyphens:auto` galt für h1 bis h3. Im Fließtext ist Trennung nützlich, in einer 64-Pixel-Überschrift sieht sie nach einem Defekt aus – und genau dort fällt sie auf. Überschriften trennen nicht mehr.
|
||||
|
||||
**4. Ruhiger Text auf dunklem Grund.** `.block-text` hatte fest `color:var(--muted)`, eine Farbe für helle Hintergründe. In einem dunkel gefärbten Abschnitt stand damit Grau auf Marineblau. Jetzt wird die Farbe aus der geerbten gemischt (`currentColor` zu 82 Prozent), womit dieselbe Regel auf hellem und dunklem Grund funktioniert. Dasselbe gilt für die automatische Kleinbeschriftung des ersten Textblocks: sie mischt den Akzent in die geerbte Farbe, statt ihn zu setzen.
|
||||
|
||||
**5. Ein Kontrastfehler, den ich selbst eingebaut hatte.** Die erste Ableitung von `--muted` mischte zu 72 Prozent und ergab für eine Palette mit hellem Textton 4,09:1 statt 4,5:1. axe hat ihn im selben Durchlauf gefunden, in dem die Farben zum ersten Mal wirkten. Jetzt 80 Prozent.
|
||||
|
||||
**Was sich nicht als Fehler herausstellte, aber auffällt:** Ecken und Seiten müssen einzeln angegeben werden (`borderRadius` als Kurzschreibweise wird abgewiesen) – der Designer schreibt das ohnehin so, von Hand ist es überraschend. Und die Beschriftung eines Einwilligungsfeldes ist auf 120 Zeichen begrenzt; der Einwilligungssatz der echten Website hat 148. Der Rest musste in den Hilfetext.
|
||||
|
||||
**Was der Nachbau belegt:** die Seite läuft mit **null axe-Verstößen** auf vier Seiten in Desktop- und Handybreite, **ohne JavaScript**, mit Bildern als WebP in vier Breiten und einer Startseite, die dem Original so nahe kommt, dass der Unterschied in der Typografie liegt und nicht in der Technik. Was fehlt, ist ehrlich zu benennen: die vollständige Datenschutzerklärung (das Gerüst ist da, der Text der laufenden Website ist länger), die übrigen Projektbeiträge (vier von fünf Seiten Archiv) und die Unterseiten von HausKlar und KantTresor.
|
||||
|
||||
## ADR 041 – Der Installer: sechs Bildschirme, und danach ist er weg (12.09.2026)
|
||||
|
||||
Umut: „Einen Installer brauche ich noch wie wordpress schritt für schritt alles einrichten migration ausführen usw.“ und, später, „einen installer das eine php ist und alle dateien herunterladet“. Beides ist gebaut, und beides hängt an derselben Frage: was darf eine Datei dürfen, die auf einem leeren Webspace liegt und von jedem aufrufbar ist?
|
||||
|
||||
**Der Ablauf schreibt jeden Schritt sofort**, statt Antworten in einer Sitzung zu sammeln. Nach dem Datenbankschritt gibt es `.env.local`, nach dem Migrationsschritt Tabellen, nach dem Websiteschritt Inhalte. Ein Neuladen, ein Zeitüberlauf, ein geschlossener Tab verliert damit nichts – der Installer sieht, was schon da ist, und macht an der nächsten fehlenden Stelle weiter. Wer eine spätere Adresse rät, wird zurückgeschickt, und zwar vom Server, nicht von einem Link.
|
||||
|
||||
**Das Ende ist das erste Konto, und es ist ein Zustand der Datenbank, keine Datei.** Eine Datei, die den Installer sperrt, ist eine Datei, die jemand löschen kann – und dann steht auf einer laufenden Website wieder ein Formular, das ein Administratorkonto anlegt. Gibt es ein Konto, antwortet jede Adresse unter `/install` mit 404.
|
||||
|
||||
**Kein Sitzungstoken, sondern eine Unterschrift.** Das Sitzungscookie dieser Anwendung gilt nur für `/admin` – eine bewusste Entscheidung, die öffentliche Seiten cachebar hält –, und für sechs Bildschirme wird sie nicht aufgeweicht. Jedes Formular trägt Schritt und Zeitpunkt, mit `APP_SECRET` unterschrieben, zwei Stunden gültig. Ehrlich dazugesagt: das ist kein CSRF-Schutz, denn wer den Installer erreicht, bekommt auch eine gültige Unterschrift. Der Schutz ist, dass es den Installer nach dem ersten Konto nicht mehr gibt – deshalb steht auf dem letzten Bildschirm, dass sofort nach dem Hochladen installiert werden soll.
|
||||
|
||||
**Migrationen laufen im Prozess, nicht über die Konsole.** Auf geteiltem Hosting gibt es oft keine Shell, und `proc_open` ist ein häufiges Opfer einer gehärteten `php.ini`. Der Installer benutzt dieselbe `migrations.php` und dieselbe `DependencyFactory` wie das Konsolenkommando. Bricht der Browser ab, ist nichts kaputt: angewandte Migrationen sind vermerkt, ein erneuter Aufruf macht weiter.
|
||||
|
||||
**Ein Huhn-Ei-Problem und seine Lösung:** ohne `APP_SECRET` bootet Symfony nicht – also nicht einmal die Seite, die erklären würde, warum. Deshalb erzeugt der erste Start das Geheimnis selbst und schreibt es nach `.env.local`. Geht das nicht, läuft die Anfrage trotzdem, und die Systemprüfung sagt in einem Satz, dass im Projektstamm das Schreibrecht fehlt. Eine weiße Seite wäre die Alternative gewesen.
|
||||
|
||||
**Und eine Weiterleitung statt eines Fehlers:** solange keine Installation existiert, führt jede Adresse zu `/install`. Der erste Besuch auf einem frisch entpackten Webspace ist sonst ein 500 aus einer fehlenden Tabelle – genau der Moment, in dem jemand einen Satz braucht und keinen Stacktrace. Sobald ein Konto existiert, wird eine Marke unter `var/installed` geschrieben und keine Anfrage fragt die Datenbank noch einmal; die Marke schweigt nur diesen Subscriber, der Installer selbst fragt immer die Datenbank.
|
||||
|
||||
**Die einzelne Datei davor** (`install.php`) lädt das Releasearchiv, prüft dessen SHA-256, entpackt es und löscht sich danach selbst. Adresse und Prüfsumme stehen **in der Datei**, eingetragen beim Bauen des Releases: ein Installer, der eine URL aus einem Formular entgegennimmt, ist eine Einladung, fremden Code auf den Server zu legen. Stimmt die Prüfsumme nicht, wird nicht entpackt – es gibt kein Häkchen daran vorbei. Jeder Archiveintrag wird gegen das Zielverzeichnis geprüft, bevor er geschrieben wird; geprüft wird nicht durch Suchen nach „..“, sondern indem der zusammengesetzte Zielpfad unterhalb des Ziels liegen muss. In ein Verzeichnis, in dem schon eine Installation liegt, arbeitet sie gar nicht erst.
|
||||
|
||||
**Was der Installer anlegt, ist nicht die Demoseite.** Eine Startseite mit dem Namen der Website, Kopf- und Fußbereich mit Menü – und Impressum und Datenschutzerklärung als Gerüst mit markierten Lücken. Das Letzte ist kein Entgegenkommen: eine deutsche Unternehmenswebsite ohne beides ist ab dem ersten Tag ein Problem, und die Installation ist der einzige Moment, in dem mit Sicherheit jemand hinsieht.
|
||||
|
||||
## ADR 040 – Bildgrößen, und ein Release für Hosting ohne Composer (12.09.2026)
|
||||
|
||||
Zwei Fragen von Umut in einem Satz: „werden Bilder in verschiedenen Größen angelegt und in WebP umgewandelt?“ und „auf einem Hostingpaket ist kein Composer, meistens, oder?“ – die erste war zur Hälfte schon beantwortet, die zweite ist berechtigt und hatte bis heute keine Antwort.
|
||||
|
||||
**Die Bilder: ja, und jetzt eine Stufe weiter.** Ein Upload wird nie im Original gespeichert. GD decodiert ihn, dreht ihn nach EXIF gerade, behandelt Transparenz und schreibt WebP mit Qualität 82 – bisher in drei Breiten, jetzt in **vier: 480, 960, 1600, 2400**. Die vierte Stufe ist keine Kosmetik: ein vollflächiges Startseitenbild auf einem zweifach skalierten Bildschirm wurde vorher aus 1600 px hochgezogen und sah entsprechend aus. Hochskaliert wird dabei nichts – ist die Quelle 1800 px breit, ist die letzte Stufe 1800 px, nicht 2400.
|
||||
|
||||
Der Bildbaustein liefert `srcset` und `sizes`, der Browser wählt. **Ein Hintergrundbild kann das nicht**, denn CSS hat kein `srcset` – und genau da lag bisher die Lücke: ein Sektionshintergrund bekam immer die größte Variante, auch auf dem Telefon. Da die Gestaltung ohnehin je Gerät geschrieben wird, entscheidet nun das Gerät: Mobil 960, Tablet 1600, Desktop die größte vorhandene. Das ist die richtige Stelle dafür, weil die Regel ohnehin in einem `@media`-Block steht – es kostet kein Byte zusätzlich.
|
||||
|
||||
Der Preis, ehrlich benannt: **vier Stufen kosten Rechenzeit und Platz beim Upload.** Deshalb bleibt die konservative Prüfung gegen das Speicherlimit vor dem Decodieren stehen; sie ist es, die ein Hosting mit 128 MB vor einem Abbruch mitten im Schreiben bewahrt. Ein Test hat das sofort gezeigt, als er im Rahmen der gesamten Suite lief und der Speicher schon halb belegt war – das war kein Testproblem, sondern die Schutzfunktion bei der Arbeit.
|
||||
|
||||
**Composer: Umut hat recht, und das ändert den Auslieferweg.** Geteiltes Hosting hat in aller Regel keinen Composer, oft nicht einmal eine Shell. Die Frage stand seit `docs/UPDATE.md` offen („Composer auf dem Server oder `vendor/` im Tag?“). Sie ist jetzt entschieden: **es gibt ein Releasearchiv.** `scripts/build-release.php` läuft auf einem Rechner, der Composer hat, baut `vendor/` ohne Entwicklungspakete in eine Kopie des Projekts und schreibt `var/releases/kantsites-<Fassung>.zip` samt SHA-256. Im Archiv liegt die Anwendung und sonst nichts: keine Tests, keine Skripte, kein `var/`, kein `.env`. Das Git-Repository bleibt dabei frei von `vendor/` – wer entwickelt, bekommt keine 3000 fremden Dateien in den Diff.
|
||||
|
||||
Daraus folgt eine Regel für den Updater, und sie ist wichtiger als das Archiv selbst: **ein Update, das `composer.lock` ändert, bricht ab, wenn Composer auf dem Server nicht aufrufbar ist.** Vorher hätte er den Code gewechselt und wäre mit einem `vendor/` weitergelaufen, das nicht mehr zum Code passt – der Fehler wäre erst beim ersten Seitenaufruf aufgetaucht, nach dem Rauchtest, als weiße Seite. Jetzt vergleicht der Updater die Prüfsumme der Datei vor und nach dem Checkout und sagt in einem Satz, was zu tun ist: das Releasearchiv einspielen, das `vendor/` mitbringt. Danach läuft der normale Rückweg.
|
||||
|
||||
**Und ein Fehler, den erst der Browsertest zeigte:** der Dokumentprüfer erlaubte drei `sources` je Bild. Die vierte Variante machte damit **jedes Dokument mit einem Bild ungültig** – ohne sichtbare Meldung, denn eine abgewiesene Speicherung sieht aus wie nichts. Das ist dieselbe Familie wie Rahmenstärke und Bildrundung: eine Zahl an einer Stelle, die niemand mit der Stelle verbindet, an der sie herkommt. Die Grenze liest jetzt `MediaLibrary::WIDTHS`, damit sie sich mit der Pipeline bewegt.
|
||||
|
||||
**Nebenbei gefunden:** `CommandRunner` konnte unter Windows `composer` nicht starten, weil das dort eine `.bat` ist und `CreateProcess` keine Stapeldatei ausführt. Ein Kandidat über `%COMSPEC% /c` behebt es – die Argumentliste bleibt fest, es kommt nichts aus einer Anfrage hinein.
|
||||
|
||||
## ADR 039 – Messen statt vermuten: Abfragen, Ladebudget, Blättern (12.09.2026)
|
||||
|
||||
|
||||
+4
-4
@@ -151,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~~ — **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).
|
||||
~~Benutzerverwaltung und Passwortänderung in der Oberfläche.~~ — **erledigt am 12.09.2026** (ADR 030). Offen bleibt der Rest des Pakets: Inhaltscache mit gezielter Erneuerung beim Veröffentlichen. ~~Bildformate prüfen.~~ — **erledigt am 12.09.2026** (ADR 040): WebP in vier Breiten, Hintergrundbilder je Gerät. Ressourcenpakete bleiben offen. ~~Sicherung und Wiederherstellung erproben~~ — **erledigt** in Arbeitspaket O: die Wiederherstellung ist im Test und von Hand nachgewiesen. Updateverfahren mit Datenbankmigration und Rückfallstrategie dokumentieren — **geplant am 12.09.2026 in `docs/UPDATE.md`**: neue Version über einen Git-Tag ziehen, mit Vorprüfung, Sicherung, Wartungszustand, Migration, Rauchtest und Rückweg. Umgesetzt am 12.09.2026 (ADR 035). Die Frage „Composer auf dem Server oder `vendor/` im Tag“ ist entschieden (ADR 040): ein Releasearchiv vom Build-Rechner bringt `vendor/` mit, und ein Update, das die Abhängigkeiten ändert, bricht ohne Composer ab statt halb durchzulaufen. Offen bleibt, ob das Hosting Shell-Zugriff hat.
|
||||
|
||||
Abnahme: Veröffentlichen macht die Änderung sofort sichtbar und erneuert nur die betroffenen Einträge. Eine Wiederherstellung aus dem Backup ist nachgewiesen.
|
||||
|
||||
@@ -191,11 +191,11 @@ Umut: „alle inhalte prüfen immer pagination und minimal laden“. Als durchge
|
||||
|
||||
Abnahme: keine Liste in der Verwaltung, die mehr Einträge hat als sie zeigt, ohne das zu sagen und ohne Blättern. Ein automatischer Test, der die Größe der Startseite und die Anzahl der Datenbankabfragen prüft. **Beides erfüllt.** Offen bleibt aus diesem Paket: `srcset` ist für Bilder aus der Mediathek vorhanden, aber eine Seite mit vielen Bildern ist nie gegen das Budget gemessen worden, und die Benutzerliste blättert noch nicht (sie ist naturgemäß kurz).
|
||||
|
||||
### N – Installer (Nutzerauftrag vom 12.09.2026)
|
||||
### N – Installer (Nutzerauftrag vom 12.09.2026) — **erledigt am 12.09.2026** (ADR 041), außer dem Download aus einer veröffentlichten Releaseadresse
|
||||
|
||||
**Teilweise erledigt am 12.09.2026:** der *Updater* ist gebaut (ADR 035), der *Installer* nicht. Beide teilen die Systemprüfung, die jetzt existiert und dort wiederverwendet wird.
|
||||
**Gebaut:** der Browserinstaller unter `/install` mit sechs Bildschirmen und die einzelne `install.php`, die ein Buildskript mit Adresse und Prüfsumme versieht. Der Schritt „Datenbank“ fragt SQLite oder MySQL ab und prüft die Verbindung sofort; damit brauchte er die Hostingentscheidung aus Arbeitspaket J nicht mehr abzuwarten.
|
||||
|
||||
*Braucht: die Hostingentscheidung aus Arbeitspaket J für den Schritt „Datenbank“.*
|
||||
**Offen bleibt:** ein echter Download (dazu muss ein Release unter seiner Adresse liegen), MySQL gegen einen echten Server, und eine Installation auf echtem Hosting.
|
||||
|
||||
Umut: „Einen Installer brauche ich noch wie wordpress schritt für schritt alles einrichten migration ausführen usw.“ Heute braucht eine Installation `bin/setup.php` und Konsolenzugriff – auf geteiltem Hosting ist das genau die Hürde, an der es scheitert.
|
||||
|
||||
|
||||
@@ -94,3 +94,9 @@ Neben dem Ausbau zwei echte Fehler, beide vorher unbemerkt:
|
||||
|
||||
- **Rahmenstärken je Seite hatten nie eine Wirkung.** `border-width-top` ist kein CSS; der Browser verwirft die Zeile stillschweigend. Die Schreibweise steht jetzt je Eigenschaft im Schema, weil sie sich nicht erraten lässt.
|
||||
- **Eine Rundung auf einem Bildblock rundete den Rahmen, nicht das Bild.** Jetzt erbt das Bild die Rundung seines Rahmens, und die neuen Bild-Eigenschaften schreiben ohnehin direkt auf das Bild.
|
||||
|
||||
## Offen: Menüart je Gerät (13.09.2026)
|
||||
|
||||
Beim Nachbau von kant-it.de aufgefallen: die **Menüart** (`horizontal`, `vertikal`, `Burger`) gilt für alle Bildschirmgrößen. Gebraucht wird fast immer „auf dem Desktop nebeneinander, auf dem Telefon ein Burger". Heute geht das nur über **zwei Menüblöcke**, von denen je Gerät einer über `display: none` ausgeblendet wird – das funktioniert und ist barrierefrei (der ausgeblendete Block steht nicht im Zugänglichkeitsbaum), aber es ist eine Umgehung und keine Einstellung.
|
||||
|
||||
Sauber wäre die Menüart als Wert je Gerät, wie es die Gestaltungseigenschaften ohnehin sind. Dafür müsste die Umschaltung ohne doppeltes Markup auskommen – `<details>` lässt sich per CSS nicht zuverlässig in allen Browsern aufklappen, also läuft es entweder auf ein Aufklappmuster ohne `details` hinaus oder auf zwei gerenderte Varianten, von denen eine verborgen ist. Die Entscheidung steht aus.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Abgleich mit dem Flatsome UX Builder
|
||||
|
||||
## Bedienkorrektur für leere Layouts (14.09.2026, KantSites 1.6.2)
|
||||
|
||||
Erneuter Quelltextabgleich mit dem von Umut genannten Ordner `C:\Users\UmutKantarci\Downloads\flatsome`, Theme-Version **3.20.9** laut `style.css`. Keine laufende WordPress-Installation dieses Pakets gestartet; die folgenden Aussagen stammen aus seinen tatsächlichen Builder-Dateien.
|
||||
|
||||
| Beobachtung im Flatsome-Code | Umsetzung in KantSites |
|
||||
| --- | --- |
|
||||
| `assets/css/builder/core/content.css`: leeres `content` mit mindestens 90 px Höhe, getöntem Hintergrund und gepunktetem Rahmen | Vorschau-only-Flächen für leere Sektionen, Zeilen und Spalten mit realer Mindesthöhe, Rahmen und Typbezeichnung |
|
||||
| `tools-addable.is-empty` und `assets/js/builder/core/content.js`: Plus in der Mitte, eigener Einfüge-/Drop-Zustand für leere Container | „Inhalt hinzufügen“ im Zielbereich; Spalten werden nummeriert; der Einfügepunkt wird konkret markiert und benannt |
|
||||
| `inc/builder/shortcodes/row.php`: fertige Zeilen mit einer, zwei, drei und vier Spalten | Diese vier gleichmäßigen Layouts direkt in der leeren Sektion anbieten, einschließlich passender mobiler Stapelung |
|
||||
|
||||
Zusätzlich: neue Container werden nach dem Rendern in den sichtbaren Ausschnitt gebracht, ein Auswahlwechsel löst alte Einfügepunkte, und beim Ziehen scrollt die Vorschau an den Rändern mit. Die Ablageanzeige nennt den Zielcontainer; beim Verschieben von Spalten entscheidet die horizontale Position. Editorhilfen entstehen ausschließlich im Vorschau-DOM, nicht im gespeicherten Dokument oder in der öffentlichen Ausgabe.
|
||||
|
||||
Der ältere Gesamtabgleich unten bleibt eine historische Bestandsaufnahme. Diese Änderung übernimmt konkrete Bedienprinzipien für den Aufbau, keine vollständige Flatsome-Oberfläche und keine WordPress-/Shortcode-Abhängigkeit.
|
||||
|
||||
Stand: 14.09.2026, KantSites 1.5.0 (Dokumentschema v19).
|
||||
Quelle des Vergleichs: Flatsome-Theme im Download-Ordner, `inc/builder/shortcodes/` (69 Definitionen)
|
||||
und `inc/shortcodes/`. Ziel laut Auftrag: alle Funktionen übernehmen und verbessern, die
|
||||
Bedienbarkeit übernehmen und verbessern, die Oberfläche mobil nutzbar machen — der Builder
|
||||
bleibt dabei Desktop-first.
|
||||
|
||||
## 1. Wo KantSites heute schon besser ist
|
||||
|
||||
Diese Punkte sind bereits gelöst und dürfen beim Nachbau nicht verschlechtert werden.
|
||||
|
||||
| Thema | Flatsome | KantSites heute |
|
||||
| --- | --- | --- |
|
||||
| Datenhaltung | Shortcode-Text im `post_content`, per Regex geparst | Validierter Blockbaum mit stabilen IDs, versioniert (v19), Migrationsweg |
|
||||
| Gestaltung | Feste Optionslisten je Element, wenige Werte je Gerät | 93 freie Eigenschaften, je Block, je Gerät **und** für Hover |
|
||||
| Öffentliche Ausgabe | jQuery, mehrere Bundles, Slider/Tabs brauchen JS | Tabs/Slider/Fortschritt ohne JavaScript, Editorcode nicht im Frontend |
|
||||
| Entwurf/Live | Revisionen von WordPress | Entwurf und Veröffentlichung getrennt, Fassungsverlauf, geplante Veröffentlichung, Papierkorb |
|
||||
| Recht | Plugins nötig | Einwilligung mit echtem Zurückhalten, Statistik ohne Wiedererkennung, Pflichtseiten |
|
||||
| Eigener Code | `ux_html`-Element | CSS je Element für alle, Skript nur für Administratoren, als eigene Datei |
|
||||
|
||||
**Folgerung:** Der Gestaltungsteil muss nicht nachgebaut werden. Zu tun sind der
|
||||
Elementkatalog und die Bedienung.
|
||||
|
||||
## 2. Elementkatalog — Lücke
|
||||
|
||||
Flatsome hat 69 Builder-Elemente, davon 12 reine Shop-Elemente (WooCommerce).
|
||||
KantSites hat 31 Blocktypen.
|
||||
|
||||
### Bereits abgedeckt (21)
|
||||
|
||||
`section` `row` `col` `title` `text` `ux_image` `button` `ux_gallery` `blog_posts`
|
||||
`featured_box` `testimonial` `accordion` `accordion_item` `tabgroup` `tab` `contact`
|
||||
`divider` `gap` `block` `ux_menu` `ux_slider`
|
||||
|
||||
### Offen, ohne Shop (20)
|
||||
|
||||
| Flatsome | Was es kann | Bewertung |
|
||||
| --- | --- | --- |
|
||||
| `ux_banner` | Bild/Video-Hintergrund, Overlay, Höhe, Parallax, Textposition | **hoch** — das meistgenutzte Element überhaupt |
|
||||
| `text_box` | frei positionierter Textkasten über einem Banner | **hoch** — gehört zu `ux_banner` |
|
||||
| `ux_stack` | Elemente nebeneinander, mit Abstand und Ausrichtung | **hoch** — löst viele Zeile/Spalte-Verrenkungen |
|
||||
| `ux_image_box` | Bild über Text als Karte | hoch |
|
||||
| `col_grid` / `ux_banner_grid` | Rasterlayout mit ungleichen Kacheln | mittel |
|
||||
| `message_box` | Hinweiskasten mit Symbol und Farbe | mittel |
|
||||
| `price_table` | Preistabelle | mittel |
|
||||
| `team_member` | Person mit Bild, Rolle, Sozialsymbolen | mittel |
|
||||
| `ux_pages` | Unterseiten dieser Seite auflisten | mittel |
|
||||
| `ux_video` | eingebettetes Video | teilweise über `embed` |
|
||||
| `video_button` | Video im Leuchtkasten öffnen | mittel |
|
||||
| `map` | Karte | teilweise über `embed` |
|
||||
| `follow` | Sozialsymbole zum Folgen | mittel |
|
||||
| `share` | Teilen-Symbole | niedrig |
|
||||
| `search` | Suchfeld | mittel |
|
||||
| `logo` | Logo der Website als Block | hoch (Kopfbereich) |
|
||||
| `scroll_to` | Ankerziel und Sprunglink | mittel |
|
||||
| `ux_countdown` | Countdown | niedrig |
|
||||
| `ux_hotspot` | Punkte auf einem Bild | niedrig |
|
||||
| `ux_lottie` / `ux_instagram_feed` / `ux_portfolio` | Animation, Instagram, Portfolio | niedrig |
|
||||
| `ux_the_title` | dynamischer Seitentitel | mittel (Vorlagen) |
|
||||
|
||||
### Bewusst ausgelassen (12)
|
||||
|
||||
Alle WooCommerce-Elemente (`products`, `ux_products`, `ux_products_list`,
|
||||
`ux_product_categories`, `ux_product_flip`, `ux_bestseller_products`, `ux_payment_icons`,
|
||||
`ux_product_gallery`, `woocommerce_cart`, `woocommerce_checkout`, `ux_badge`,
|
||||
`custom-product`). KantSites hat keinen Shop; ohne Warenkorb, Bestellung und Zahlung
|
||||
wären diese Blöcke leere Hüllen.
|
||||
|
||||
## 3. Bedienung — die eigentliche Lücke
|
||||
|
||||
Der Auftrag nennt die Bedienbarkeit ausdrücklich. Hier ist Flatsome heute voraus.
|
||||
|
||||
| Flatsome | KantSites heute | Verbesserung |
|
||||
| --- | --- | --- |
|
||||
| Element aus der Liste **in die Vorschau ziehen**, mit Einfügemarke | Einfügen „hinter das gewählte Element“ | Ziehen mit sichtbarer Marke, **zusätzlich** die heutige Tastaturbedienung behalten |
|
||||
| **Werkzeugleiste am Element** in der Vorschau (bearbeiten, duplizieren, löschen, Elternelement) | nur über den Strukturbaum links | Leiste am Element, Auswahl bleibt mit dem Baum verbunden |
|
||||
| **Pluszeichen zwischen Elementen** | — | Einfügepunkt genau dort, wo geklickt wird |
|
||||
| Elementwahl als **Bilderraster** mit Suche | Textliste mit Symbolzeichen | Raster mit echten Vorschaubildern, Suche, Kategorien |
|
||||
| **Vorlagen je Element** mit Vorschaubild (Sektion: 20+) | 4 Textvorlagen gesamt | Vorlagen je Element, mit Bild, erweiterbar |
|
||||
| **Je Option ein Geräteschalter** | ein globaler Geräteschalter | Schalter direkt an der Option, zeigt wo ein abweichender Wert gesetzt ist |
|
||||
| **Zahl durch Ziehen ändern** (`scrubfield`) | Eingabefeld | Ziehen plus Eingabe plus Pfeiltasten |
|
||||
| **Innen-/Außenabstand als Rahmenbild** | vier Felder | Rahmenbild mit verbundenen Seiten |
|
||||
| **Spaltenbreite in der Vorschau ziehen** | Zahl eingeben | Ziehgriff zwischen Spalten |
|
||||
| Vorschau **sofort** (clientseitige Vorlagen) | Server-Neuladen, entprellt | Sofortige optimistische Anzeige, Server bleibt die Wahrheit |
|
||||
| Kopieren/Einfügen zwischen Seiten | — | Zwischenablage für Blöcke, seitenübergreifend |
|
||||
| Mobil | Builder nicht bedienbar | Verwaltung vollständig mobil; Builder mobil im Lesemodus mit Inhaltsbearbeitung |
|
||||
|
||||
## 4. Vorgeschlagene Reihenfolge
|
||||
|
||||
1. **Arbeitspaket H — Bedienung des Builders.** Ziehen und Ablegen, Werkzeugleiste am
|
||||
Element, Einfügepunkte, Bilderraster, optimistische Vorschau. Größter Nutzen je Aufwand,
|
||||
betrifft jedes vorhandene Element sofort.
|
||||
2. **Arbeitspaket I — Elementkatalog Stufe 1.** `banner`, `textbox`, `stack`, `imagebox`,
|
||||
`logo`, `messagebox`. Die Blöcke, ohne die eine Startseite Umwege braucht.
|
||||
3. **Arbeitspaket J — Optionsbedienung.** Geräteschalter je Option, Ziehfelder,
|
||||
Abstandsrahmen, Spaltenbreite ziehen, Vorlagen je Element.
|
||||
4. **Arbeitspaket K — Elementkatalog Stufe 2.** Raster, Preistabelle, Team, Unterseiten,
|
||||
Video-Leuchtkasten, Suche, Sozialsymbole, Ankersprung.
|
||||
5. **Arbeitspaket L — Mobile Oberfläche.** Verwaltung und Builder auf dem Telefon.
|
||||
|
||||
Jede Stufe endet mit einem prüfbaren Ergebnis und darf die Qualitätsziele aus AGENTS.md
|
||||
nicht verschlechtern.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Livegang von kant-it.de
|
||||
|
||||
Stand: 13.09.2026. Diese Liste trennt, was **geprüft ist**, von dem, was **nur auf dem echten Hosting geprüft werden kann** – und in welcher Reihenfolge umgeschaltet wird.
|
||||
|
||||
## Geprüft (lokal, gegen den Nachbau)
|
||||
|
||||
| Prüfung | Ergebnis |
|
||||
| --- | --- |
|
||||
| PHPUnit, PHP 8.2 und 8.4 | 409 Tests, 1791 Zusicherungen, grün |
|
||||
| JavaScript-Modelltests | grün |
|
||||
| Editor durchgespielt (Playwright + axe) | grün, isolierte Datenbank |
|
||||
| Öffentliche Seiten (Playwright + axe, 1440 px und 390 px) | keine Beanstandung |
|
||||
| Interne Verweise (`npm run test:links`) | 35 Seiten, 408 Adressen, kein toter Verweis |
|
||||
| Lighthouse, vier Adressen, je drei Läufe | Barrierefreiheit, Best Practices, SEO, agentic browsing **100** überall; Performance 100 außer Startseite (98 mobil / 96 Desktop) |
|
||||
| Sicherung (`cms:backup`) | angelegt: 33 Seiten, 100 Fassungen, 388 Mediendateien |
|
||||
| Weiterleitungen der alten WordPress-Adressen | 14 Stück angelegt, alle beantwortet mit 301 |
|
||||
|
||||
Gemessen wurde im Produktionsmodus auf dem PHP-Entwicklungsserver, der dieselben Kopfzeilen sendet wie `public/.htaccess`. **Das ist kein Ersatz für eine Messung auf dem echten Hosting.**
|
||||
|
||||
## Nicht geprüft – muss vor dem Umschalten passieren
|
||||
|
||||
1. **E-Mail-Versand.** Nie ausgeführt, in keiner Umgebung. `MAILER_DSN=sendmail://default` funktioniert auf den meisten PHP-Hostings; verlangt der Anbieter SMTP, gehört dort eine Zeile der Form `smtp://benutzer:passwort@mailserver:587` hinein. **Nach dem Hochladen eine echte Anfrage über das Kontaktformular abschicken und prüfen, ob sie ankommt** – auch im Spam-Ordner. Die Anfrage wird vor dem Versand gespeichert; kommt keine Mail an, steht sie trotzdem unter „Formulare“ in der Verwaltung.
|
||||
2. **HTTPS.** Zertifikat einrichten, dann in `public/.htaccess` die drei Zeilen der HTTPS-Umleitung einschalten. **Erst wenn das eine Weile läuft**, die HSTS-Zeile aktivieren – sie lässt sich in einem Browser ein halbes Jahr lang nicht zurücknehmen.
|
||||
3. **www oder nicht.** Das alte kant-it.de ist kanonisch mit `www`. In `.htaccess` steht die Umleitung dafür vorbereitet und auskommentiert; sie muss zur Einstellung `SITE_ORIGIN` passen, die in `.env` steht. Beide zeigen auf dieselbe Schreibweise, sonst zeigt jede Seite auf eine Adresse, die weiterleitet.
|
||||
4. **Datenbank.** Geprüft ist ausschließlich SQLite. Für MySQL ist alles vorbereitet, aber nichts erprobt – wer MySQL nimmt, sollte danach Seiten anlegen, veröffentlichen, Bilder hochladen und ein Formular absenden.
|
||||
5. **Wiederkehrende Aufgaben.** Sicherung, Statistik und geplante Veröffentlichung laufen nach dem Hochladen von selbst – angestoßen von Seitenaufrufen und vom geöffneten Backend (Verwaltung → Aufgaben). **Das genügt, ist aber nicht minutengenau:** ohne Besuch passiert nichts. Bietet der Hoster einen Cronjob, gehört `php bin/console cms:tasks:run` dorthin (jede Minute); bietet er nur einen URL-Cronjob, die Adresse vom Bildschirm „Aufgaben“ (alle fünf Minuten). Danach dort den Auslöser „Besuche der Website“ abschalten. **Nach dem Umzug einmal nachsehen**, ob in der Spalte „Zuletzt“ bei der Sicherung ein `ok` steht – ohne Sicherung gibt es keinen Rückweg.
|
||||
6. **Schriften.** Auf dem neuen Server einmal `php bin/console cms:fonts:fetch` ausführen oder im Designbereich den Knopf drücken – die Dateien liegen nicht im Repository. Bis dahin lädt die Seite die Schriften von Google, was sie nicht soll.
|
||||
7. **Rechtstexte.** Impressum und Datenschutzerklärung sind vom alten Auftritt übernommen. Der Abschnitt über Google Fonts wurde an die Wirklichkeit angepasst (die Schriften kommen jetzt vom eigenen Server), die Abschnitte über Google Maps und reCAPTCHA entfernt, weil beides nicht eingebunden ist. **Das ersetzt keine anwaltliche Prüfung**, und es ist die Verantwortung des Betreibers, nicht des CMS.
|
||||
8. **Texte.** Acht Projektbeschreibungen stammen nicht vom alten Auftritt, sondern sind neu geschrieben. Sie sollten gelesen werden, bevor sie öffentlich sind.
|
||||
|
||||
## Optional: die Verwaltung umbenennen
|
||||
|
||||
`/admin` ist die erste Adresse, die jeder Scanner probiert. `php bin/console cms:admin:path meine-verwaltung` legt sie woanders hin; der Name steht danach in `.env.local` und in keiner öffentlichen Datei. **Vorher notieren, wohin** – danach antwortet `/admin` mit 404 wie jede unbekannte Adresse. Zurück geht es mit `php bin/console cms:admin:path admin`.
|
||||
|
||||
## Optional: Spamschutz mit Cloudflare
|
||||
|
||||
Die Formulare haben drei eingebaute Prüfungen ohne Dritte. Kommt trotzdem Spam durch, gibt es unter
|
||||
Einstellungen → „Spamschutz für Formulare" Cloudflare Turnstile: Schlüsselpaar im Cloudflare-Konto
|
||||
anlegen (kostenlos), beide Schlüssel eintragen, Haken setzen. **Vorher wissen:** das Widget braucht
|
||||
JavaScript, und die IP-Adresse des Absenders geht an Cloudflare – beides gehört in die
|
||||
Datenschutzerklärung, bevor es scharf geschaltet wird.
|
||||
|
||||
## Reihenfolge am Umzugstag
|
||||
|
||||
1. Dateien auf das Hosting, `.env` mit `APP_ENV=prod`, `APP_DEBUG=0`, `DATABASE_URL`, `MEDIA_DIRECTORY`, `SITE_ORIGIN`, `MAILER_DSN` und einem neuen `APP_SECRET`.
|
||||
2. Datenbank und Medienordner einspielen (oder `php vendor/bin/doctrine-migrations migrate` auf einer leeren Datenbank und dann der Installer).
|
||||
3. `php bin/console cms:fonts:fetch`, danach eine Seite aufrufen und im Quelltext prüfen, dass nichts von `fonts.googleapis.com` geladen wird.
|
||||
4. Kontaktformular abschicken, Mail prüfen.
|
||||
5. **Jetzt erst** die Domain umstellen.
|
||||
6. Danach: `CMS_URL=https://www.kant-it.de npm run test:links` von einem Rechner mit Node, `robots.txt` und `sitemap.xml` im Browser aufrufen, die Sitemap in der Google Search Console neu einreichen, und drei bis vier alte Adressen von Hand aufrufen (etwa `/productsolutions/`, `/kanttresor-dsgvo-konforme-archivierung-fuer-moderne-unternehmen/`) – sie müssen mit 301 auf die neue Adresse führen.
|
||||
7. Eine Woche lang in der Search Console auf 404-Meldungen schauen; jede weitere alte Adresse bekommt eine Weiterleitung unter „Einstellungen → Weiterleitungen“.
|
||||
|
||||
## Rückweg
|
||||
|
||||
Das alte WordPress bleibt bis auf Weiteres bestehen. Geht etwas schief, wird die Domain dorthin zurückgestellt; die Inhalte des neuen Auftritts liegen unverändert in der Sicherung unter `var/backups/`.
|
||||
@@ -0,0 +1,228 @@
|
||||
# Eine neue Website mit KantSites bauen
|
||||
|
||||
Diese Anleitung richtet sich an den, der die Arbeit macht – Mensch oder Assistent. Sie sagt, in
|
||||
welcher Reihenfolge etwas entsteht, welche Entscheidung wohin gehört und welche Fehler dieses
|
||||
Projekt schon einmal gemacht hat. Wer sie befolgt, kommt ohne Umwege zu einer Website, die die
|
||||
Prüfungen besteht.
|
||||
|
||||
**Lies zuerst:** `AGENTS.md` (Projektregeln), `docs/STATUS.md` (was es gibt und was fehlt),
|
||||
`docs/ARCHITECTURE.md` (warum es so ist). Diese drei sind der Stand der Dinge; diese Datei hier ist
|
||||
das Kochbuch.
|
||||
|
||||
---
|
||||
|
||||
## 0. Die Grundregel
|
||||
|
||||
**Alles, was eine Website ausmacht, steht in der Datenbank – nicht im Code.** Seiten, Kopf- und
|
||||
Fußbereich, Menüs, Formulare, Farben, Schriften, Einstellungen. Wer für eine neue Website eine
|
||||
Twig-Datei anfasst oder CSS in `public/assets/site.css` schreibt, hat sich verlaufen: dann ändert
|
||||
sich das Aussehen *aller* Installationen, und beim nächsten Update ist es weg.
|
||||
|
||||
Ausnahme mit Ansage: eigenes CSS und eigenes Skript je Seite gibt es im Editor (Bereich „Code"),
|
||||
und es landet ebenfalls in der Datenbank.
|
||||
|
||||
---
|
||||
|
||||
## 1. Installation aufsetzen
|
||||
|
||||
```bash
|
||||
# Eigene Datenbank und eigener Medienordner - nie gegen die Entwicklungsinstallation arbeiten.
|
||||
DATABASE_URL="sqlite:///…/var/kunde.db" php vendor/bin/doctrine-migrations migrate --no-interaction
|
||||
DATABASE_URL="sqlite:///…/var/kunde.db" MEDIA_DIRECTORY="…/var/kunde-media" php bin/console cms:user:create
|
||||
```
|
||||
|
||||
Oder im Browser über `/install` – sechs Schritte, legt Startseite, Kopf, Fuß, Menü, Impressum und
|
||||
Datenschutzgerüst gleich mit an.
|
||||
|
||||
**Die Verwaltung muss nicht `/admin` heißen.** `ADMIN_PATH` in `.env` setzt die Adresse; `php
|
||||
bin/console cms:admin:path <neu>` prüft, ändert und sagt, welcher Zwischenspeicher danach zu leeren
|
||||
ist. Bei einer Kundenwebsite lohnt das: `/admin` ist die erste Adresse, die ein Scanner probiert.
|
||||
|
||||
Zum Ansehen ein eigener Server auf einem eigenen Port:
|
||||
|
||||
```bash
|
||||
DATABASE_URL="sqlite:///…/var/kunde.db" MEDIA_DIRECTORY="…/var/kunde-media" \
|
||||
SITE_ORIGIN=http://127.0.0.1:8090 php -S 127.0.0.1:8090 -t public bin/dev-router.php
|
||||
```
|
||||
|
||||
`bin/dev-router.php` ist Pflicht: ohne ihn beantwortet der eingebaute Server `/sitemap.xml` und
|
||||
`/robots.txt` selbst, und die Dateien kommen ohne die Kopfzeilen, die später `public/.htaccess`
|
||||
setzt – jede Messung wäre dann falsch.
|
||||
|
||||
---
|
||||
|
||||
## 2. Inhalte anlegen: als Skript, nicht von Hand
|
||||
|
||||
Eine vollständige Website besteht aus Dutzenden Blöcken mit Hunderten Gestaltungswerten. Das klickt
|
||||
niemand zweimal. **Schreibe ein Aufbauskript**, das Medien hochlädt, Dokumente zusammensetzt und
|
||||
veröffentlicht – Vorlage ist das Skript, mit dem kant-it.de nachgebaut wurde.
|
||||
|
||||
Drei Eigenschaften, die ein solches Skript haben muss (alle drei sind aus Schaden gelernt):
|
||||
|
||||
1. **Wiederholbar.** Ein zweiter Lauf aktualisiert vorhandene Seiten, statt sie zu überspringen –
|
||||
sonst ändert eine Korrektur im Skript nichts an der Website. Bilder werden am Titel
|
||||
wiedererkannt, sonst wächst die Mediathek mit jedem Lauf.
|
||||
2. **Eine Quelle für Adressen.** Verweise nie von Hand schreiben. Die Adresse eines Beitrags
|
||||
entsteht aus derselben Funktion, die den Beitrag anlegt. Im Nachbau standen vier Links im Hero
|
||||
von Hand darin – alle vier tot, weil die Beiträge längere Adressen bekamen.
|
||||
3. **Über die Repository-Klassen, nicht per SQL.** `PageRepository::saveDraft()` und `publish()`
|
||||
prüfen das Dokument. Ein per `INSERT` eingeschmuggeltes Dokument kann Felder enthalten, die der
|
||||
Editor nie wieder öffnen kann.
|
||||
|
||||
Reihenfolge im Skript: Theme → Medien → Kopf- und Fußbereich → Seiten → Beiträge → Menüs →
|
||||
Formulare → Einstellungen.
|
||||
|
||||
---
|
||||
|
||||
## 3. Das Dokumentformat
|
||||
|
||||
Ein Dokument ist `{schemaVersion, title, description, blocks: [...]}`. Ein Block ist
|
||||
`{id, type, props, style, layout, children}`.
|
||||
|
||||
- **`id`** – höchstens 64 Zeichen, eindeutig im Dokument, erscheint als HTML-`id` und als
|
||||
CSS-Selektor. Sprechend wählen (`hero-headline`, nicht `block-7`).
|
||||
- **`type`** – siehe Liste im Editor; `section`, `row`, `column`, `heading`, `text`, `image`,
|
||||
`button`, `postlist`, `slider`, `tabs`, `form`, `contact`, `stat`, `quote`, `iconcard`, …
|
||||
- **`props`** – der Inhalt. Was hier steht, ist Text und Daten, nie Auszeichnung mit Wirkung.
|
||||
- **`style`** – `{desktop: {...}, tablet: {...}, mobile: {...}}`, jeder Wert aus `StyleSchema`.
|
||||
Seiten, Abstände und Ecken sind **Objekte** (`['top' => '10px', …]`), keine Kurzschreibweise.
|
||||
- **`layout.effects`** – `{entrance: 'rise', delay: 'short'}` für das Einblenden beim Scrollen.
|
||||
|
||||
**Setze `schemaVersion` auf die aktuelle Fassung** (siehe `DocumentValidator::SCHEMA_VERSION`),
|
||||
sonst sind neuere Bausteine und Eigenschaften nicht erlaubt – und der Editor kann in einem zu alten
|
||||
Dokument nichts einfügen.
|
||||
|
||||
---
|
||||
|
||||
## 4. Gestaltung
|
||||
|
||||
**Erst das Theme, dann die Blöcke.** Farben und Schriften gehören in die Palette
|
||||
(`/admin/theme`): ein Block, der `@accent` sagt, ändert sich mit dem Theme; einer, der `#c1121f`
|
||||
sagt, nicht. Die Palette färbt auch Knöpfe, Symbole, Fokusringe und den Einwilligungsdialog.
|
||||
|
||||
Jedes Farbfeld hat einen Farbwähler und die Palettenfarben zur Auswahl. **Das Textfeld daneben
|
||||
bleibt maßgeblich**: wer dort `@accent` stehen lässt, behält den Verweis auf die Palette; wer im
|
||||
Farbwähler zieht, schreibt einen festen Wert hinein und kappt ihn.
|
||||
|
||||
Feste Größen nur, wo sie gemeint sind. `maxWidth` ohne `margin: auto` zentriert nichts – das war
|
||||
hier schon zweimal die Ursache eines „geht nicht".
|
||||
|
||||
**Die Vorschau im Editor zeigt dieselbe Ausgabe wie die Website.** Was dort falsch aussieht, ist
|
||||
falsch – nicht „nur die Vorschau".
|
||||
|
||||
---
|
||||
|
||||
## 5. Bilder
|
||||
|
||||
- Hochladen über `MediaLibrary::upload()`; daraus entstehen WebP-Varianten in 480/960/1600/2400 px
|
||||
(nie größer als das Original). Das Original wird nicht behalten.
|
||||
- **Vor dem Hochladen prüfen:** über 12 Megapixel oder 8000 px Kantenlänge werden abgelehnt, und
|
||||
ein Hostingpaket mit 128 MB Speicher schafft große Bilder nicht. Vorher auf 2400 px verkleinern.
|
||||
- Im Dokument immer alle `sources` mitgeben, damit `srcset` entsteht.
|
||||
- Ein Bild mit fester Anzeigebreite bekommt `style.desktop.maxWidth` – daraus entsteht das
|
||||
`sizes`-Attribut, und der Browser lädt die kleine Variante. Ohne das lud ein 190-px-Logo die
|
||||
960er Datei.
|
||||
- Hintergrundbilder werden je Gerät verkleinert, auch wenn sie nur einmal gesetzt wurden.
|
||||
- „Beim Klick" entscheidet je Bild, ob nichts passiert, ob es sich vergrößert oder ob es zu einer
|
||||
Adresse führt; eine Galerie hat dafür einen Schalter. Zugrunde liegt ein gewöhnlicher Verweis
|
||||
auf die große Fassung – ohne JavaScript öffnet ein Klick sie, nichts ist kaputt.
|
||||
|
||||
---
|
||||
|
||||
## 6. Beiträge
|
||||
|
||||
Optional. Gibt es keine, verschwinden Archiv, Feed und Feedverweis von selbst; ein
|
||||
Beitragslisten-Baustein zeigt öffentlich nichts.
|
||||
|
||||
Beim Anlegen wählt man einen von drei Aufbauten (`PostLayouts`: Bildkopf, Klassisch, Magazin), für
|
||||
die Liste eine von vier Darstellungen (Karten, Liste, Kompakt, Wechselseitig). Beides sind
|
||||
Startpunkte, keine Schablonen.
|
||||
|
||||
---
|
||||
|
||||
## 7. SEO
|
||||
|
||||
Meistens fertig, wenn der Inhalt stimmt: Titel, Beschreibung, Canonical, Open Graph, strukturierte
|
||||
Daten, Sitemap, `robots.txt` entstehen aus dem, was dasteht. Zu setzen bleibt:
|
||||
|
||||
- **Titel für Suchergebnisse** je Seite, wenn er sich vom Titel auf der Seite unterscheiden soll.
|
||||
- **Vorschaubild beim Teilen** – sonst nimmt ein Beitrag sein Beitragsbild und eine Seite gar nichts.
|
||||
- **Unternehmensdaten** unter Einstellungen; daraus entsteht der strukturierte Datensatz.
|
||||
- **Weiterleitungen**, wenn die Website eine alte ablöst. **Vor dem Umzug** die Adressliste der
|
||||
alten Website besorgen (deren `sitemap.xml`) und für jede Adresse, die sich ändert, eine
|
||||
Weiterleitung anlegen. Ohne das ist der Rang von Jahren weg.
|
||||
|
||||
---
|
||||
|
||||
## 8. Schriften
|
||||
|
||||
Nach dem Aufsetzen **einmal** `php bin/console cms:fonts:fetch` (oder der Knopf im Designbereich).
|
||||
Ohne das lädt die Website die Schriften von Google – zwei fremde Verbindungen nacheinander und die
|
||||
IP-Adresse jedes Besuchers an einen Dritten. Die Dateien der Pilotwebsite liegen im Repository;
|
||||
wählt eine neue Website andere Schriften, holt dieser Befehl sie.
|
||||
|
||||
---
|
||||
|
||||
## 9. Prüfen
|
||||
|
||||
In dieser Reihenfolge, gegen die neue Website:
|
||||
|
||||
```bash
|
||||
CMS_URL=http://127.0.0.1:8090 npm run test:links # kein toter Verweis
|
||||
CMS_URL=http://127.0.0.1:8090 npm run test:browser # Barrierefreiheit, Tastatur, ohne JavaScript, Ladebudget
|
||||
MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8090 CMS_PATHS="/,/leistungen,/kontakt" npm run audit
|
||||
```
|
||||
|
||||
Für `npm run audit` muss die Installation im **Produktionsmodus** laufen (`APP_ENV=prod`,
|
||||
`APP_DEBUG=0`), sonst misst man den Debugmodus.
|
||||
|
||||
Am Code geändert? Dann zusätzlich `php vendor/bin/phpunit` (beide PHP-Fassungen),
|
||||
`npm run test:editor` und `composer format`.
|
||||
|
||||
**Zielwerte:** Barrierefreiheit, Best Practices und SEO je 100. Performance mindestens 95, 100
|
||||
anstreben – eine bilderreiche Startseite mit Slider landet realistisch bei 96–98.
|
||||
|
||||
Findet die Prüfung einen Kontrastmangel aus einer im Editor gesetzten Farbe, wird er **gemeldet und
|
||||
nicht behoben**: das ist eine Entscheidung des Redakteurs, keine des Codes.
|
||||
|
||||
---
|
||||
|
||||
## 10. Livegang
|
||||
|
||||
`docs/LIVEGANG.md` ist die Liste. Die drei Dinge, die dort am häufigsten vergessen werden:
|
||||
E-Mail-Versand einmal wirklich ausprobieren, Schriften holen, und die Weiterleitungen der alten
|
||||
Adressen prüfen.
|
||||
|
||||
---
|
||||
|
||||
## 11. Updates dieser Installation
|
||||
|
||||
Zwei Wege, und welcher gilt, entscheidet das Hosting:
|
||||
|
||||
- **Mit Git** (eigener Server, Shell-Zugriff): Bildschirm „System" oder `php bin/console cms:update`.
|
||||
Vorprüfung, Sicherung, Wartungszustand, Checkout, Migration, Rauchtest, Rückweg.
|
||||
- **Ohne Git** (geteiltes Hosting, über `install.php` installiert): im selben Bildschirm die Karte
|
||||
„Update aus dem Releasearchiv" – zwei Knöpfe, der zweite führt die Migrationen aus. `var/`, `.env`,
|
||||
hochgeladene Schriften und das erzeugte Stylesheet-Bündel bleiben unberührt.
|
||||
|
||||
Beides steht ausführlich in `docs/UPDATE.md`. Wichtig für den, der eine Website baut: **nichts von
|
||||
dem, was diese Website ausmacht, liegt im Code**, also überlebt es beide Wege. Wer dafür doch eine
|
||||
Twig-Datei angefasst hat, verliert die Änderung beim ersten Update – siehe Abschnitt 0.
|
||||
|
||||
---
|
||||
|
||||
## Was in diesem Projekt immer wieder schiefging
|
||||
|
||||
Wer diese fünf kennt, spart sich die Suche:
|
||||
|
||||
1. **Eine Einstellung, die speichert und nichts tut.** Das Feld war da, die Ausgabe nicht – beim
|
||||
Vorschaubild fürs Teilen zwei Monate lang. **Nach jedem neuen Feld nachsehen, ob es vorne
|
||||
ankommt**, und einen Test schreiben, der genau das prüft.
|
||||
2. **`|default(true)` in Twig macht aus einem gespeicherten `false` ein `true`.** Für Schalter immer
|
||||
`??` verwenden.
|
||||
3. **Die Prüfung lief gegen die Demo, nicht gegen die Website.** Feste Pfade und Linktexte in einem
|
||||
Prüfskript prüfen nichts, sobald der Inhalt anders ist.
|
||||
4. **Messen statt vermuten.** Das Vorabladen des Hero-Bildes klang richtig und kostete fünf
|
||||
Punkte. Jede Annahme über Geschwindigkeit wird gemessen, vorher und nachher.
|
||||
5. **Der Entwicklungsserver ist nicht das Hosting.** Ohne Kompression und Cache-Kopfzeilen misst
|
||||
man 90 statt 96 – und optimiert an der falschen Stelle.
|
||||
@@ -1,5 +1,83 @@
|
||||
# Qualitätsbericht – erster ausführbarer Prototyp
|
||||
|
||||
## Ergänzung: Sichtbare leere Layouts, 14.09.2026 (1.6.2)
|
||||
|
||||
30 JavaScript-Tests und 27 PHP-Editor-/Sicherheitstests mit 312 Assertions bestanden. Vollständiger isolierter Chromium-Editortest einschließlich axe ohne Verstöße: direkte Tastatureinfügung in leere Sektionen/Spalten, genaues Drag-and-drop-Ziel und Scrollen am Rand, Spaltenreihenfolge auf Desktop und Mobil, Undo/Redo, Speichern/Neuladen, Veröffentlichung und Konflikte. Die Tests prüfen ausdrücklich, dass die neuen Editorhilfen weder im gespeicherten Blockbaum noch im öffentlichen HTML stehen. Desktop-, Mobil- und Drag-Screenshots visuell geprüft; Bericht unter `reports/editor/result.json`.
|
||||
|
||||
Vergleich mit lokalen Flatsome-3.20.9-Quelldateien, keine laufende WordPress-Referenz geprüft. Kein neuer Lighthouse-Lauf, da der öffentliche Renderer und dessen CSS unverändert sind; die Messung von 1.6.1 ist weiterhin als solche zu lesen. Kein neuer Screenreader-, Firefox-, Safari-, Touchgeräte- oder Felddatennachweis.
|
||||
|
||||
## Ergänzung: Flexible Editorfläche und Inhaltsbreiten, 14.09.2026 (1.6.1)
|
||||
|
||||
`npm.cmd test`: 30 Modelltests; PHP 8.4.22: 452 Tests / 3476 Assertions. Der vollständige isolierte Playwright-Lauf einschließlich axe, Speichern/Neuladen, Veröffentlichung, Konfliktbehandlung und neuer Panel-/Sektionsprüfungen besteht. Bildschirmbreiten 390/850/1100/1920 px ohne horizontalen Überlauf geprüft. Screenshotprüfung für Desktop, Bildinspektor und Telefon; kein neuer Screenreader-Test.
|
||||
|
||||
`PHP_BINARY=C:\php84\php.exe`, `npm.cmd run test:editor -- --audit`: Lighthouse **13.4.1**, lokaler Symfony-Produktionsmodus mit isolierter Datenbank und den vom Editortest erstellten Inhalten/Bildern. Je **drei** Läufe im Mobil- und Desktopprofil aus `scripts/audit.mjs`. Leistung, Barrierefreiheit, Best Practices, SEO und agentisches Browsing in jedem Lauf und damit in beiden Medianen **100**. Vollständige Geräte-/Drosselungs- und Umgebungseinstellungen sowie Ergebnisse stehen in `reports/lighthouse-editor/summary.json` und den sechs Einzelberichten; funktionale Ergebnisse in `reports/editor/result.json`.
|
||||
|
||||
Die Messung prüft die gemeinsame öffentliche Ausgabe nach Entfernung der festen Text-/Überschriftenbreiten. Sie ist keine Abnahme aller Pilot- oder Kundenseiten. Felddaten zu LCP/INP/CLS wurden nicht erhoben; INP wird aus diesem Ladelauf nicht abgeleitet. GEO/Auffindbarkeit ist durch den Agentic-Browsing-Score nicht nachgewiesen.
|
||||
|
||||
## Ergänzung: Autorotation, Blättern, eigene Texte, 13.09.2026
|
||||
|
||||
- **395 PHP-Tests / 1672 Assertions** unter PHP 8.2.12 und 8.4.22, 18 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung. axe: null Verstöße auf Startseite, Projekteseite, Beitrag.
|
||||
- **Die Autorotation ist als Verlauf geprüft, nicht als Schnappschuss:** 0 → 666 → 1332 → 1998 in drei Schritten zu je sieben Sekunden, und nach einem Mausrad-Ereignis bleibt der Wert stehen. Dabei fiel der Fehler der ersten Fassung auf: das Sprungziel wurde aus `offsetLeft` berechnet und der Offset des Sliders doppelt abgezogen – Ergebnis −78, also keine Bewegung.
|
||||
- Belegt ist außerdem, dass das Skript **nur auf Seiten mit einem solchen Slider** geladen wird und die Richtlinie sonst bei `script-src 'none'` bleibt.
|
||||
- **Blättern in der Beitragsliste** mit sieben Beiträgen geprüft: erste Seite ohne „Neuere“, letzte ohne „Ältere“, Seite 99 landet auf der letzten, und ohne den Schalter bleibt es eine Auswahl ohne Leiste.
|
||||
- **Acht Projekttexte sind geschrieben, nicht übernommen** – für die Beiträge, die auf der laufenden Website keinen Text haben (zwei davon nicht einmal einen Auszug). Sie beschreiben Aufgabe, Weg und Ergebnis, ohne Zahlen, Namen oder Zusagen zu erfinden. **Sie gehören vor einem Livegang gegengelesen**, denn sie sind meine Formulierung über Umuts Projekte.
|
||||
- **Nicht geprüft:** die Autorotation auf einem echten Touchgerät und in Firefox oder Safari; gemessen ist sie im Chromium der Browserprüfung.
|
||||
|
||||
## Ergänzung: Nachbau vollständig, 13.09.2026
|
||||
|
||||
- **390 PHP-Tests / 1641 Assertions** unter PHP 8.2.12 und 8.4.22, 18 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung. **axe: null Verstöße** auf Startseite, Projekteseite, Datenschutzerklärung, Projektbeitrag und mobiler Projekteseite.
|
||||
- **Der Nachbau ist jetzt vollständig:** 29 Projektbeiträge mit den echten Bildern, Titeln, Auszügen und Texten der laufenden Website, die vollständige Datenschutzerklärung (27 Überschriften, rund 22.000 Zeichen), Impressum, Startseite, Projekteseite. 41 Medien, 33 Seiten.
|
||||
- **Ein Befund, der für den echten Umzug zählt:** **11 der 29 Projektbilder** hätte die Mediathek auf einem Hosting mit 128 MB abgewiesen, zwei davon (13,5 und 12,0 Megapixel) auch mit mehr Speicher – sie überschreiten die Pixelgrenze. Der Umzug muss solche Bilder vorher verkleinern; für den Nachbau ist genau das geschehen (9 Bilder auf 2400 px). Die Meldung der Mediathek ist dabei richtig und verständlich, sie kommt nur an einer Stelle, an der niemand mit ihr rechnet.
|
||||
- Die Größe der **Kennzahl** und der **Kundenstimme** ließ sich nicht einstellen: `.block-stat strong` und `.block-quote p` hatten feste Werte, gegen die eine Blockeinstellung nicht ankam. Beide erben jetzt vom Block – dieselbe Familie wie die Theme-Skala, die die Kartenüberschriften überschrieb.
|
||||
- Gemessen: Startseite 56 KB HTML mit 15 Bildern, Projekteseite 32 KB mit 13, Datenschutzerklärung 38 KB. **Kein ausführbares Skript** auf keiner Seite; das einzige `<script>` ist strukturierte Daten als `application/ld+json`, und die Richtlinie sendet weiterhin `script-src 'none'`.
|
||||
- **Nicht geprüft:** die Projektbeiträge sind inhaltlich übernommen, nicht redaktionell überarbeitet – einzelne Texte der laufenden Website sind kurz oder fehlen dort ganz (8 von 29 haben nur den Auszug). Und nichts davon steht auf echtem Hosting.
|
||||
|
||||
## Ergänzung: Detailabgleich mit dem Original, 13.09.2026
|
||||
|
||||
- **390 PHP-Tests / 1641 Assertions** unter PHP 8.2.12 und 8.4.22, 18 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung. **axe: null Verstöße** auf Startseite, Projekteseite und Handybreite des Nachbaus.
|
||||
- **Der ernsteste Fund ist eine beinahe entstandene Lücke:** beim Freigeben von Auszeichnungen in Überschriften war die Dokumentfassung in der Schliessung des Filters nicht gebunden – der Filter lief nie, während die Ausgabe schon unescaped war. Ein Test, der `<script>alert(1)</script>` in eine Überschrift schreibt, hat es im ersten Lauf gezeigt. Er prüft jetzt beides: das hervorgehobene Wort kommt durch, das Skript nicht.
|
||||
- Gegen das Original nachgemessen statt geschätzt: Kartenbreite 316 px (Original 324), Kartenhöhe 342 (330), Kartentitel 19 px/800 in beiden, Überschrift zweiter Ebene 44 px in beiden, Hauptüberschrift 66 px (68).
|
||||
- **Nicht geprüft:** Punkte am Slider in Firefox und Safari – dort greift der Rückfall auf ein wischbares Schiebefeld, gesehen habe ich ihn in diesen Browsern nicht.
|
||||
|
||||
## Ergänzung: Nachbau überarbeitet, Dokumentschema v17, 13.09.2026
|
||||
|
||||
- **389 PHP-Tests / 1636 Assertions** unter PHP 8.2.12 und 8.4.22, **18 JavaScript-Modelltests**, Playwright-Editortest, öffentliche Browserprüfung.
|
||||
- **Der schwerste Fund des Tages kommt aus dem Editor, nicht aus dem Nachbau:** in einem Dokument ab v13 ließ sich kein Baustein mehr einfügen, und Symbol, Fortschritt, Slider und eingebetteter Inhalt in gar keinem. Der bestehende Editortest hat das nicht bemerkt, weil er ein altes Dokument bearbeitet. Der neue Test fügt jeden Typ in Dokumente von v1 bis v17 ein.
|
||||
- **Ein zweiter, der nur in der Gegenprobe sichtbar wird:** `|default(true)` macht in Twig aus einem gespeicherten `false` ein `true`. Die neuen Anzeigeoptionen der Beitragsliste hätten sich speichern lassen und nichts getan – dieselbe Krankheit wie Rahmenstärke und Bildrundung. Der Test prüft beide Richtungen.
|
||||
- Neu geprüft: verlinktes Bild (mit denselben Linkregeln, sechs Fälle), klebender Kopfbereich als Einstellung, Projektraster gegen Blogarchiv.
|
||||
- Am Nachbau nachgemessen: Lösungskarte 213 px statt 300, Abstand zur Projektliste 54 px, Projektkarte 337 px mit Bild und Titel – das Original liegt bei 324 px.
|
||||
- **Nicht geprüft:** ob der klebende Kopfbereich auf einem Telefon mit dynamischer Adressleiste gut aussieht, und der Slider ist nicht auf einem echten Touchgerät gewischt worden.
|
||||
|
||||
## Ergänzung: kant-it.de nachgebaut, 12.09.2026
|
||||
|
||||
- **381 PHP-Tests / 1619 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung.
|
||||
- **Der Nachbau ist die Prüfung:** Startseite mit neun Abschnitten, Projekteseite, vier Beiträge, Impressum, Datenschutzgerüst, Kopf- und Fußbereich, zwei Menüs, Kontaktformular, zwölf Bilder, eigene Palette und Schrift – gegen eine eigene Datenbank und einen eigenen Medienordner, nicht gegen die Entwicklungsinstallation.
|
||||
- **Gefunden dabei: fünf Fehler** (ADR 042), darunter der größte: die Palette färbte Symbole, Knöpfe, Rahmen und den Einwilligungsdialog überhaupt nicht. Vier davon sind behoben und durch Tests abgesichert, der fünfte war mein eigener Kontrastfehler beim Beheben des ersten.
|
||||
- **axe: null Verstöße** auf Startseite, Projekteseite, Beitrag und Impressum, in 1440 px und 390 px – gemessen an der nachgebauten Seite, nicht an der Demo.
|
||||
- Gemessen am Nachbau: Startseite 45 KB HTML, 33 KB CSS, rund 430 KB Bilder in WebP, **0 Skripte**, 21 Abfragen. Die Abfragezahl ist die höchste bisher gemessene und erklärt sich aus der Beitragsliste; sie liegt unter der Obergrenze, ist aber der nächste Kandidat fürs Zusammenfassen.
|
||||
- **Nicht geprüft:** die vollständige Datenschutzerklärung der laufenden Website (nur das Gerüst), die restlichen Projektbeiträge, die Produktseiten von HausKlar und KantTresor, und nichts davon auf echtem Hosting. Der Nachbau ist ein Abbild, keine Migration: die Inhalte sind neu geschrieben, nicht aus WordPress exportiert.
|
||||
|
||||
## Ergänzung: Installer, 12.09.2026
|
||||
|
||||
- **379 PHP-Tests / 1606 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung.
|
||||
- **Die Abnahme des Pakets ist ein Test, kein Bericht:** ein Test geht die sechs Bildschirme durch die echte HTTP-Schicht, mit einem Kernel in einem wegwerfbaren Projektverzeichnis – leere Adresse → Weiterleitung auf `/install`, Datenbank, Migration, Website, Konto, fertig. Danach antwortet `/install` mit **404** und `/` mit **200**, und auf der Startseite steht der Name, den das Formular bekommen hat. Die Migrationen darin sind die echten.
|
||||
- **Von Hand im Browser nachgestellt**, gegen eine Kopie der Anwendung in einem leeren Verzeichnis mit leerer Datenbank: erste Anfrage 302 auf `/install`, Systemprüfung, Datenbank (SQLite), 13 Migrationen, Website „Tischlerei Berg“. Den letzten Schritt – das Konto – habe ich nicht im Browser ausgefüllt, sondern dem Test überlassen; ich tippe keine Passwörter in Formulare.
|
||||
- **axe auf allen vier erreichbaren Installationsbildschirmen: null Verstöße** (wcag2a, wcag2aa, wcag21a, wcag21aa).
|
||||
- **Das Releasearchiv wurde entpackt und gestartet**, nicht nur gebaut: `kantsites-1.3.0.zip` in ein leeres Verzeichnis, Server darauf, erste Anfrage → Installer. Damit ist belegt, dass `vendor/` vollständig ist und auf dem Zielsystem weder Composer noch eine Konsole gebraucht wird.
|
||||
- Die einzelne `install.php` ist dort geprüft, wo sie gefährlich wäre: neun Archiveinträge, die aus dem Zielverzeichnis ausbrechen wollen (`../`, absolut, Windows-Laufwerk, Rückwärtsschrägstrich, Nullbyte), landen nirgends – und ein Archiv mit einem solchen Eintrag bricht ab, ohne die Datei außerhalb anzulegen. Der Eintrag im Testarchiv ist echt: ZIP speichert `kantsites-1.2.0/../entwischt.txt` unverändert.
|
||||
- **Nicht geprüft:** der Download selbst. Solange das Archiv nicht unter seiner Adresse liegt, ist die Kette „Datei hochladen → lädt → entpackt“ nur bis zum Entpacken belegt. Ebenso ungeprüft: MySQL als Zieldatenbank im Installer (der Verbindungstest ist da, ein echter MySQL-Server war es nicht), echtes Hosting, und ein Screenreader.
|
||||
|
||||
## Ergänzung: Bildgrößen und Releasearchiv, 12.09.2026
|
||||
|
||||
- **352 PHP-Tests / 1479 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung.
|
||||
- **Der Browsertest hat den Fehler gefunden, den die Unittests nicht sehen konnten:** der Dokumentprüfer ließ höchstens **drei** `sources` je Bild zu. Mit der vierten Variante war jedes Dokument mit einem Bild darin ungültig – und eine abgewiesene Speicherung sieht im Editor so aus, als wäre nichts passiert. Gefunden wurde er, weil ein Galeriebild im Vorschaurahmen nicht erschien. Die Grenze hängt jetzt an `MediaLibrary::WIDTHS` statt an einer Zahl, und ein Test prüft beide Seiten: vier Quellen werden angenommen, fünf abgewiesen.
|
||||
- **Die Bildpipeline ist nachgerechnet, nicht behauptet:** ein Test lädt ein erzeugtes Foto hoch, öffnet jede entstandene Datei und prüft, dass sie WebP ist, dass die vier Breiten 480/960/1600/2400 entstehen und dass das Seitenverhältnis erhalten bleibt. Ein zweiter Upload mit 700 px Breite bekommt zwei Stufen – 480 und 700 –, womit „nie hochskalieren“ geprüft ist und nicht nur im Kommentar steht.
|
||||
- **Ein Testfehlschlag, der keiner war:** derselbe Test fiel um, sobald er in der gesamten Suite lief, mit der Meldung „dieses Bild benötigt mehr Arbeitsspeicher als das Hosting bereitstellt“. Das ist die Schutzprüfung vor dem Decodieren, die den bereits belegten Speicher mitrechnet – sie hat richtig gehandelt. Der Test benutzt jetzt eine kleinere Quelle und hebt das Limit für seine Dauer an, weil sein Gegenstand die Pipeline ist und nicht die Schutzprüfung.
|
||||
- Hintergrundbilder sind je Gerät geprüft: die Desktopregel nennt die größte Variante, die Regel im `max-width:767px`-Block die 960er.
|
||||
- **Das Releasearchiv ist gebaut, nicht geplant:** `kantsites-1.2.0.zip`, 3465 Dateien, 4,4 MB, SHA-256 `950eaa4c…`. Nachgesehen wurde, was **nicht** drin ist: kein PHPUnit, keine Tests, kein `var/`, kein `.env`.
|
||||
- Der Abbruch bei geänderten Abhängigkeiten ohne Composer ist mit zwei Tests belegt: geänderte `composer.lock` ohne Composer bricht ab und stellt den alten Stand wieder her; unveränderte `composer.lock` läuft durch.
|
||||
- **Nicht geprüft:** das Archiv ist auf keinem echten Hostingpaket entpackt und gestartet worden – das gehört zu den Arbeitspaketen J und N. Der Installer, der es herunterlädt, existiert noch nicht.
|
||||
|
||||
## Ergänzung: Ladeverhalten, 12.09.2026
|
||||
|
||||
- **347 PHP-Tests / 1422 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung mit neuem Ladebudget.
|
||||
|
||||
+374
-3
@@ -1,9 +1,45 @@
|
||||
# Projektstatus
|
||||
|
||||
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.
|
||||
## 1.6.2 – Sichtbare leere Sektionen und Spalten (14.09.2026)
|
||||
|
||||
**Erledigt:** Leere Sektionen, Zeilen und Spalten besitzen in der Vorschau sichtbare, beschriftete Arbeitsflächen. „Inhalt hinzufügen“ setzt einen genauen Einfügepunkt, öffnet den Werkzeugkasten und fokussiert die Suche. Leere Sektionen bieten unmittelbar 1–4 Spalten; neue Elemente werden sichtbar gescrollt. Beim Ziehen sind Container und Ablageziel erkennbar, die Vorschau scrollt am Rand mit. Spalten werden auf Desktop horizontal, bei mobiler Stapelung vertikal eingeordnet. Geöffnete Feinschliff-Gruppen bleiben auch bei schnellen Änderungen erhalten. Cachekennung 1.6.2; keine Schema- oder Datenbankmigration. Die Hilfen werden ausschließlich im Editor-DOM erzeugt und erscheinen weder in gespeicherten Dokumenten noch auf öffentlichen Seiten. Entscheidungen: ADR 075.
|
||||
|
||||
**Referenz:** Die tatsächlichen Builder-Dateien des bereitgestellten Flatsome 3.20.9 wurden erneut gelesen. Übernommen sind sichtbare Leerflächen, direkte Plus-Aktionen und schnelle Spaltenlayouts. Konkrete Fundstellen und Umfang stehen in `docs/FLATSOME-ABGLEICH.md`; keine laufende WordPress-Referenzinstallation geprüft.
|
||||
|
||||
**Geprüft:** `npm.cmd test` 30/30; PHP 8.4.22 `vendor/bin/phpunit --filter EditorSecurityTest` 27/27 mit 312 Assertions; JavaScript-Syntax und alle 19 Admin-Twig-Dateien gültig. Der vollständige isolierte `npm.cmd run test:editor` besteht einschließlich axe-Prüfungen. Neue Abdeckung: sichtbare leere Sektion, Tastaturwahl des Zweispaltenlayouts, Einfügen in genau die zweite Spalte, Ziehen in die erste Spalte mit Zielmarkierung und automatischem Scrollen, Undo/Redo, leere Layouts nach Speichern/Neuladen, Spaltenumsortierung Desktop/Mobil und Veröffentlichung ohne Editorhilfen. Desktop-, Mobil- und Drag-Ansichten anhand der Screenshots visuell geprüft. Bericht: `reports/editor/result.json`; Bilder: `empty-section.png`, `empty-layout-desktop.png`, `empty-layout-mobile.png`, `empty-layout-drag.png` im selben Ordner. Keine Änderungen an Kundendaten durch die isolierten Tests.
|
||||
|
||||
**Bekannte Grenzen:** Der öffentliche Renderer ist unverändert; Lighthouse wurde für diese Fassung nicht erneut gemessen. Die Messung zu 1.6.1 bleibt unten dokumentiert. Kein neuer Screenreader-, Firefox-, Safari- oder echter Touchgerätetest. **Nächster Schritt:** Die neuen Aufbauabläufe mit Umuts tatsächlichen Seiten nutzen und anschließend die restlichen Arbeitspakete des Entwicklungsplans fortsetzen.
|
||||
|
||||
## 1.6.1 – Editorbreite, neue Seitenabschnitte und passende Eigenschaften (14.09.2026)
|
||||
|
||||
**Veröffentlicht am 14.09.2026:** Commit `b783d9d` und annotierter Tag `v1.6.1` sind auf `origin` (`https://git.kant-it.de/kantarci/KantSites.git`) hochgeladen. Das stabile [Release 1.6.1](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.6.1) enthält ZIP, SHA-256-Datei und `install.php`. Das Archiv wurde aus dem exakten Commit mit Produktionsabhängigkeiten gebaut (3520 Dateien); Archivintegrität, Übereinstimmung mit dem Commit und Produktionscontainer wurden geprüft. Alle drei Anhänge anschließend ohne Anmeldung heruntergeladen und mit den lokalen Dateien verglichen; die API liefert 1.6.1 als neuestes stabiles Release. Archiv-SHA-256: `a6e7da2bb14ede43dd2d94960b3b4d67cc285d9c717b560f796a91cbcde4dd26`. Dieser Schritt veröffentlicht das Update, installiert es aber nicht auf Kundenwebsites.
|
||||
|
||||
**Erledigt:** Die festen 690/900/760-px-Grenzen für Text/H1/H2 sind entfernt. Elemente nutzen ihren Container, eigene Maximalbreiten bleiben möglich. Dauerhaft sichtbare Aktion **Neue Sektion auf Seitenebene**; auch der normale Sektionsklick erzeugt ohne expliziten Einfügepunkt einen übergeordneten Seitenabschnitt. Linke und rechte Sidebar sind per Ziehen und Tastatur verstellbar und merken ihre Breite im Browser. Die Vorschau nutzt ihre Fläche ohne Außenränder; die Elementbibliothek läuft nicht mehr horizontal über. Auf kleinen Fenstern passen sich die Panels an, auf dem Telefon stehen sie untereinander.
|
||||
|
||||
**Feinschliff:** Bild-, Text- und Containeroptionen werden passend gefiltert und sortiert. Gespeicherte Werte bleiben sichtbar; die gesamte Eigenschaftenauswahl ist über eine Schaltfläche erreichbar. Die Auswahl eines anderen Blocks setzt die Panelrolleiste nach oben. Geöffnete Eigenschaftengruppen bleiben beim Ändern eines Werts geöffnet. Automatische Größen und „Ohne Begrenzung“ sind explizit wählbar. Cachekennung auf 1.6.1 angehoben; keine Datenbankmigration, keine Kundeninhalte durch Tests geändert. Begründung und Rückweg: ADR 074.
|
||||
|
||||
**Geprüft:** `npm.cmd test`: 30/30; PHP 8.4.22 `vendor/bin/phpunit`: 452/452, 3476 Assertions. JavaScript-Syntax und alle 19 Admin-Twig-Dateien gültig; Produktionscache erfolgreich aufgewärmt, `git diff --check` sauber. `npm.cmd run test:editor -- --audit` vollständig bestanden: Seitenebenen/Undo/Redo, Panelbreiten per Maus/Tastatur und nach Neuladen, 390/850/1100/1920-px-Fenster, kontextbezogene Eigenschaften und bewusst gesetzte Textbreiten; auch Speichern, Neuladen, Veröffentlichen und Konfliktbehandlung. Sämtliche enthaltenen axe-Prüfungen ohne Verstoß. Desktop-, Bild- und Telefonansicht anhand der Browser-Screenshots visuell geprüft. Lighthouse **13.4.1**, isolierte öffentliche Editortestseite im Produktionsmodus: alle fünf Kategorien **100**, jeweils drei Läufe mobil und Desktop, auch jeder Einzellauf 100. Kein Nachweis für beliebige Kundeninhalte oder Feldwerte. Berichte: `reports/editor/result.json`, `reports/lighthouse-editor/summary.json`; Ansichten: `reports/editor/flexible-workspace.png`, `context-image.png`, `editor-phone.png`.
|
||||
|
||||
**Bekannte Grenzen:** Die geänderten Standardbreiten können den Zeilenumbruch bestehender Seiten verändern. Sektions- und ausdrücklich gespeicherte Elementbreiten bleiben wirksam. Kein neuer Screenreader- oder Felddatennachweis. **Nächster Schritt:** Umuts tatsächliche Editorabläufe mit realen Seiten durchgehen, danach den verbliebenen Ausbau gemäß Entwicklungsplan fortsetzen.
|
||||
|
||||
## Vorheriger Stand und bisherige Arbeitspakete
|
||||
|
||||
Stand: 14.09.2026, Fassung **1.6.0**. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Startseite von 18 auf 8 Datenbankabfragen, gemessenes Ladebudget, alle langen Listen blättern. Sicherung und Wiederherstellung, letztere nachgewiesen. Globale Seitenbreite wirkt jetzt auch nach oben, vier Menüplätze mit drei Menüarten, und jede Seite wählt ihren Kopf- und Fußbereich – auch „keiner“. 93 Gestaltungseigenschaften, neun Einblendungen, sieben Hover-Effekte, Symbol, Fortschritt und ein Slider ohne JavaScript. Update über Git mit Vorprüfung, Sicherung und Rückweg. Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
||||
|
||||
Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
|
||||
|
||||
**Für den Livegang vorbereitet (13.09.2026):** `docs/LIVEGANG.md` trennt, was geprüft ist, von dem, was nur auf dem echten Hosting geprüft werden kann, und nennt die Reihenfolge am Umzugstag. **14 Weiterleitungen** von den alten WordPress-Adressen auf die neuen sind angelegt und beantwortet (`/productsolutions` → `/projekte`, die gekürzten Beitragsadressen, `/category/allgemein`); ohne sie hätte jede bei Google stehende Adresse nach dem Umzug mit 404 geantwortet. In der Datenschutzerklärung wurde der Abschnitt über Google Fonts an die Wirklichkeit angepasst (die Schriften kommen jetzt vom eigenen Server) und die Abschnitte über Google Maps und reCAPTCHA entfernt, weil beides nicht eingebunden ist – eine Erklärung, die Datenflüsse beschreibt, die es nicht gibt, ist so falsch wie eine, die welche verschweigt. **Nicht geprüft und nicht prüfbar von hier: der E-Mail-Versand.**
|
||||
|
||||
**Verwaltung und Updateweg (1.4.3, 13.09.2026):** Die Farbfelder im Designbereich haben einen **Farbwähler** samt Palettenauswahl; das Textfeld bleibt maßgeblich, damit `@akzent` ein Verweis bleibt und nicht beim ersten Klick zu einem festen Hexwert erstarrt (ADR 066). Die Verwaltung ist **auf dem Telefon bedienbar**: das Menü ist unter 760 px ein zugeklapptes `<details>`, das den aktuellen Bereich nennt, mit 44 px hohen Zielen – ohne Skript. Die zwölf Navigationssymbole sind **eigene SVG-Pfade** statt Unicode-Zeichen, die ein Telefon zu bunten Emoji macht (ADR 067). Und es gibt ein **Update ohne Git**: `ArchiveUpdate` lädt das Releasearchiv, vergleicht die Prüfsumme, sichert, ersetzt die Dateien und führt die Migrationen in einer zweiten Anfrage aus – der einzige Updateweg auf geteiltem Hosting (ADR 068, `docs/UPDATE.md`).
|
||||
|
||||
**Nachbau nachgezogen (13.09.2026):** Der Fußbereich ist jetzt vierspaltig wie im Original – Zeichen und Satz, „Leistungen", „Unternehmen", „Kontakt", darunter die Zeile mit Copyright. Die beiden Linklisten sind **Menüs**, keine Dokumentinhalte: sie lassen sich in der Verwaltung pflegen. Alle **29 Beiträge haben jetzt Text** – die elf fehlenden sind neu geschrieben, denn auch im Original stehen dort nur Bildergalerien ohne Beschreibung. Die Karte „Bereit für den nächsten Schritt" hat 34 px Abstand zu den Kontaktzeilen darunter. **Nicht erledigt: das TeamViewer-Logo** im Fernwartungsbereich – dazu unten.
|
||||
|
||||
**Vom Original übernommen (13.09.2026):** die Meta-Beschreibung der Startseite im Wortlaut von kant-it.de, das Symbol der Website (dort 192 × 192) und die Bot-Regeln der robots.txt. Titel, Canonical, Open Graph, strukturierte Daten (`LocalBusiness` statt Yoasts `ProfessionalService`), Sitemap, robots.txt und `llms.txt` entstehen in KantSites aus dem Inhalt und stimmen mit dem Original überein. **Beim Abgleich aufgefallen:** das `og:image` des echten kant-it.de zeigt auf `wp-content/uploads/kant-it-share.png` – die Datei antwortet mit 404, beim Teilen erscheint dort also kein Bild.
|
||||
|
||||
**Im Nachbau von kant-it.de gefunden und behoben:** vier tote Verweise im Hero-Slider – die Adressen waren im Aufbauskript von Hand geschrieben, die Beiträge bekamen längere. Sie werden jetzt aus derselben Regel abgeleitet, mit der die Beiträge entstehen, und ein zweiter Lauf des Skripts aktualisiert vorhandene Seiten, statt sie zu überspringen. Eine Prüfung aller internen Verweise auf 404 gibt es im CMS noch nicht; gefunden wurden sie mit einem einmaligen Durchlauf im Browser.
|
||||
|
||||
**Wiederkehrende Aufgaben ohne Cronjob (14.09.2026, ADR 073):** Bis hierher stand in vier Dokumenten „gehört in einen Cronjob“ – auf geteiltem Hosting oft nicht einlösbar, und die Folge war nicht „läuft seltener“, sondern **läuft nie**. Jetzt gibt es einen Aufgabenplan mit vier Auslösern: **jeder Seitenaufruf** (nach der ausgelieferten Antwort, in `kernel.terminate`), das **geöffnete Backend** (im Minutentakt, nur solange der Reiter sichtbar ist), ein **URL-Cronjob** unter `/cms-cron/<Schlüssel>` für Hoster, die nur eine Adresse aufrufen können, und die **Konsole** (`cms:tasks:run`, auch als Dauerläufer mit `--loop`). Fünf Aufgaben sind angeschlossen: geplante Veröffentlichung (5 Minuten), Statistik verdichten, Sicherung, Updateprüfung und Aufräumen (je täglich). Der Bildschirm **Aufgaben** zeigt Plan, letztes Ergebnis, Dauer, Auslöser und Protokoll, schaltet jede Aufgabe einzeln und ändert ihren Abstand. **Die Einschränkung steht auf diesem Bildschirm:** ohne Besuch und ohne geöffnetes Backend passiert nichts – eine Aufgabe für 03:00 Uhr läuft auf einer stillen Website erst um 07:15 Uhr.
|
||||
|
||||
## Erledigt
|
||||
|
||||
- `AGENTS.md` und `ENTWICKLUNGSPLAN.md` unverändert aus Downloads im Projektstamm abgelegt; SHA-256 jeweils mit dem Original identisch.
|
||||
@@ -19,14 +55,63 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- Drei bearbeitbare Abschnittsvorlagen (Intro, zwei Leistungen, Kontaktaufruf), einklappbarer Werkzeugkasten, Struktursuche mit Vorfahren, einklappbare Teilbäume, Pfadnavigation und dauerhaft erreichbare Blockaktionen.
|
||||
- Dokumentformat v2, erhaltender idempotenter v1-Konverter, gemeinsame Tokenliste für Validator/Editor/CSS und dokumentierter Migrations-/Rückweg. Bestehende v1-Seiten werden beim Öffnen nicht geändert; keine neue DB-Migration erforderlich.
|
||||
- Dokumentformat v3 für Galerie, Bildbeschriftungen/-varianten und Effekte, mit erhaltendem Upgrade ohne Downgrade und Undo. v1/v2 bleiben unterstützt. Schatten, Hover-Anheben/Bildzoom und Einblenden beim Laden ohne öffentliches JavaScript; Bewegungsreduktion wird berücksichtigt.
|
||||
- Medienverwaltung im Editor und unter `/admin/media`: JPEG/PNG/WebP-Upload, echte Bildvalidierung, Größen-/Pixel-/Speichergrenzen, WebP-Varianten bis 480/960/1600 px, Suche, Titel/Alt-Text, Archivieren/Wiederherstellen und Schreibkonflikterkennung. Bildauswahl für einzelne Bilder und Galerien, kontextbezogene Alt-Texte/dekorative Verwendung und Beschriftungen.
|
||||
- Medienverwaltung im Editor und unter `/admin/media`: JPEG/PNG/WebP-Upload, echte Bildvalidierung, Größen-/Pixel-/Speichergrenzen, WebP-Varianten bis 480/960/1600/2400 px (ohne Hochskalieren, Hintergrundbilder geräteabhängig), Suche, Titel/Alt-Text, Archivieren/Wiederherstellen und Schreibkonflikterkennung. Bildauswahl für einzelne Bilder und Galerien, kontextbezogene Alt-Texte/dekorative Verwendung und Beschriftungen.
|
||||
- Neue Medientabelle durch Migration `20260912000300` lokal angewendet. Vorher konsistentes SQLite-Backup unter `var/backups/before-media-20260912-051258.db`. Bestehende Seiten/Benutzer nicht geändert. Lokal läuft PHP 8.4 mit 8M Upload-, 10M POST- und 256M Speicherlimit; originale Uploads werden nicht gespeichert.
|
||||
- Speichern/Laden von Entwürfen, separates Veröffentlichen mit Bestätigung, sichtbare Speicher-/Fehler-/Konfliktzustände, JSON-Sicherung und bewusster Server-Neuladedialog. Keine Tests schreiben in den echten Seitenbestand.
|
||||
- Auf ausdrücklichen Nutzerwunsch lokalen Administrator `umut` mit zufälligem Passwort angelegt und im In-App-Browser erfolgreich angemeldet. Das Passwort steht in keiner Projektdatei; nur der Hash in der lokalen Datenbank. Keine Browser-Passwortspeicherung ausgelöst.
|
||||
- Seitenerstellung und -löschung im Browser: `POST /admin/pages` (ab `ROLE_EDITOR`) legt Seite oder Beitrag mit gültigem Startdokument als privaten Entwurf an; `POST /admin/pages/{id}/delete` (nur `ROLE_ADMIN`) entfernt eine Seite mit allen Revisionen. Beide mit CSRF-Token, Fehlermeldungen als Flash und ohne JavaScript bedienbar.
|
||||
- Adresshierarchie: neue Spalten `path`, `parent_id`, `type`, `created_at` über Migration `20260912000400`; websiteweit eindeutiger Pfad statt eindeutigem Slug, Tiefe bis vier Ebenen, Beiträge ohne Unterseiten, reservierte Adressen und Adressableitung aus dem Titel. Öffentliche Route bedient verschachtelte Pfade wie `/leistungen/webdesign`; ein Lookahead hält `/admin`, `/media` und `/assets` aus dem Auffangmuster.
|
||||
- **Ladeverhalten gemessen statt vermutet** (ADR 039, Arbeitspaket M): ein Zähler liefert die Datenbankabfragen je Anfrage im Kopf `X-Kant-Queries` (außerhalb der Produktion). Die erste Messung zeigte 18 Abfragen auf der Startseite, weil Einstellungen und Theme je Aufrufer erneut gelesen wurden; beide merken sich ihren Stand jetzt für die Dauer einer Anfrage – **18 → 8**. Die Browserprüfung misst je Seite HTML, CSS, Anfragen, Skripte und Abfragen gegen Obergrenzen: Startseite 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 0 Skripte.
|
||||
- **Aufgabenplan ohne Cronjob** (ADR 073, Migration `20260914000100`): eine Zeile je Aufgabe mit Fälligkeit, Sperre und letztem Ergebnis. Wer sie ausführen darf, entscheidet ein bedingtes `UPDATE` – auf MySQL wie auf SQLite atomar, damit zwei gleichzeitige Besucher eine Aufgabe nicht zweimal starten; die Sperre verfällt nach 15 Minuten von selbst. Der Normalfall kostet **keine Datenbankabfrage**: in `var/scheduler.json` steht, wann überhaupt das nächste Mal etwas ansteht. Ein Fehlschlag ist ein sichtbarer Zustand mit wachsendem Abstand bis zum nächsten Versuch (Verdopplung, gedeckelt bei sechs Stunden), und eine scheiternde Aufgabe reißt nie eine Seite mit. Neue Aufgaben sind eine Datei: wer `App\Ops\Tasks\Task` implementiert, steht beim nächsten Abgleich im Plan. Befehle: `cms:tasks`, `cms:tasks:run` (`--task`, `--limit`, `--budget`, `--loop`, `--max-time`).
|
||||
- **Das erste Bild einer Seite lädt sofort**, alles darunter weiterhin verzögert. Es ist meistens das größte Ding über der Falz.
|
||||
- **Slider mit Punkten ohne Skript** (ADR 044): `::scroll-marker` zeichnet je Folie einen Punkt, der aktive ist erkennbar, die Rollleiste entfällt; ältere Browser behalten das wischbare Schiebefeld. Beitragskarten sind echte Karten (Bild 16:10, 18 px Ecken). Überschriften dürfen ein Wort hervorheben (`<mark>`, in Akzentfarbe). Behoben: die Theme-Schriftskala überschrieb die Größen in Karten und Aufruf-Bändern.
|
||||
- **„Weiterlesen" ist jetzt ein Link.** In der wechselseitigen Beitragsliste stand dort eine reine Beschriftung: sie sah aus wie ein Knopf und war keiner. Der Titel steckt für Vorlesesoftware im Linktext, damit nicht zwölf Links gleich heißen.
|
||||
- **Behoben in 1.4.5: die Verwaltung zeigte Git-Fehler auf einer Installation ohne Git** (ADR 072). `git` installiert und „Arbeitskopie" waren derselbe Schalter; auf Umuts Hosting ist Git vorhanden, die Installation kam aber aus dem Releasearchiv. Dort stand `fatal: Not a git repository` an der Stelle der Fassungsnummer und eine rote Vorprüfung für Bedingungen, die gar nicht gelten. Ohne Arbeitskopie verschwinden die Git-Karten jetzt.
|
||||
- **Behoben in 1.4.5: „Sicherung anlegen" endete mit 500** (ADR 072). `VACUUM INTO` gibt es erst seit SQLite 3.27; Umuts Hosting hat eine ältere Fassung. Bei älteren wird unter einer Schreibsperre kopiert, und der Sicherungsknopf fängt jeden Fehler ab, statt mit einer 500-Seite zu enden.
|
||||
- **Das Update schreibt mit** (1.4.6, ADR 071): Jeder Schritt des Archiv-Updaters geht sofort nach `var/updates.log` – während des Laufs, nicht am Ende. Auf Umuts Hosting war die Ergebnisseite tot, und damit war auch verloren, was der Updater berichtet hatte; genau so wurde der nächste Fehlschlag dann doch noch lesbar („`bin` ließ sich nicht beiseitelegen – der alte Stand wurde zurückgeschoben"). Die Ergebnisseite versteht zusätzlich die Schreibweise bis 1.4.4: Beim Update schreibt der **alte** Code die Schritte und der **neue** zeigt sie an.
|
||||
- **Behoben in 1.4.5: der Updateknopf endete mit 500** (ADR 071). Auf Umuts Hosting gefunden, mit einer echten 1.4.3-Installation nachgestellt: Das Update lief vollständig durch – alle neun Schritte, Dateien getauscht, `var/` und `.env` unberührt –, aber die **Ergebnisseite** starb daran. Der Updater über Git nennt einen Schritt `step`, der über das Archiv nannte ihn `label`, beide legen ihn unter demselben Sitzungsschlüssel ab, und die Vorlage liest `step`. Damit war auch der zweite Knopf unerreichbar und die Website blieb im Wartungszustand. Zehn Tests prüften das Rückgabe-Array, keiner die Seite, die es anzeigt; der neue Test rendert sie und wurde gegengeprüft, indem der Fehler noch einmal eingebaut wurde.
|
||||
- **Migrationen im Browser ausführen** (1.4.4, ADR 070): Wer ein Update von Hand über FTP einspielt, hat danach neuen Code und eine alte Datenbank. Der Bildschirm „System" zeigt jetzt eine Karte, sobald etwas aussteht, nennt die Namen und führt sie auf Knopfdruck aus – vorher ging das nur über eine Konsole, also genau das, was auf geteiltem Hosting fehlt. Der Weg von Hand steht Schritt für Schritt in `docs/UPDATE.md`.
|
||||
- **Ein Fehlschlag beim Laden sagt, woran es lag** (1.4.4, ADR 070): „Die Releaseseite antwortet nicht" war drei Ursachen in einem Satz. Beim Gegenprüfen des Releases fiel auf, dass diesem PHP der **Zertifikatsspeicher** fehlt – auf einem Webhosting der häufigste Grund und der einzige, den man in einer Minute behebt. Die Meldung nennt ihn jetzt, und bei genau diesem Fehler wird der zweite Weg über den Dateizeiger versucht. Abgeschaltet wird die Prüfung nicht.
|
||||
- **Das Hintergrundbild des ersten Abschnitts wird vorab geholt** (1.4.3, ADR 069): Auf der Startseite des Piloten ist das größte Element über der Falz kein `<img>`, sondern der Hintergrund des Heros – und der steht nur im Stylesheet, das der Browser erst nach dem HTML holt. Drei Vorabverweise mit denselben Gerätegrenzen wie der Compiler holen **genau die Variante, die die Regel auch nimmt**; ein `imagesrcset` hätte bei manchen Fensterbreiten ein zweites Bild geladen. Gemessen: LCP 1,3 s → 1,1 s, Leistung Desktop 96 → 97, Mobil 98 → 99 (auf dem Entwicklungsserver ohne Kompression). Dabei gefunden: dasselbe Bild wurde je Geräteregel neu abgefragt – die Mediathek merkt sich jetzt, wonach sie in dieser Anfrage schon gefragt wurde, **Startseite 19 → 15 Abfragen**.
|
||||
- **Update aus dem Releasearchiv** (1.4.3, ADR 068): der Weg für Installationen ohne Git – bisher stand dort nur „Dieses Verzeichnis ist keine Git-Arbeitskopie". Zwei Knöpfe im Bildschirm „System": der erste lädt, prüft die Prüfsumme, sichert und ersetzt die Dateien, der zweite führt die Migrationen aus – in einer eigenen Anfrage, weil ein Prozess, der sich selbst ausgetauscht hat, mit dem alten Code weiterarbeitet. `var/`, `.env`, hochgeladene Schriften und das erzeugte Stylesheet-Bündel bleiben unberührt, `public/` wird dazu Datei für Datei zusammengeführt. Der alte Stand liegt danach unter `var/update/`. Geprüft mit acht Tests gegen eine Releaseseite im Speicher (`tests/ArchiveUpdateTest.php`), einer davon hält die Ersetzungsliste gegen die Mitnahmeliste des Releaseskripts.
|
||||
- **Eigene Symbole in der Verwaltung** (1.4.3, ADR 067): zwölf SVG-Pfade statt Unicode-Zeichen, die auf dem Telefon als Emoji in fremdem Stil erscheinen. Ein Test findet Emoji-Zeichen in der Navigationsvorlage und zwei Einträge mit demselben Pfad – beides war vorhanden.
|
||||
- **Verwaltung auf dem Telefon** (1.4.3, ADR 067): das Menü klappt unter 760 px zu und nennt in der Zusammenfassung den Bereich, in dem man steht; Ziele 44 px hoch, kein Skript, kein seitliches Scrollen. Geprüft bei 390 px.
|
||||
- **Farbwähler im Designbereich** (1.4.3, ADR 066): Farbfeld und Palettenauswahl neben dem Textfeld, das maßgeblich bleibt – ein `<input type="color">` allein hätte `@akzent` in einen festen Wert verwandelt und die Verbindung zur Palette gekappt. Ohne JavaScript bleibt das Textfeld.
|
||||
- **Behoben in 1.4.2: die Systemprüfung nennt jetzt die SQLite-Fassung.** Auf dem Hosting scheiterte die Installation im dritten Schritt mit „near \"(\": syntax error" – dort läuft SQLite in einer Fassung vor 3.16, und Doctrine liest das Schema mit Tabellenfunktionen (`pragma_table_info(...)`), die es erst seit 3.16 gibt. Die Prüfung nennt die gefundene Fassung vor der Wahl der Datenbank, und der Datenbankschritt bietet SQLite gar nicht erst an, wenn sie zu alt ist – mit dem Hinweis auf MySQL. Eine Voraussetzung, die erst mitten in der Installation auffällt, ist keine Prüfung wert.
|
||||
- **Behoben in 1.4.1: dem Releasearchiv fehlten `migrations.php` und `cli-config.php`.** Auf dem Hosting brach die Installation deshalb im dritten Schritt ab – „The configuration file does not exist", genau dort, wo die Tabellen entstehen sollten. Die beiden Dateien standen weder in der Mitnahme- noch in der Ausschlussliste des Releaseskripts, also fiel niemandem auf, dass sie fehlen. Ein Test ordnet jetzt **jeden** Eintrag im Projektstamm einer der beiden Listen zu; was neu dazukommt und unbedacht bleibt, lässt ihn fehlschlagen. Der Installationsweg wurde anschließend vollständig durchgespielt: 3504 Dateien entpackt, 14 Migrationen ausgeführt, 15 Tabellen angelegt.
|
||||
- **Fassung 1.4.0 ist veröffentlicht** und der Installationsweg einmal vollständig durchgespielt: `install.php` aus dem Release geladen (anonym, ohne Anmeldung), in ein leeres Verzeichnis gelegt, aufgerufen – es lädt `kantsites-1.4.0.zip` (4,7 MB, 3502 Dateien mit `vendor/`), prüft die SHA-256-Summe, entpackt und löscht sich selbst. Die frische Installation antwortet mit der Systemprüfung unter `/install`. **Das Repository ist dafür öffentlich** – ein privates liefert die Downloadadresse ohne Anmeldung nicht aus.
|
||||
- **Die Verwaltung kann umziehen** (ADR 065): `php bin/console cms:admin:path meine-verwaltung` legt sie auf eine andere Adresse – geprüft wird, dass der Name frei ist und keine Seite verdeckt. Der Wert steht in `.env.local` und gilt für Routen, Firewall, Schutzkopfzeilen und Statistik. **In der `robots.txt` wird ein geänderter Name nicht genannt**; beim Standardnamen `admin` bleibt die Zeile, weil diese Adresse ohnehin bekannt ist. Die Systemseite zeigt die aktuelle Adresse an.
|
||||
- **Dokumentschema v19**: Baustein **Rechtszeile** (Impressum, Datenschutz, „Einwilligung ändern") für den Fußbereich – gestaltbar und an der richtigen Stelle statt als heller Streifen unter einem dunklen Fuß; die eingebaute Leiste erscheint nur noch, wenn die Seite den Baustein nicht hat. Dazu **„Beim Klick"** je Bild (nichts / vergrößern / Adresse) und **„Bilder vergrößerbar"** je Galerie.
|
||||
- **Bildbetrachter** (ADR 063): ohne JavaScript ein Verweis auf die große Bildfassung, mit JavaScript ein Betrachter über der Seite – `<dialog>` mit Blättern, Escape, Pfeiltasten und Zähler. Das Skript lädt nur auf Seiten, die ein vergrößerbares Bild enthalten.
|
||||
- **Knopf „nach oben"** (ADR 062), abschaltbar und vollständig einstellbar: Seite, Form, Größe, Farben, Schwelle, Beschriftung. Ein Anker ohne Skript; eingeblendet über eine Scroll-Zeitachse, wo der Browser sie kennt.
|
||||
- **Behoben: zwei Stylesheet-Regeln griffen seit drei Veröffentlichungen nicht mehr** (ADR 064). Beim Entfernen alter Regeln waren Selektorreste stehen geblieben; CSS nimmt dann die nächste Regel mit. Betroffen waren der Einwilligungsdialog und die Rechtszeile – beide standen ungestaltet unter dem Fuß. Ein Test prüft jetzt jede CSS-Datei auf abgebrochene Selektoren.
|
||||
- **Menüs kosten nicht mehr je eine Abfrage.** Jedes Menü holte die Seitenliste selbst; mit vier Menüs im Fuß waren das vier gleiche Abfragen je Seitenaufruf. Jetzt einmal je Anfrage gemerkt – Startseite des Nachbaus **21 → 18 Abfragen**.
|
||||
- **Behoben: die Vorschau zeigte beim Aufbau bis zu 1440 ms lang eine Seite ohne Gestaltung** (ADR 061). Die Stile wurden erst angewandt, wenn das letzte Bild geladen war. Jetzt sitzen sie, sobald das Dokument geparst ist, und der Rahmen bleibt bis dahin verdeckt. Die Farben selbst stimmten immer – Vorschau und veröffentlichte Seite geben nachweislich dieselben berechneten Werte.
|
||||
- **Cloudflare Turnstile für Formulare, optional** (ADR 059): aus, bis in den Einstellungen beide Schlüssel stehen; dann erscheint die Prüfung in jedem Formular, und die Sicherheitsrichtlinie öffnet sich nur auf Seiten mit Formular für `challenges.cloudflare.com`. Antwortet Cloudflare nicht, wird die Anfrage trotzdem angenommen. Die eingebauten drei Prüfungen ohne Dritte bleiben unabhängig davon bestehen. Der Preis steht in der Verwaltung dabei: das Widget braucht JavaScript.
|
||||
- **Behoben: die Fehlerseite eines Formulars hatte keine Gestaltung** (ADR 060). Wer ein Pflichtfeld vergaß, bekam eine nackte Seite – der Formularcode baute sie selbst auf und ließ Theme, Blockstile und Schriften weg. Jetzt rendert derselbe Controller wie sonst auch.
|
||||
- **Die Schriften liegen im Repository** (`public/assets/fonts/`, mit `lato-OFL.txt` und `LIZENZ.md` daneben, wie die Open Font License es verlangt): auf einem frischen Server fehlt damit nichts, und es wird nie versehentlich von Google geladen. Andere Installationen holen ihre eigenen mit `cms:fonts:fetch`.
|
||||
- **`docs/NEUE-WEBSITE.md`**: die Anleitung, mit der eine neue Kundenwebsite entsteht – Reihenfolge, Dokumentformat, Bilder, SEO, Prüfläufe, und am Ende die fünf Fehler, die in diesem Projekt immer wieder passiert sind.
|
||||
- **Schutzkopfzeilen ergänzt und geprüft**: `Permissions-Policy` (Kamera, Mikrofon, Standort und Sensoren abgeschaltet) auf öffentlichen Seiten und in der Verwaltung; `X-Powered-By` wird entfernt – der Server nannte bisher in jeder Antwort seine genaue PHP-Fassung. Die Browserprüfung misst die Kopfzeilen jetzt, statt sie anzunehmen.
|
||||
- **Behoben: der Editor kannte Schema v17, der Validator v18.** In einem aktuellen Dokument war die Beitragsnavigation nicht einfügbar. Ein Test liest die höchste Fassung jetzt aus dem Validator und vergleicht sie mit der des Editors – dieselbe Falle war schon bei v13 zugeschnappt.
|
||||
- **Getrennter Titel für Suchergebnisse** (Migration `20260913000100`): jede Seite und jeder Beitrag kann einen eigenen `<title>` tragen, unabhängig von der Überschrift auf der Seite. Leer heißt weiterhin: der Titel der Seite gilt. Er zählt auch für `og:title`.
|
||||
- **Schriften kommen vom eigenen Server** (ADR 057): ein Knopf im Designbereich oder `php bin/console cms:fonts:fetch` lädt die gewählten Google-Schnitte als WOFF2 nach `public/assets/fonts` und schreibt die `@font-face`-Regeln daneben. Damit lädt keine Seite mehr etwas von Google – das spart zwei fremde Verbindungen nacheinander (gemessen 820 ms) und die datenschutzrechtliche Angreifbarkeit gleich mit. Ohne Dateien bleibt es beim bisherigen Verweis.
|
||||
- **Ein Stylesheet statt vier, und eine `.htaccess`, die etwas tut** (ADR 056): gebündelt, ohne Kommentare, mit Prüfsumme im Dateinamen; Kompression, ein Jahr Cache für Dateien mit Fassung oder Kennung in der Adresse, Schutz vor dem Ausliefern interner Dateien, HTTPS-Umleitung und HSTS vorbereitet und auskommentiert. Der Entwicklungsserver sendet dieselben Kopfzeilen, damit lokale Messungen etwas bedeuten.
|
||||
- **Bilder in der Größe, in der sie erscheinen** (ADR 058): ein Hintergrundbild, das nur für „Desktop“ gesetzt wurde, gilt auf dem Telefon in der Telefongröße (gemessen 189 KB → 40 KB); `<img>` nennt seine feste Breite, wenn es eine hat, und sonst `sizes="auto"`; `fetchpriority="high"` liegt auf dem ersten Bild des Inhalts statt auf dem Logo im Kopfbereich.
|
||||
- **Lighthouse gegen den Nachbau von kant-it.de** (`docs/quality/lighthouse-kantit.json`, vier Adressen, je drei Läufe, Median): Barrierefreiheit, Best Practices, SEO und „agentic browsing“ **100 auf allen geprüften Seiten, mobil wie am Schreibtisch**. Performance: Projekte-, Beitrags- und Impressumsseite **100/100**, Startseite **98 mobil / 96 am Schreibtisch** – dort liegen vier Sliderbilder und ein großflächiges Hintergrundbild über der Falz. Gemessen im Produktionsmodus mit denselben Kopfzeilen, die `public/.htaccess` setzt; keine Felddaten und keine Messung auf echtem Hosting.
|
||||
- **Verweise werden geprüft** (`npm run test:links`): folgt der Sitemap, sammelt jeden internen Verweis samt Bildern, Stylesheets und Hintergrundbildern und meldet jede Antwort ab 400. Im Nachbau 408 Adressen, kein toter Verweis.
|
||||
- **Das Vorschaubild beim Teilen erscheint jetzt tatsächlich** (ADR 056): `og:image` wird aus dem Feld der Seite aufgelöst, ersatzweise aus dem Beitragsbild – vorher speicherte das Feld und bewirkte nichts, keine KantSites-Seite hatte ein Teilen-Bild. Dazu `og:site_name`, `og:locale`, `og:image:alt`, `twitter:card` (klein ohne Bild, groß mit) und das Bild im strukturierten Datensatz eines Beitrags. Archive haben jetzt ebenfalls Open-Graph-Angaben.
|
||||
- **Eigene robots.txt-Zeilen: 200 statt 30, Leerzeilen bleiben** (ADR 056), damit sich Regeln je Bot in Blöcken schreiben lassen – etwa welche KI-Crawler lesen dürfen. Ein Sitemap-Index ist bewusst nicht gebaut: nötig wird er ab 50.000 Adressen.
|
||||
- **Beiträge sind optional** (ADR 053): ohne einen einzigen veröffentlichten Beitrag antworten `/beitraege` und `/feed.xml` mit 404, der Feedverweis fehlt im Kopf, die Sitemap kennt kein Archiv, und ein Beitragslisten-Baustein zeigt öffentlich nichts – der Hinweis „hier erscheinen Beiträge“ bleibt nur im Editor. Ein Kategorie- oder Schlagwortarchiv verschwindet ebenso, sobald es nichts mehr zu zeigen hat; der Begriff selbst bleibt. Dabei behoben: die Beitragsliste fragte je Beitrag einzeln nach Bild und Begriffen – jetzt je eine Abfrage für die ganze Liste, Startseite des Nachbaus **22 → 16 Abfragen**.
|
||||
- **Unterstriche nur noch im Fließtext** (ADR 054), dort mit Schalter im Designbereich („Links im Fließtext unterstreichen“, Standard an). Karten, Menü, Kontaktzeilen, Rechtszeile und der „Ansehen“-Streifen über Kartenbildern haben keinen mehr; beim Überfahren erscheint er überall. Die aktuelle Seite im Menü wird über das Schriftgewicht kenntlich – die Akzentfarbe fiel im dunklen Fußmenü durch die Kontrastprüfung.
|
||||
- **Eigenes Symbol der Website** (ADR 055): ein Bild aus der Mediathek in den Einstellungen wählen, beim Speichern entstehen daraus quadratische PNG-Dateien in 32 und 180 px (`/favicon.png`, `/apple-touch-icon.png`). Ohne Auswahl bleibt es beim mitgelieferten Zeichen.
|
||||
- **Die Browserprüfung prüft jetzt jede Installation, nicht nur die Demo**: die geprüften Seiten kommen aus der Sitemap, Tastatur- und Ohne-JavaScript-Weg folgen dem ersten Verweis im Inhalt statt festen Linktexten der Demo. Das Ladebudget trennt Bilder (Inhalt) vom Beiwerk (≤ 12 Anfragen) und deckelt stattdessen das übertragene Gewicht (1,5 MB); Skripte werden gegen die Richtlinie der Seite gezählt, strukturierte Daten zählen nicht mit. Der Nachbau von kant-it.de besteht die vollständige Prüfung ohne Befund.
|
||||
- **Einblendungen laufen beim Scrollen** (ADR 052): `animation-timeline: view()` hinter Bewegungsschranke und `@supports`; auf dem Scrollweg nur Bewegung, keine Deckkraft, damit Text außerhalb des Blicks lesbar bleibt. Die drei Beitragsvorlagen bringen sie mit.
|
||||
- **Vier Darstellungen der Beitragsliste** (ADR 051): Karten, Liste, Kompakt und Wechselseitig – am Baustein einstellbar, dieselben Daten, nur andere Regeln.
|
||||
- **Drei Aufbauten für neue Beiträge** (ADR 050): „Bildkopf“, „Klassisch“ und „Magazin“, wählbar beim Anlegen. Startpunkte, keine Schablonen – jeder Block bleibt änderbar.
|
||||
- **Dokumentschema v18** (ADR 049): Baustein „Beitragsnavigation“ – Vor/Zurück zwischen Beiträgen derselben Kategorie, vom Renderer gefüllt, ohne Umbruch am Rand. Dazu Autorotation für den Slider (Sekunden, eigenes Skript nur auf Seiten mit einem solchen Slider) und Blättern in der Beitragsliste.
|
||||
- **Dokumentschema v17** (ADR 043): eine Beitragsliste kann Datum, Auszug und Kategorie ausblenden und damit als Projektraster erscheinen; ein Bild kann verlinken (Logo auf die Startseite). Dazu die Einstellung „Kopfbereich beim Scrollen oben halten“. Behoben: im Editor ließ sich in Dokumenten ab v13 kein Baustein mehr einfügen.
|
||||
- **Die Gestaltung färbt jetzt auch die Bausteine** (ADR 042): Symbole, Knöpfe, Rahmen, ruhiger Text, Fokusring und Einwilligungsdialog nehmen die Farben des Themes statt fester Werte aus `site.css`. Der Einwilligungsdialog lässt sich zusätzlich eigens färben über eine Palettenfarbe mit dem Schlüssel `consent`. Überschriften werden nicht mehr getrennt.
|
||||
- **Installer (Arbeitspaket N, ADR 041):** `/install` führt in sechs Schritten von einer leeren Installation zu einer Website – Systemprüfung, Datenbank (SQLite oder MySQL, Verbindung sofort geprüft), Migration im Prozess ohne Konsole, Website mit Startseite, Kopf-/Fußbereich, Menü sowie Impressum und Datenschutzgerüst, erstes Administratorkonto, Abschlussliste. Solange keine Installation existiert, führt jede Adresse dorthin; sobald ein Konto existiert, antwortet `/install` mit 404. Davor liegt `scripts/install.php`: eine einzelne Datei, die das Releasearchiv mit fester Adresse und fester SHA-256 lädt, gegen ZIP-Slip gesichert entpackt und sich danach selbst löscht.
|
||||
- **Fassungsverlauf, Einsendungen und Weiterleitungen blättern** statt bei 30, 50 beziehungsweise 2000 Einträgen stillschweigend aufzuhören.
|
||||
- **Sicherung und Wiederherstellung** (ADR 038, Arbeitspaket O): `cms:backup` schreibt Datenbank (über `VACUUM INTO`, nicht als Dateikopie) und Medienordner nach `var/backups/`, mit Manifest samt Prüfsumme, und rotiert die alten. In der Verwaltung gibt es die Übersicht mit Zustand je Sicherung und einen Knopf zum Anlegen. **Zurückspielen nur auf der Konsole** (`cms:restore`): Prüfsumme, Fassungsvergleich, Sicherheitskopie des aktuellen Stands, Wartungszustand, Datenbank ersetzen, Medien **ergänzen** statt ersetzen. Nicht enthalten: `.env`, `vendor/`, Quellcode – steht so im Manifest.
|
||||
- **Seiten ohne Menü, andere Köpfe je Seite** (ADR 037, Dokumentschema v16): jede Seite wählt Kopf- und Fußbereich – Standard, ein anderer Bereich oder **keiner**, für eine Landingpage ohne Navigation. Dafür sind mehrere Kopf- und Fußbereiche erlaubt, jeder mit eigener Adresse; der erste bleibt `default`. Ein Verweis auf einen gelöschten Bereich fällt auf den Standard zurück statt die Navigation stillschweigend zu verlieren.
|
||||
@@ -83,6 +168,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- 201 PHP-Tests / 716 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 15 JavaScript-Modelltests, der vollständige Playwright-Editortest und die öffentliche Browserprüfung. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen.
|
||||
- Neu geprüft (15 Tests): privater Entwurf nach dem Anlegen, verschachtelte Pfade mit gleichem Slug unter verschiedenen Eltern, abgewiesene Adressen (Großbuchstaben, Schrägstrich, leer, reserviert, unbekannter Typ, fehlendes Elternteil) ohne Schreibzugriff, doppelte Adresse, Tiefengrenze, Beiträge ohne Unterseiten, Löschen mit allen Revisionen samt Schutz für Elternseiten und veraltete Versionen, sowie dass die CMS-Routen nicht vom öffentlichen Auffangmuster verschluckt werden. Über HTTP zusätzlich: anonyme Schreibversuche, CSRF für Anlegen und Löschen, Adressableitung aus dem Titel, kein Löschangebot und HTTP 403 für die Editorrolle, erfolgreiches Löschen als Administrator.
|
||||
- Verwaltung geprüft: axe ohne Verstöße auf Anmeldung, Seitenübersicht und Editor (Desktop und Mobil), 201 PHP-Tests grün. **Nicht geprüft:** kein Screenreader, und die Bildschirme Menüs, Formulare, Bereiche, Design, Einstellungen und Fassungen sind nur von Hand angesehen, nicht automatisiert auf Barrierefreiheit geprüft – die Browserprüfung deckt sie nicht ab.
|
||||
- **Aufgabenplan geprüft** (14.09.2026): 12 Tests auf die Mechanik (nur Fälliges läuft; eine gesperrte Aufgabe wird übergangen, eine abgelaufene Sperre übernommen; ein Fehlschlag wird festgehalten und der nächste Versuch rückt weiter weg; eine abgeschaltete Aufgabe läuft nicht, von Hand gestartet doch; eine Anfrage arbeitet nicht alles auf einmal ab; eine fehlende Tabelle ist kein Fehler) und 5 über HTTP (der Bildschirm nennt jede Aufgabe und die Adresse des URL-Cronjobs; anonym führt niemand etwas aus, eine Redakteurin bekommt 403, ohne gültiges Formular passiert nichts; der Takt des Backends antwortet mit JSON; ein falscher Cron-Schlüssel bekommt 404). Von Hand auf der Entwicklungsinstallation **alle vier Auslöser ausgeführt**: alle fünf Aufgaben über die Konsole (Sicherung 36 ms, Updateprüfung 318 ms gegen das echte Repository), ein Seitenaufruf hat eine fällige Aufgabe abgearbeitet (Auslöser `besuch` im Protokoll), das geöffnete Backend hat getaktet (`POST /admin/aufgaben/takt` → 200), und der URL-Cronjob hat mit dem richtigen Schlüssel Text geliefert und mit einem falschen 404. Der Bildschirm wurde im Browser bedient (Abstand und Schalter gespeichert und wieder zurückgesetzt); dabei fiel eine Inline-Stilangabe auf, die die CSP der Verwaltung blockiert hat – sie ist jetzt eine Klasse.
|
||||
- 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.
|
||||
@@ -134,11 +220,14 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- v3-Galerieseite mit echten optimierten Testbildern und Effekten: sechs Lighthouse-Läufe, alle fünf Kategorien jeweils 100; LCP-Median mobil 937 ms/Desktop 279 ms, CLS/TBT 0. Upload, Metadaten, Mehrfachauswahl per Tastatur, Bildbeschriftung, Sortier-Undo, Archivieren/Wiederherstellen, Bewegungsreduktion und öffentliche Bilder im isolierten Browserablauf bestanden. Keine axe-Verstöße in Desktop-/Mobil-Mediathek; Screenshots geprüft. Berichte: `docs/quality/editor-media-browser.json` und `editor-media-lighthouse.json`.
|
||||
- Keine bekannten Dependency-Advisories im ausgeführten Composer-Audit/npm-Installationsaudit.
|
||||
|
||||
**Gemessen am Nachbau selbst (13.09.2026, Fassung 1.4.3):** je drei mobile und drei Desktop-Läufe auf `/`, `/projekte` und `/impressum` im Produktionsmodus, Median. Barrierefreiheit, Best Practices, SEO und agentisches Browsen überall **100**. Leistung: `/projekte` und `/impressum` **100/100**, die Startseite **99 mobil / 97 Desktop** – dort bleibt das blockierende Stylesheet (rund 300 ms erste Anzeige) das Letzte, und das ist zu einem guten Teil der Entwicklungsserver ohne Kompression und Cache-Kopfzeilen. Bericht: `docs/quality/kantit-lighthouse.json`. **Auf dem echten Hosting nachmessen** – dort greifen die Regeln aus `public/.htaccess`.
|
||||
|
||||
## Bekannte Einschränkungen
|
||||
|
||||
- Gestaltung über geprüfte Tokens, keine beliebigen Pixelwerte, eigenen Schriften oder freien Farben. Noch keine Inline-Textbearbeitung, Formulare, dynamischen Menüs, Plugin-/Themeverwaltung oder WordPress-Import. Abschnittsvorlagen sind lokale Blockrezepte, keine globale Templateverwaltung.
|
||||
- Mediathek derzeit für statische Bilder: keine Videos/PDFs/SVGs, Lightbox/Slideshow, freie Bildzuschnitte, Ordner, Paginierung oder physische Löschung. Bild-URLs sind öffentlich; Archivieren schützt keine vertraulichen Dateien. Originale werden durch optimierte Varianten ersetzt. Einblendungen starten beim Laden, nicht beim Scrollen.
|
||||
- Sektionen bieten die vorhandenen Farb-/Breitentokens geräteübergreifend. Benutzerverwaltung und Passwortänderung sind noch nicht als Browseroberfläche verfügbar.
|
||||
- **Der Aufgabenplan ist von Anfragen abhängig.** Ohne Besucher und ohne geöffnetes Backend läuft nichts; minutengenau ist nur ein echter Cronjob oder der URL-Cronjob des Hosters. Unter Apache mit mod_php belegt eine laufende Aufgabe einen PHP-Prozess, nachdem die Seite beim Besucher ist (dort gibt es kein `fastcgi_finish_request()`) – deshalb sind die Grenzen je Seitenaufruf klein (2 Aufgaben, 20 Sekunden) und der Auslöser abschaltbar. Die Sicherung ist die schwerste Aufgabe und läuft trotzdem mit: eine tägliche Sicherung ist mehr wert als keine. **Nicht geprüft:** das Verhalten unter mod_php und unter echter Last – hier lief alles gegen den eingebauten PHP-Server.
|
||||
- **Offene redaktionelle Feststellung:** `/grundlagen` trägt einen im Editor gesetzten Textblock in `#d08080` auf hellem Grund – 2,75:1, unter der AA-Schwelle. Die Farbe wurde **nicht** verändert, weil sie eine Nutzerentscheidung ist; die Prüfung nennt sie bei jedem Lauf, und die Kontrastwarnung im Editor meldet sie ebenfalls.
|
||||
- **Aus Umuts erweitertem Auftrag noch offen** (Paket G2 im Ausbauplan): die Neugestaltung der Verwaltung.
|
||||
- Eigenes CSS kennt keine Gerätevarianten – dafür sind die Vorschaugrößen im Designbereich da. Eigenes Skript hat keine Fassungsverwaltung außer der Seitenhistorie und keine Prüfung auf Syntaxfehler; ein fehlerhaftes Skript schreibt in die Browserkonsole und lässt die Seite sonst unangetastet.
|
||||
@@ -160,9 +249,291 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- Noch keine repräsentativen Kundeninhalte/Bilder/Blog-/Formularseite, keine Felddaten und keine vollständige manuelle Barrierefreiheitsprüfung.
|
||||
- Die vor der Hostingantwort erzeugte `.venv` bleibt ungenutzt und von Git ausgeschlossen. Die automatische Freigabeprüfung blockierte ihren Löschversuch mit „blocked by policy“; keine Umgehung versucht. Auch `var/fresh-check`, DB, Secrets und Berichte sind lokal ignoriert.
|
||||
|
||||
## Arbeitspaket H, Teil 1: die Vorschau wird zur Arbeitsfläche
|
||||
|
||||
**Erledigt.** Der Abgleich mit dem Flatsome UX Builder steht in `docs/FLATSOME-ABGLEICH.md`:
|
||||
die Gestaltung ist Flatsome bereits voraus (93 freie Eigenschaften je Gerät und für Hover gegen
|
||||
feste Optionslisten), die Lücken liegen beim Elementkatalog (20 offene Elemente ohne Shop) und
|
||||
bei der Bedienung. Umuts Entscheidung: Bedienung zuerst, Shop-Elemente ausgelassen.
|
||||
|
||||
Umgesetzt ist die erste Hälfte der Bedienung:
|
||||
|
||||
- **Werkzeugleiste am Element** in der Vorschau: Name des Elements, Elternelement auswählen,
|
||||
nach oben, nach unten, duplizieren, löschen. Sie trägt den Namen des Elements
|
||||
(„Sektion duplizieren“) statt des nackten Verbs – die Seitenleiste hat dieselben fünf
|
||||
Aktionen, und zwei gleichnamige Bedienelemente auf einem Bildschirm sind für eine
|
||||
Sprachausgabe nicht unterscheidbar. Beide Wege rufen dieselben Funktionen auf.
|
||||
- **Auswahl- und Überfahrrahmen** mit Beschriftung direkt in der Vorschau.
|
||||
- **Einfügepunkte**: ein Pluszeichen an der Ober- und Unterkante des gewählten Elements setzt
|
||||
eine Marke; das nächste Element aus dem Werkzeugkasten landet genau dort statt am Ende.
|
||||
- **Ziehen aus dem Werkzeugkasten in die Vorschau**, mit Ablegemarke zwischen den Elementen und
|
||||
Rahmen um einen Behälter, der das Element aufnimmt. Der Strukturbaum zieht in dieselbe Fläche.
|
||||
- Im Modell dafür neu: `insertBlockAt`, `moveBlockTo` und `dropList` – punktgenaues Einfügen
|
||||
und Verschieben statt „hinter das Gewählte“.
|
||||
|
||||
Die Ebene liegt im Editordokument über dem Rahmen, nicht darin: die Vorschau wird bei jeder
|
||||
Änderung vollständig ersetzt, und eingezeichnete Bedienelemente wären damit weg und stünden
|
||||
außerdem in der Auszeichnung, die die Vorschau wahrheitsgetreu zeigen soll. Sie trägt denselben
|
||||
Skalierungsfaktor wie der Rahmen; Leiste und Pluszeichen werden gegenskaliert und bleiben in der
|
||||
Tablet- und Mobilvorschau fingergroß.
|
||||
|
||||
**Geprüft.** `npm test` 22 von 22 (vier neue Tests für Einfügen, Verschieben, Behälterregeln und
|
||||
die oberste Ebene). `php vendor/bin/phpunit` 434 von 434. `npm run test:editor` vollständig,
|
||||
mit einem neuen Abschnitt für Werkzeugleiste, Einfügepunkt und Ziehen; alle axe-Prüfungen ohne
|
||||
Verstoß, auch die mobile Editoransicht. Screenshot unter `reports/editor/canvas-overlay.png`.
|
||||
|
||||
**Dabei gefunden und behoben:** die ganze Ebene war zunächst `aria-hidden` und enthielt
|
||||
fokussierbare Schaltflächen – axe hat das gemeldet. `canContain` las `block.type` an einem
|
||||
fehlenden Block und zerbrach beim Ziehen aus dem Werkzeugkasten, wo der Block noch nicht
|
||||
existiert. Beim Verschieben ans Listenende wurde der Index begrenzt, bevor die Entnahme
|
||||
verrechnet war; der Block landete eine Stelle zu früh.
|
||||
|
||||
**Bekannte Grenzen.** Die Vorschau wird weiterhin nach 250 ms serverseitig neu erzeugt – das
|
||||
Ablegen fühlt sich dadurch nicht so unmittelbar an wie bei Flatsome, die Wahrheit bleibt aber
|
||||
der Server. Die Werkzeugleiste ist nicht mit einem Screenreader erprobt, nur mit axe. Spalten
|
||||
lassen sich noch nicht in der Vorschau breiter ziehen, der Werkzeugkasten ist weiterhin eine
|
||||
Textliste ohne Vorschaubilder, und es gibt je Option noch keinen eigenen Geräteschalter.
|
||||
|
||||
## Arbeitspaket H, Teil 2: der Werkzeugkasten kennt den Zusammenhang
|
||||
|
||||
**Erledigt.** Umuts Einwand: Zeile und Spalte standen oben in der Auswahl, als wären sie
|
||||
Seitenanfänge. Sie gehören in den Inhalt – erst eine Sektion, dann eine Zeile darin. Das war
|
||||
nicht nur eine Anzeigefrage: eine Zeile nahm bisher **jedes** Kind an, eine Überschrift konnte
|
||||
neben den Spalten landen, und eine Spalte durfte überall stehen. Die Struktur, die der Editor in
|
||||
seinem eigenen Hinweistext beschreibt – Sektion → Zeile → Spalte → Inhalt –, galt für die
|
||||
Schaltflächen nicht.
|
||||
|
||||
Übernommen ist Flatsomes Regelmodell, weil es die Fälle abdeckt und weil es das ist, was Umut in
|
||||
den Händen hat (`inc/builder/core/server/src/Collections/Elements.php`):
|
||||
|
||||
| Regel | Flatsome | KantSites jetzt |
|
||||
| --- | --- | --- |
|
||||
| `allow` | `row => ['col']`, `accordion => ['accordion-item']` | `ALLOW` – Zeile nimmt nur Spalten |
|
||||
| `require` | `col => ['row']`, `tab => ['tabgroup']` | `REQUIRE` – Spalte lebt nur in einer Zeile |
|
||||
| — | (Flatsome kennt das nicht) | `ROOT_NEEDS_CONTAINER` – eine Zeile ist kein Seitenanfang |
|
||||
|
||||
Der dritte Punkt ist der Zusatz: Flatsome lässt eine Zeile auch lose am Dokumentanfang zu. Genau
|
||||
das ist das „aus dem Nichts“, das Umut stört, also wird es unterbunden – in jedem Behälter
|
||||
dagegen bleibt die Zeile erlaubt, auch in einem Tab oder einem Akkordeon-Eintrag.
|
||||
|
||||
Daraus folgt die Bedienung:
|
||||
|
||||
- **Der Werkzeugkasten ist nach Aufbau gruppiert** – Aufbau, Inhalt, Bausteine, Gruppen, Seite
|
||||
und Daten – statt einer flachen Liste aus 31 Typen.
|
||||
- **Was an der gewählten Stelle nicht hingehört, ist gesperrt und sagt beim Überfahren warum**
|
||||
(„Spalte: gehört in Zeile. Wähle dort zuerst ein Element aus.“). Eine Regel, die man erklärt
|
||||
bekommt, lernt man; eine Schaltfläche, die nur nicht mehr reagiert, drückt man weiter.
|
||||
- **Was möglich ist, sagt wohin es kommt** („Wird in Zeile eingefügt.“).
|
||||
- **Die Zeile wird nach Spaltenzahl angeboten** (2, 3, 4) und bringt ihre Spalten mit. Der
|
||||
separate Zeilenbereich über der Liste ist entfallen.
|
||||
- **Eine Regel für alle Wege:** `insertionTarget` beantwortet „wohin käme das?“, ohne etwas
|
||||
einzufügen. Knopf, Einfügepunkt, Ziehen und die Sperrlogik des Werkzeugkastens nutzen
|
||||
dieselbe Funktion – vorher entschied `insertBlock` allein, niemand konnte vorher fragen, und
|
||||
`insertRow` hatte eine eigene zweite Kopie derselben Platzierungslogik.
|
||||
|
||||
Ein Element, das der gewählte Behälter nicht nimmt, wird **nicht** gesperrt, sondern steigt auf,
|
||||
bis ein Elternteil es annimmt: eine Überschrift bei gewählter Zeile landet hinter der Zeile in
|
||||
der Sektion. Die Absicht ist eindeutig, nur die Zeile ist der falsche Ort.
|
||||
|
||||
**Nebenbei behoben:** `postnav` fehlte seit Schema v18 in `labels` und `icons` – der
|
||||
Strukturbaum zeigte für die Beitragsnavigation „Unbekannt“ und ein Fragezeichen.
|
||||
|
||||
**Geprüft.** `npm test` 27 von 27 (fünf neue: Zeilen-/Spaltenregel, oberste Ebene, Übereinstimmung
|
||||
von Auskunft und Tat, Kategorie-Vollständigkeit, Zeile mit Spalten an fester Stelle).
|
||||
`php vendor/bin/phpunit` 446 von 446. `npm run test:editor` vollständig mit neuem Abschnitt
|
||||
„the toolbox follows the structure“; alle axe-Prüfungen ohne Verstoß. Screenshot unter
|
||||
`reports/editor/toolbox.png`.
|
||||
|
||||
**Bekannte Grenzen.** Inhalt darf weiterhin lose am Dokumentanfang stehen – der Validator
|
||||
erlaubt es, und eine weitere Einschränkung wäre erfunden statt übernommen. Bestehende Dokumente
|
||||
werden nicht nachträglich geprüft: eine Überschrift, die früher direkt in einer Zeile gelandet
|
||||
ist, wird weiterhin ausgegeben und lässt sich auswählen und löschen, nur nicht mehr dorthin
|
||||
verschieben. Der Werkzeugkasten hat weiterhin keine Vorschaubilder und keine Suche.
|
||||
|
||||
## Arbeitspaket H, Teil 3a: die Optionen selbst
|
||||
|
||||
**Erledigt.** Drei Bedienungen, die Flatsome an jeder Option hat und die hier fehlten.
|
||||
|
||||
**Geräteschalter je Option.** Bisher entschied ein einziger Schalter über der Vorschau, welches
|
||||
Gerät das ganze Bedienfeld bearbeitet. „Ist die Überschrift auf dem Telefon kleiner?“ ließ sich
|
||||
nur durch Umschalten und Hinsehen beantworten. Jetzt trägt jede Option drei Chips (D, T, M): sie
|
||||
zeigen, **wo ein eigener Wert liegt**, und ein Klick wechselt dorthin. Das ist mehr als Flatsome
|
||||
bietet – dort schaltet man um, hier sieht man vorher, ob es überhaupt etwas zu sehen gibt.
|
||||
Bedienfeld und Vorschau wechseln gemeinsam: einen Wert für ein Gerät zu setzen, das man nicht
|
||||
ansieht, ist blindes Ändern.
|
||||
|
||||
**Abstand als Rahmen.** Innenabstand, Außenabstand, Rahmenstärke und Ecken standen als vier
|
||||
Felder untereinander, beschriftet „Oben, Rechts, Unten, Links“ – man musste lesen, um zu wissen,
|
||||
welche Kante man setzt. Jetzt sitzt jedes Feld an seiner Kante, die Lage ist die Beschriftung.
|
||||
In der Mitte verkettet ein Schalter alle vier. Er startet **aus**: er ersetzt den früheren Knopf
|
||||
„Alle Seiten gleich setzen“, und das war eine bewusste Handlung. Die Einheit bleibt eine Wahl für
|
||||
die ganze Eigenschaft – vier Einheitenmenüs passen nicht in ein 240 Pixel breites Feld. Seiten
|
||||
mit unterschiedlichen Einheiten werden als „gemischt“ geführt und bleiben unangetastet, bis eine
|
||||
Einheit tatsächlich gewählt wird; ein gespeichertes 2rem wird nicht still zu 2px.
|
||||
|
||||
**Ziehfelder.** Zahlenfelder lassen sich seitwärts ziehen. „24“ tippen, schauen, „26“ tippen,
|
||||
schauen, „22“ tippen ist drei Anläufe für eine Entscheidung; Ziehen beantwortet sie in einer
|
||||
Bewegung. Umschalt grob, Alt fein. Tippen und Pfeiltasten bleiben unverändert. Nur für Maus und
|
||||
Stift: auf einem Tippgerät ist ein seitliches Ziehen über ein Feld die Art, wie das Bedienfeld
|
||||
gescrollt wird.
|
||||
|
||||
**Dabei gefunden und behoben:** Der Zug war zunächst nach einem Pixel zu Ende – jede Änderung
|
||||
zeichnete das Bedienfeld neu und ersetzte damit genau das Feld, an dem der Zeiger hing. Das
|
||||
Bedienfeld bleibt während eines Zuges stehen und wird einmal neu gezeichnet, wenn losgelassen
|
||||
wird (`keepPanel`). Der Kettenschalter verlor aus demselben Grund seinen Zustand, sobald er
|
||||
gedrückt wurde; er liegt jetzt außerhalb der Darstellung. Und das Gleichziehen nahm den Wert des
|
||||
ersten Feldes, auch wenn es leer war – ein Druck auf die Kette löschte damit die Seiten, die
|
||||
einen Wert hatten. Es nimmt jetzt den ersten vorhandenen Wert.
|
||||
|
||||
**Geprüft.** `npm test` 27 von 27. `php vendor/bin/phpunit` 452 von 452. `npm run test:editor`
|
||||
vollständig mit neuem Abschnitt „device chips, spacing box and scrub fields“: Chips zeigen den
|
||||
richtigen Zustand und wechseln Bedienfeld und Vorschau zusammen, der Rahmen ändert ohne Kette
|
||||
nur eine Seite und mit Kette alle vier, die Einheitenumstellung erhält die Zahlen, und ein Zug
|
||||
über 20 Pixel hebt 72 auf 92 und ist **ein** Undo-Schritt. Alle axe-Prüfungen ohne Verstoß.
|
||||
Screenshot unter `reports/editor/options.png`.
|
||||
|
||||
**Bekannte Grenzen.** Die Chips zeigen einen eigenen Wert, aber nicht, *welchen* – dafür muss
|
||||
man weiterhin hinwechseln. Auf Tippgeräten gibt es kein Ziehen. Der Rahmen zeigt geerbte Werte
|
||||
als Platzhaltertext, nicht als eigene Kennzeichnung. Noch offen aus Teil 3: Vorschaubilder und
|
||||
Suche im Werkzeugkasten, Vorlagen je Element mit Bild, und das Ziehen der Spaltenbreite in der
|
||||
Vorschau.
|
||||
|
||||
## 1.5.3: der Editor blieb nach dem Update schwarz
|
||||
|
||||
**Der Fehler.** Nach 1.5.2 meldete der Editor im Browser
|
||||
`The requested module './editor-model.js' does not provide an export named 'dropList'`, und der
|
||||
Werkzeugkasten war leer.
|
||||
|
||||
Ursache war nicht der Code, sondern das Zwischenspeichern. `public/.htaccess` setzt für **jede**
|
||||
`.js`-Datei `Cache-Control: public, max-age=31536000, immutable` – ein Jahr. Die Fassungskennung
|
||||
`?v=` hängt aber nur an den Einstiegsdateien, die die Vorlage schreibt. Die fünf Module, die der
|
||||
Editor über `import './….js'` lädt, tragen keine: der Browser holte das neue `editor.js` und
|
||||
behielt das alte `editor-model.js`. Ein neues `canvas-overlay.js` traf damit auf ein Modell ohne
|
||||
`dropList`.
|
||||
|
||||
Beide gemeldeten Symptome hatten diese eine Wurzel: seit 1.5.1 baut das Skript den Werkzeugkasten
|
||||
auf (`buildLibrary()`), und `editor.js` importiert `canvas-overlay.js` in Zeile 5. Bricht der
|
||||
Import, läuft keine Zeile danach – und die Elementliste bleibt leer. Das gemeldete „Menü fehlt“
|
||||
war der Werkzeugkasten, nicht die Verwaltungsnavigation. Die wurde geprüft: in Chrome 152 stehen
|
||||
`::details-content`, ausgeblendeter Schalter und sichtbare Liste korrekt.
|
||||
|
||||
**Die Behebung.** Eine Importkarte in `base.html.twig` hängt die Fassung an die aufgelöste
|
||||
Adresse jedes Moduls, ohne dass eine einzige Importzeile sie kennen muss. Das behält das
|
||||
Zwischenspeichern über ein Jahr – die Leistung war nie das Problem, nur die fehlende Kennung.
|
||||
|
||||
Eine Importkarte muss eingebettet sein; die Spezifikation kennt keine externe. Unter
|
||||
`script-src 'self'` wurde sie wortlos verworfen. `AdminHeadersSubscriber` erzeugt deshalb je
|
||||
Verwaltungsseite einen Nonce, nennt ihn in der Richtlinie und legt ihn in die Anfrage; die
|
||||
Twig-Funktion `csp_nonce()` gibt ihn der Karte mit. Der Nonce erlaubt genau dieses eine Skript –
|
||||
`'self'` gilt unverändert für alles andere.
|
||||
|
||||
**Geprüft.** Der Browsertest schreibt jede angeforderte `.js`-Adresse mit und verlangt an jeder
|
||||
eine Fassungskennung; vor der Behebung nennt er die fünf fehlenden beim Namen, danach trägt sie
|
||||
alle acht. Dazu ein Test, der jeden `./….js`-Import in `public/assets` gegen die Importkarte
|
||||
hält – ein neues Modul, das dort fehlt, fällt damit im Test auf und nicht erst beim nächsten
|
||||
Update beim Kunden. Gegenprobe gemacht: mit einem entfernten Eintrag schlägt er fehl.
|
||||
`npm test` 28 von 28, `php vendor/bin/phpunit` 452 von 452, `npm run test:editor` vollständig.
|
||||
|
||||
**Bekannte Grenze.** Ein Modulfehler legt den Editor weiterhin vollständig lahm, und der
|
||||
Werkzeugkasten ist seit 1.5.1 nichts ohne sein Skript. Für einen Editor ist das vertretbar – die
|
||||
öffentliche Website läuft ohne JavaScript –, aber die Fehleranzeige dafür ist noch die
|
||||
Browserkonsole und kein Satz in der Oberfläche.
|
||||
|
||||
## Arbeitspaket H, Teil 3b: der Werkzeugkasten bekommt Bilder und eine Suche
|
||||
|
||||
**Erledigt.** Die Elementwahl war eine Liste aus Schriftzeichen – „▣“ für Sektion, „☰“ für Zeile.
|
||||
Jetzt trägt jeder der 34 Einträge eine Strichzeichnung, die zeigt, was er baut: die Sektion ein
|
||||
gefülltes Band, „Zeile, 3 Spalten“ tatsächlich drei Kästen, das Bild einen Rahmen mit Bergzug.
|
||||
|
||||
**Zeichnungen statt Bilddateien.** Flatsome liefert je Element ein PNG mit. Gezeichnet ist hier
|
||||
besser: `library-previews.js` ist ein paar hundert Byte statt eines Ordners voller Bitmaps, die
|
||||
Formen nehmen die Farbe der Fläche an, auf der sie liegen – gesperrte Einträge werden damit von
|
||||
selbst blass – und sie bleiben auf jedem Bildschirm scharf. Die Zeilenvorschau wird gerechnet,
|
||||
nicht gezeichnet: zwei, drei oder vier Kästen aus derselben Funktion.
|
||||
|
||||
**Suche.** Ein Feld über der Liste filtert über alle Gruppen. Gesucht wird über den Namen, den
|
||||
internen Typ **und** den Gruppennamen – wer „slider“ eingibt, muss nicht wissen, dass er unter
|
||||
„Gruppen“ einsortiert ist. Gruppen ohne Treffer verschwinden, damit die Liste keine Lücken
|
||||
behält, und darunter steht, wie viele von wie vielen übrig sind.
|
||||
|
||||
**Dabei bemerkt:** Es gab damit zwei Suchfelder mit dem Namen „Element suchen“ auf einem
|
||||
Bildschirm – für eine Sprachausgabe nicht unterscheidbar. Sie heißen jetzt nach dem, was sie
|
||||
durchsuchen: „Element zum Einfügen suchen“ im Werkzeugkasten, „Element auf der Seite suchen“ im
|
||||
Strukturbaum.
|
||||
|
||||
**Geprüft.** Der Browsertest zählt die Vorschaubilder gegen die Einträge (alle 34 haben eines),
|
||||
sucht nach „galerie“ (ein Eintrag, eine Gruppe), nach „aufbau“ (fünf Einträge über den
|
||||
Gruppennamen), nach etwas Nichtvorhandenem und wieder nach nichts. `npm test` 28 von 28,
|
||||
`php vendor/bin/phpunit` 452 von 452, `npm run test:editor` vollständig ohne axe-Verstoß.
|
||||
|
||||
**Noch offen aus Teil 3.** Vorlagen je Element mit Vorschaubild. Und das Ziehen der
|
||||
Spaltenbreite: das geht heute nicht ohne einen Zwischenschritt, denn eine Zeile ist ein
|
||||
CSS-Raster mit **gleich breiten** Spalten (`repeat(n, minmax(0,1fr))`); ein `span` je Spalte
|
||||
gibt es nicht. Dafür braucht es erst ein Zwölf-Spalten-Modell im Designtoken-Manifest, und das
|
||||
berührt die öffentliche CSS und damit die Messwerte aus AGENTS.md. Das ist ein eigener Schritt,
|
||||
kein Nebenbei.
|
||||
|
||||
## 1.5.5: die Verwaltungsnavigation war am Schreibtisch unerreichbar
|
||||
|
||||
**Der Fehler.** In der ausgelieferten Fassung fehlte in der Verwaltung am Schreibtisch die
|
||||
gesamte linke Navigation – Marke oben, Fußzeile unten, dazwischen nichts. Kein Weg zu Seiten,
|
||||
Mediathek, Einstellungen außer über die Adresszeile.
|
||||
|
||||
Die Navigation steckt in einem `<details>`: auf dem Telefon ein Klappmenü, am Schreibtisch die
|
||||
stehende Spalte. Am Schreibtisch wurde der Schalter mit `.shell-menu > summary { display: none }`
|
||||
ausgeblendet – aber das Element trug kein `open`, und ein geschlossenes `details` versteckt seinen
|
||||
Inhalt selbst. Schalter weg **und** Inhalt weg.
|
||||
|
||||
**Die Behebung.** `::details-content` holt den Inhalt am Schreibtisch zurück, während der
|
||||
Schalter ausgeblendet bleibt. Kennt ein Browser die Regel nicht, fällt über `@supports` die ganze
|
||||
Gruppe weg: dann steht der Schalter da und die Liste ist einen Klick entfernt – statt gar nicht
|
||||
da. Auf dem Telefon bleibt es beim Klappmenü.
|
||||
|
||||
Der Code dafür lag bereits im Arbeitsverzeichnis: eine parallel laufende Sitzung hatte ihn
|
||||
geschrieben, aber nicht committet, und damit war er in keinem Release. Übernommen ist hier nur
|
||||
diese Korrektur, nicht die übrige Arbeit jener Sitzung.
|
||||
|
||||
**Wie das durchrutschen konnte – und was dagegen getan ist.** Beim vorigen Bericht wurde die
|
||||
Navigation für in Ordnung erklärt, nachdem eine **Nachbildung** der Regel in einer leeren Seite
|
||||
geprüft worden war. Die Nachbildung enthielt die Korrektur, die ausgelieferte Datei nicht; der
|
||||
Befund war damit wertlos. Jetzt prüft der Browsertest die **echte** Verwaltungsseite: am
|
||||
Schreibtisch kein Schalter, Navigation sichtbar, der Eintrag „Seiten“ mit messbarer Fläche; auf
|
||||
390 px Breite ein Schalter, Liste zunächst zu, ein Klick öffnet sie. Gegenprobe gemacht – gegen
|
||||
die ausgelieferte `admin.css` schlägt der Test fehl.
|
||||
|
||||
**Geprüft.** `npm run test:editor` vollständig, `npm test` 28 von 28,
|
||||
`php vendor/bin/phpunit` 452 von 452.
|
||||
|
||||
## 1.6.0: der Aufgabenplan wird veröffentlicht
|
||||
|
||||
Diese Fassung bringt den **Aufgabenplan ohne Cronjob** (ADR 073, oben beschrieben) in ein
|
||||
Release. Er entstand in einer parallel laufenden Sitzung und lag bis hierher unveröffentlicht im
|
||||
Arbeitsverzeichnis – die Releases 1.5.0 bis 1.5.5 wurden bewusst aus dem jeweiligen Tag gebaut
|
||||
und enthielten ihn deshalb nicht.
|
||||
|
||||
**Vor der Veröffentlichung geprüft, nicht bloß eingepackt:**
|
||||
|
||||
- `php vendor/bin/phpunit` 452 von 452, `npm test` 28 von 28, `npm run test:editor` vollständig.
|
||||
- Die Migration `20260914000100` legt **nur** die Tabelle `scheduled_task` an und nimmt sie beim
|
||||
Zurückrollen wieder weg. Sie fasst keine bestehenden Daten an – das ist die risikoarme Sorte.
|
||||
- Seit einer Stunde keine Schreibzugriffe der anderen Sitzung mehr; der Stand ist ruhig.
|
||||
|
||||
**Dabei zwei Fehler behoben:**
|
||||
|
||||
- `config/services.yaml` verwendet `!tagged_iterator`. Das ist gültiges Symfony, aber
|
||||
`php bin/console lint:yaml config` kennt eigene Tags nur mit `--parse-tags` und gab 1 zurück –
|
||||
der CI-Lauf in `.github/workflows/ci.yml` wäre daran gescheitert. Das Flag ist ergänzt.
|
||||
- `src/EventSubscriber/AdminHeadersSubscriber.php` (aus 1.5.3) entsprach nicht dem Stil von
|
||||
`php-cs-fixer`; `composer lint` gab 8 zurück. Behoben, jetzt 0 von 128 Dateien zu ändern.
|
||||
|
||||
## Nächster konkreter Schritt
|
||||
|
||||
Den Editor mit einem Screenreader prüfen und die übrigen Verwaltungsbildschirme in die automatische Barrierefreiheitsprüfung aufnehmen. Danach der Rest von Arbeitspaket G (Inline-Ausbau, globale Bereiche für Header und Footer, wiederverwendbare Sektionen). Danach Arbeitspaket F aus `docs/AUSBAUPLAN.md`: Seiten umbenennen und verschieben mit Aktualisierung aller Kindpfade und automatischer Weiterleitung, frei wählbare Startseite, geplantes Veröffentlichen, Revisionsliste mit Wiederherstellung und ein Papierkorb statt sofortigem Löschen.
|
||||
Arbeitspaket H, Teil 3b: Vorschaubilder und Suche im Werkzeugkasten, Vorlagen je Element mit
|
||||
Vorschaubild und das Ziehen der Spaltenbreite in der Vorschau. Danach
|
||||
Arbeitspaket I: `banner`, `textbox`, `stack`, `imagebox`, `logo`, `messagebox`. Reihenfolge und
|
||||
Begründung in `docs/FLATSOME-ABGLEICH.md`.
|
||||
|
||||
Unverändert offen: den Editor mit einem Screenreader prüfen und die übrigen Verwaltungsbildschirme in die automatische Barrierefreiheitsprüfung aufnehmen. Danach der Rest von Arbeitspaket G (Inline-Ausbau, globale Bereiche für Header und Footer, wiederverwendbare Sektionen). Danach Arbeitspaket F aus `docs/AUSBAUPLAN.md`: Seiten umbenennen und verschieben mit Aktualisierung aller Kindpfade und automatischer Weiterleitung, frei wählbare Startseite, geplantes Veröffentlichen, Revisionsliste mit Wiederherstellung und ein Papierkorb statt sofortigem Löschen.
|
||||
|
||||
`kant-it.de` ist seit 12.09.2026 als Pilotwebsite bestimmt. Der öffentliche Bestand wurde erhoben (WordPress 7.1, Flatsome 3.20.9 mit eigenem Child-Theme, WPForms Lite, Complianz, Google-Rezensionen, eigenes Plugin `kant-nova-chat`) und die noch offene Arbeit daraus in `docs/AUSBAUPLAN.md` in elf Arbeitspakete aufgeschlüsselt. Erhoben wurde ausschließlich die öffentlich ausgelieferte Startseite; ohne Backendzugang sind Beitragsbestand, Formularkonfiguration, Benutzer und Flatsome-Shortcodes nicht inventarisierbar. Keine externe Veröffentlichung erfolgt.
|
||||
|
||||
|
||||
+51
-2
@@ -29,7 +29,7 @@ Jeder Schritt hat eine Abbruchbedingung. Bricht ein Schritt ab, bleibt der Wartu
|
||||
3. **Sichern.** Datenbank kopieren (bei SQLite Datei, bei MySQL `mysqldump`), dazu `.env` und den aktuellen Commit-Hash, nach `var/backups/update-<zeitstempel>/`. Der Medienordner wird **nicht** mitkopiert – er ist zu groß, und kein Update schreibt darin. Das steht auch so in der Oberfläche, damit niemand die Sicherung für vollständig hält.
|
||||
4. **Wartungszustand an.** Eine Datei `var/maintenance`. Öffentliche Anfragen antworten mit 503, einer erklärenden Seite und `Retry-After`; `/admin` bleibt erreichbar, sonst kann niemand das Update beenden. Suchmaschinen verstehen 503 als „später wieder“, nicht als „weg“.
|
||||
5. **Code wechseln.** `git checkout --force <tag>`. Danach prüfen, dass `VERSION` den erwarteten Wert hat – erst das beweist, dass der Wechsel wirklich stattgefunden hat.
|
||||
6. **Abhängigkeiten.** `composer install --no-dev --optimize-autoloader`, wenn Composer auf dem Server läuft. Wenn nicht – der wahrscheinliche Fall auf geteiltem Hosting – muss `vendor/` Teil des Tags sein. **Das ist die Entscheidung, die ansteht:** entweder Composer auf dem Server, oder ein Releasebranch, in dem `vendor/` mitgeliefert wird und den ein Build-Rechner erzeugt. Beides ist vertretbar, aber es muss vor dem ersten Livegang entschieden sein, weil der Updater danach anders aussieht.
|
||||
6. **Abhängigkeiten.** `composer install --no-dev --optimize-autoloader`, wenn Composer auf dem Server aufrufbar ist. Wenn nicht – der wahrscheinliche Fall auf geteiltem Hosting – gilt: **ändert dieses Update `composer.lock`, bricht der Updater hier ab** und verweist auf das Releasearchiv, das `vendor/` mitbringt (`scripts/build-release.php`). Ändert das Update die Abhängigkeiten nicht, läuft es ohne Composer durch. Der Vergleich läuft über die Prüfsumme der Datei vor und nach dem Checkout, nicht über Vertrauen.
|
||||
7. **Migrieren.** `doctrine-migrations migrate --no-interaction`. Die Migrationen sind bewusst nicht umkehrbar: `down()` würde Spalten entfernen, in denen Inhalte stehen. Der Rückweg ist die Sicherung aus Schritt 3, nicht ein `down()`.
|
||||
8. **Cache.** `cache:clear --env=prod` und Aufwärmen. Der Grund steht in `docs/QUALITY.md`: ein laufender Prozess kann einen älteren kompilierten Container halten und dann Seiten mit 500 beantworten, obwohl der Code richtig ist. Bei PHP-FPM gehört ein Neuladen dazu.
|
||||
9. **Rauchtest.** Intern `/`, `/admin/login` und `/sitemap.xml` abrufen und auf 200 prüfen, dazu eine Abfrage auf `page` und `revision`. Schlägt das fehl, gilt das Update als gescheitert, auch wenn jeder Schritt davor gemeldet hat, dass er fertig ist.
|
||||
@@ -62,9 +62,58 @@ Der teuerste Fall ist ein Update, das durchläuft und erst Tage später als fals
|
||||
- Kein `down()`-Rückweg für Migrationen. Der Rückweg ist die Sicherung, und das ist eine Entscheidung, nicht eine Lücke.
|
||||
- Kein Update ohne Sicherung, auch nicht mit Häkchen „ich weiß, was ich tue“.
|
||||
|
||||
## Der zweite Weg: Update aus dem Releasearchiv
|
||||
|
||||
Alles bis hierhin setzt eine Git-Arbeitskopie voraus. Auf geteiltem Hosting gibt es die nicht – dort wird über `install.php` aus dem Releasearchiv installiert, und danach stand in der Verwaltung „Dieses Verzeichnis ist keine Git-Arbeitskopie". **`src/Ops/ArchiveUpdate.php` ist der Weg für genau diese Installationen** (ADR 068). Er steht im Bildschirm „System" als eigene Karte neben dem Update über Git.
|
||||
|
||||
Der Ablauf ist derselbe wie oben, nur ohne `git`:
|
||||
|
||||
1. **Fassung ermitteln.** Die Releaseliste nennt den neuesten Tag. Antwortet sie nicht – viele Hoster lassen keine ausgehenden Verbindungen zu –, endet es hier, und es wurde nichts angefasst.
|
||||
2. **Sichern.** Datenbank und Medien nach `var/backups/`, vor dem ersten Handgriff.
|
||||
3. **Archiv laden und Prüfsumme vergleichen.** Stimmt sie nicht, wird nicht entpackt. Die Adresse steht in der Konfiguration, nicht in der Anfrage.
|
||||
4. **Entpacken und auf Vollständigkeit prüfen.** Fehlen `VERSION` oder `vendor/`, ist es kein brauchbares Release – und das fällt auf, bevor der Wartungszustand angeht.
|
||||
5. **Wartungszustand an, Dateien ersetzen.** `bin`, `config`, `migrations`, `src`, `templates`, `vendor`, `composer.json`, `composer.lock`, `migrations.php`, `cli-config.php`, `VERSION`. Der alte Stand wandert nach `var/update/<zeitstempel>/alt/`, er wird nicht gelöscht.
|
||||
6. **`public/` wird zusammengeführt, nicht ersetzt.** Dort liegen Dateien der Anwendung und Dateien des Betreibers nebeneinander: `assets/fonts` (hochgeladene Schriften) und `assets/build` (das erzeugte Stylesheet-Bündel) bleiben unangetastet.
|
||||
7. **Zwischenspeicher leeren.**
|
||||
8. **Zweiter Knopf: Migrationen.** Sie laufen in einer **eigenen Anfrage**, weil ein PHP-Prozess, der sich selbst ausgetauscht hat, mit den Klassen weiterarbeitet, die er beim Start geladen hat. Erst danach geht der Wartungszustand aus.
|
||||
|
||||
Unberührt bleiben in jedem Fall `var/`, `.env` und `.env.local`. Scheitert das Ersetzen mittendrin, schiebt der Updater zurück, was er bewegt hat, und nimmt den Wartungszustand wieder heraus.
|
||||
|
||||
**Geprüft wird ohne Netz** (`tests/ArchiveUpdateTest.php`): eine Releaseseite im Speicher, echte Zip-Archive, echte Prüfsummen, echte Umbenennungen in einem Wegwerfverzeichnis. Ein Test hält zusätzlich die Ersetzungsliste gegen die Mitnahmeliste von `scripts/build-release.php`: Was neu ins Archiv kommt, muss beim Update auch ersetzt werden.
|
||||
|
||||
## Der dritte Weg: von Hand über FTP
|
||||
|
||||
Der Weg für den einen Fall, in dem die Karte im Bildschirm „System" noch nicht existiert: **das Update auf die Fassung, die sie mitbringt.** Eine Installation der Fassung 1.4.2 kennt sie nicht – sie muss einmal von Hand nachgezogen werden, danach gibt es den Knopf.
|
||||
|
||||
1. **Sichern.** Verwaltung → System → „Sicherung anlegen". Zusätzlich per FTP `var/` und `.env` herunterladen. Migrationen sind nicht umkehrbar; der Rückweg ist die Sicherung.
|
||||
2. **Archiv holen und auspacken.** `kantsites-<fassung>.zip` von der Releaseseite, auf dem eigenen Rechner entpacken. Darin liegt ein Ordner `kantsites-<fassung>/`.
|
||||
3. **Wartungszustand einschalten.** Verwaltung → System → „Wartung". Sonst sieht ein Besucher währenddessen eine halb ersetzte Anwendung.
|
||||
4. **Diese Einträge hochladen und ersetzen:** `bin`, `config`, `migrations`, `src`, `templates`, `vendor`, `composer.json`, `composer.lock`, `migrations.php`, `cli-config.php`, `VERSION`. Verzeichnisse ersetzen, nicht hineinkopieren – eine Datei, die es in der neuen Fassung nicht mehr gibt, muss weg.
|
||||
5. **`public/` Datei für Datei** – und **`public/assets/fonts` und `public/assets/build` auslassen**. Dort liegen die hochgeladenen Schriften und das erzeugte Stylesheet-Bündel; beide gehören dem Betreiber und stehen in keinem Archiv.
|
||||
6. **Nicht anfassen:** `var/`, `.env`, `.env.local`. Dort stecken Inhalte, Medien und `APP_SECRET`.
|
||||
7. **`var/cache` vollständig löschen.** Sonst läuft die neue Fassung mit den Wegweisern der alten und antwortet mit 500, obwohl der Code richtig ist.
|
||||
8. **Migrationen ausführen.** Verwaltung → System → Karte „Migrationen stehen aus" → Knopf. Die Karte erscheint nur, wenn welche ausstehen. Wer eine Konsole hat, kann stattdessen `php vendor/bin/doctrine-migrations migrate --no-interaction` aufrufen.
|
||||
9. **Wartungszustand aus**, Startseite und Verwaltung einmal aufrufen, Fassungsnummer im Bildschirm „System" prüfen.
|
||||
|
||||
Das ist einmalig: Ab 1.4.3 kennt jede Installation den Weg über das Archiv, und dann entfällt das alles. Dann steht im selben Bildschirm die Karte „Update aus dem Releasearchiv", und die Schritte 2 bis 8 erledigt sie selbst.
|
||||
|
||||
## Ein Release veröffentlichen
|
||||
|
||||
`php scripts/build-release.php` baut auf einem Rechner mit Composer das Archiv – Anwendung plus `vendor/` ohne Entwicklungspakete – und schreibt die SHA-256-Summe in eine Kopie von `install.php`. Beides gehört anschließend an das Git-Release des passenden Tags:
|
||||
|
||||
```
|
||||
var/releases/kantsites-<fassung>.zip → Release-Anhang
|
||||
var/releases/kantsites-<fassung>.zip.sha256 → Release-Anhang
|
||||
var/releases/install.php → Release-Anhang
|
||||
```
|
||||
|
||||
`install.php` erwartet das Archiv unter `https://git.kant-it.de/kantarci/KantSites/releases/download/v<fassung>/kantsites-<fassung>.zip`. Genau dort landet es, wenn es als Anhang am Release dieses Tags hängt – eine andere Adresse lässt sich beim Bauen mit `--url=` setzen.
|
||||
|
||||
**Ein privates Repository liefert diese Adresse nicht aus.** Ohne Anmeldung antwortet sie mit 404, und `install.php` kann nichts herunterladen; das ist der Zweck der Prüfsumme, nicht ihr Fehler. Damit die Installation auf einem fremden Hosting funktioniert, müssen Archiv und Installer ohne Anmeldung erreichbar sein – entweder weil das Repository öffentlich ist, oder weil beide Dateien auf einem eigenen Webspace liegen und das Release mit `--url=` darauf gebaut wurde.
|
||||
|
||||
## Offen, bevor das gebaut wird
|
||||
|
||||
1. **Composer auf dem Server oder `vendor/` im Tag?** Hängt am Hosting aus Arbeitspaket J.
|
||||
1. ~~**Composer auf dem Server oder `vendor/` im Tag?**~~ Entschieden: `vendor/` gehört nicht ins Repository, sondern in ein Releasearchiv, das ein Build-Rechner erzeugt (`scripts/build-release.php`, SHA-256 daneben). Der Updater über Git bleibt für Installationen mit Composer und für Updates ohne Abhängigkeitsänderung.
|
||||
2. **Wie kommt der Code auf den Server?** Git-Klon im Projektstamm setzt Shell-Zugriff voraus. Gibt es den nicht, ist der Updater in dieser Form nicht möglich, und der Weg ist ein Deployment von außen (rsync eines Releaseverzeichnisses, Umschalten eines Symlinks). Das ist derselbe Ablauf, nur mit einem anderen Schritt 5.
|
||||
3. **Ein Repository oder zwei?** Für den Piloten reicht eines. Sobald mehrere Websites dasselbe CMS nutzen, gehört der Code in ein eigenes Repository und die Inhalte bleiben je Installation.
|
||||
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
[
|
||||
{
|
||||
"path": "/",
|
||||
"profile": "mobile",
|
||||
"scores": {
|
||||
"performance": 0.99,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"settings": {
|
||||
"output": "json",
|
||||
"maxWaitForFcp": 30000,
|
||||
"maxWaitForLoad": 45000,
|
||||
"pauseAfterFcpMs": 1000,
|
||||
"pauseAfterLoadMs": 1000,
|
||||
"networkQuietThresholdMs": 1000,
|
||||
"cpuQuietThresholdMs": 1000,
|
||||
"formFactor": "mobile",
|
||||
"throttling": {
|
||||
"rttMs": 150,
|
||||
"throughputKbps": 1638.4,
|
||||
"requestLatencyMs": 562.5,
|
||||
"downloadThroughputKbps": 1474.5600000000002,
|
||||
"uploadThroughputKbps": 675,
|
||||
"cpuSlowdownMultiplier": 4
|
||||
},
|
||||
"throttlingMethod": "simulate",
|
||||
"screenEmulation": {
|
||||
"mobile": true,
|
||||
"width": 412,
|
||||
"height": 823,
|
||||
"deviceScaleFactor": 1.75,
|
||||
"disabled": false
|
||||
},
|
||||
"emulatedUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36",
|
||||
"auditMode": false,
|
||||
"gatherMode": false,
|
||||
"clearStorageTypes": [
|
||||
"file_systems",
|
||||
"shader_cache",
|
||||
"service_workers",
|
||||
"cache_storage"
|
||||
],
|
||||
"disableStorageReset": false,
|
||||
"debugNavigation": false,
|
||||
"channel": "node",
|
||||
"usePassiveGathering": false,
|
||||
"disableFullPageScreenshot": false,
|
||||
"skipAboutBlank": false,
|
||||
"blankPage": "about:blank",
|
||||
"ignoreStatusCode": false,
|
||||
"locale": "en-US",
|
||||
"blockedUrlPatterns": null,
|
||||
"additionalTraceCategories": null,
|
||||
"extraHeaders": null,
|
||||
"precomputedLanternData": null,
|
||||
"onlyAudits": null,
|
||||
"onlyCategories": null,
|
||||
"skipAudits": null
|
||||
},
|
||||
"environment": {
|
||||
"networkUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Mobile Safari/537.36",
|
||||
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||
"benchmarkIndex": 5208.5,
|
||||
"credits": {
|
||||
"axe-core": "4.13.0"
|
||||
}
|
||||
},
|
||||
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||
},
|
||||
{
|
||||
"path": "/",
|
||||
"profile": "desktop",
|
||||
"scores": {
|
||||
"performance": 0.97,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"settings": {
|
||||
"output": "json",
|
||||
"maxWaitForFcp": 30000,
|
||||
"maxWaitForLoad": 45000,
|
||||
"pauseAfterFcpMs": 1000,
|
||||
"pauseAfterLoadMs": 1000,
|
||||
"networkQuietThresholdMs": 1000,
|
||||
"cpuQuietThresholdMs": 1000,
|
||||
"formFactor": "desktop",
|
||||
"throttling": {
|
||||
"rttMs": 40,
|
||||
"throughputKbps": 10240,
|
||||
"requestLatencyMs": 0,
|
||||
"downloadThroughputKbps": 0,
|
||||
"uploadThroughputKbps": 0,
|
||||
"cpuSlowdownMultiplier": 1
|
||||
},
|
||||
"throttlingMethod": "simulate",
|
||||
"screenEmulation": {
|
||||
"mobile": false,
|
||||
"width": 1350,
|
||||
"height": 940,
|
||||
"deviceScaleFactor": 1,
|
||||
"disabled": false
|
||||
},
|
||||
"emulatedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||
"auditMode": false,
|
||||
"gatherMode": false,
|
||||
"clearStorageTypes": [
|
||||
"file_systems",
|
||||
"shader_cache",
|
||||
"service_workers",
|
||||
"cache_storage"
|
||||
],
|
||||
"disableStorageReset": false,
|
||||
"debugNavigation": false,
|
||||
"channel": "node",
|
||||
"usePassiveGathering": false,
|
||||
"disableFullPageScreenshot": false,
|
||||
"skipAboutBlank": false,
|
||||
"blankPage": "about:blank",
|
||||
"ignoreStatusCode": false,
|
||||
"locale": "en-US",
|
||||
"blockedUrlPatterns": null,
|
||||
"additionalTraceCategories": null,
|
||||
"extraHeaders": null,
|
||||
"precomputedLanternData": null,
|
||||
"onlyAudits": null,
|
||||
"onlyCategories": null,
|
||||
"skipAudits": null
|
||||
},
|
||||
"environment": {
|
||||
"networkUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
|
||||
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||
"benchmarkIndex": 5277.5,
|
||||
"credits": {
|
||||
"axe-core": "4.13.0"
|
||||
}
|
||||
},
|
||||
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||
},
|
||||
{
|
||||
"path": "/projekte",
|
||||
"profile": "mobile",
|
||||
"scores": {
|
||||
"performance": 1,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"settings": {
|
||||
"output": "json",
|
||||
"maxWaitForFcp": 30000,
|
||||
"maxWaitForLoad": 45000,
|
||||
"pauseAfterFcpMs": 1000,
|
||||
"pauseAfterLoadMs": 1000,
|
||||
"networkQuietThresholdMs": 1000,
|
||||
"cpuQuietThresholdMs": 1000,
|
||||
"formFactor": "mobile",
|
||||
"throttling": {
|
||||
"rttMs": 150,
|
||||
"throughputKbps": 1638.4,
|
||||
"requestLatencyMs": 562.5,
|
||||
"downloadThroughputKbps": 1474.5600000000002,
|
||||
"uploadThroughputKbps": 675,
|
||||
"cpuSlowdownMultiplier": 4
|
||||
},
|
||||
"throttlingMethod": "simulate",
|
||||
"screenEmulation": {
|
||||
"mobile": true,
|
||||
"width": 412,
|
||||
"height": 823,
|
||||
"deviceScaleFactor": 1.75,
|
||||
"disabled": false
|
||||
},
|
||||
"emulatedUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36",
|
||||
"auditMode": false,
|
||||
"gatherMode": false,
|
||||
"clearStorageTypes": [
|
||||
"file_systems",
|
||||
"shader_cache",
|
||||
"service_workers",
|
||||
"cache_storage"
|
||||
],
|
||||
"disableStorageReset": false,
|
||||
"debugNavigation": false,
|
||||
"channel": "node",
|
||||
"usePassiveGathering": false,
|
||||
"disableFullPageScreenshot": false,
|
||||
"skipAboutBlank": false,
|
||||
"blankPage": "about:blank",
|
||||
"ignoreStatusCode": false,
|
||||
"locale": "en-US",
|
||||
"blockedUrlPatterns": null,
|
||||
"additionalTraceCategories": null,
|
||||
"extraHeaders": null,
|
||||
"precomputedLanternData": null,
|
||||
"onlyAudits": null,
|
||||
"onlyCategories": null,
|
||||
"skipAudits": null
|
||||
},
|
||||
"environment": {
|
||||
"networkUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Mobile Safari/537.36",
|
||||
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||
"benchmarkIndex": 5231,
|
||||
"credits": {
|
||||
"axe-core": "4.13.0"
|
||||
}
|
||||
},
|
||||
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||
},
|
||||
{
|
||||
"path": "/projekte",
|
||||
"profile": "desktop",
|
||||
"scores": {
|
||||
"performance": 1,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"settings": {
|
||||
"output": "json",
|
||||
"maxWaitForFcp": 30000,
|
||||
"maxWaitForLoad": 45000,
|
||||
"pauseAfterFcpMs": 1000,
|
||||
"pauseAfterLoadMs": 1000,
|
||||
"networkQuietThresholdMs": 1000,
|
||||
"cpuQuietThresholdMs": 1000,
|
||||
"formFactor": "desktop",
|
||||
"throttling": {
|
||||
"rttMs": 40,
|
||||
"throughputKbps": 10240,
|
||||
"requestLatencyMs": 0,
|
||||
"downloadThroughputKbps": 0,
|
||||
"uploadThroughputKbps": 0,
|
||||
"cpuSlowdownMultiplier": 1
|
||||
},
|
||||
"throttlingMethod": "simulate",
|
||||
"screenEmulation": {
|
||||
"mobile": false,
|
||||
"width": 1350,
|
||||
"height": 940,
|
||||
"deviceScaleFactor": 1,
|
||||
"disabled": false
|
||||
},
|
||||
"emulatedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||
"auditMode": false,
|
||||
"gatherMode": false,
|
||||
"clearStorageTypes": [
|
||||
"file_systems",
|
||||
"shader_cache",
|
||||
"service_workers",
|
||||
"cache_storage"
|
||||
],
|
||||
"disableStorageReset": false,
|
||||
"debugNavigation": false,
|
||||
"channel": "node",
|
||||
"usePassiveGathering": false,
|
||||
"disableFullPageScreenshot": false,
|
||||
"skipAboutBlank": false,
|
||||
"blankPage": "about:blank",
|
||||
"ignoreStatusCode": false,
|
||||
"locale": "en-US",
|
||||
"blockedUrlPatterns": null,
|
||||
"additionalTraceCategories": null,
|
||||
"extraHeaders": null,
|
||||
"precomputedLanternData": null,
|
||||
"onlyAudits": null,
|
||||
"onlyCategories": null,
|
||||
"skipAudits": null
|
||||
},
|
||||
"environment": {
|
||||
"networkUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
|
||||
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||
"benchmarkIndex": 5272.5,
|
||||
"credits": {
|
||||
"axe-core": "4.13.0"
|
||||
}
|
||||
},
|
||||
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||
},
|
||||
{
|
||||
"path": "/impressum",
|
||||
"profile": "mobile",
|
||||
"scores": {
|
||||
"performance": 1,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"settings": {
|
||||
"output": "json",
|
||||
"maxWaitForFcp": 30000,
|
||||
"maxWaitForLoad": 45000,
|
||||
"pauseAfterFcpMs": 1000,
|
||||
"pauseAfterLoadMs": 1000,
|
||||
"networkQuietThresholdMs": 1000,
|
||||
"cpuQuietThresholdMs": 1000,
|
||||
"formFactor": "mobile",
|
||||
"throttling": {
|
||||
"rttMs": 150,
|
||||
"throughputKbps": 1638.4,
|
||||
"requestLatencyMs": 562.5,
|
||||
"downloadThroughputKbps": 1474.5600000000002,
|
||||
"uploadThroughputKbps": 675,
|
||||
"cpuSlowdownMultiplier": 4
|
||||
},
|
||||
"throttlingMethod": "simulate",
|
||||
"screenEmulation": {
|
||||
"mobile": true,
|
||||
"width": 412,
|
||||
"height": 823,
|
||||
"deviceScaleFactor": 1.75,
|
||||
"disabled": false
|
||||
},
|
||||
"emulatedUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36",
|
||||
"auditMode": false,
|
||||
"gatherMode": false,
|
||||
"clearStorageTypes": [
|
||||
"file_systems",
|
||||
"shader_cache",
|
||||
"service_workers",
|
||||
"cache_storage"
|
||||
],
|
||||
"disableStorageReset": false,
|
||||
"debugNavigation": false,
|
||||
"channel": "node",
|
||||
"usePassiveGathering": false,
|
||||
"disableFullPageScreenshot": false,
|
||||
"skipAboutBlank": false,
|
||||
"blankPage": "about:blank",
|
||||
"ignoreStatusCode": false,
|
||||
"locale": "en-US",
|
||||
"blockedUrlPatterns": null,
|
||||
"additionalTraceCategories": null,
|
||||
"extraHeaders": null,
|
||||
"precomputedLanternData": null,
|
||||
"onlyAudits": null,
|
||||
"onlyCategories": null,
|
||||
"skipAudits": null
|
||||
},
|
||||
"environment": {
|
||||
"networkUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Mobile Safari/537.36",
|
||||
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||
"benchmarkIndex": 5205,
|
||||
"credits": {
|
||||
"axe-core": "4.13.0"
|
||||
}
|
||||
},
|
||||
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||
},
|
||||
{
|
||||
"path": "/impressum",
|
||||
"profile": "desktop",
|
||||
"scores": {
|
||||
"performance": 1,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"settings": {
|
||||
"output": "json",
|
||||
"maxWaitForFcp": 30000,
|
||||
"maxWaitForLoad": 45000,
|
||||
"pauseAfterFcpMs": 1000,
|
||||
"pauseAfterLoadMs": 1000,
|
||||
"networkQuietThresholdMs": 1000,
|
||||
"cpuQuietThresholdMs": 1000,
|
||||
"formFactor": "desktop",
|
||||
"throttling": {
|
||||
"rttMs": 40,
|
||||
"throughputKbps": 10240,
|
||||
"requestLatencyMs": 0,
|
||||
"downloadThroughputKbps": 0,
|
||||
"uploadThroughputKbps": 0,
|
||||
"cpuSlowdownMultiplier": 1
|
||||
},
|
||||
"throttlingMethod": "simulate",
|
||||
"screenEmulation": {
|
||||
"mobile": false,
|
||||
"width": 1350,
|
||||
"height": 940,
|
||||
"deviceScaleFactor": 1,
|
||||
"disabled": false
|
||||
},
|
||||
"emulatedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||
"auditMode": false,
|
||||
"gatherMode": false,
|
||||
"clearStorageTypes": [
|
||||
"file_systems",
|
||||
"shader_cache",
|
||||
"service_workers",
|
||||
"cache_storage"
|
||||
],
|
||||
"disableStorageReset": false,
|
||||
"debugNavigation": false,
|
||||
"channel": "node",
|
||||
"usePassiveGathering": false,
|
||||
"disableFullPageScreenshot": false,
|
||||
"skipAboutBlank": false,
|
||||
"blankPage": "about:blank",
|
||||
"ignoreStatusCode": false,
|
||||
"locale": "en-US",
|
||||
"blockedUrlPatterns": null,
|
||||
"additionalTraceCategories": null,
|
||||
"extraHeaders": null,
|
||||
"precomputedLanternData": null,
|
||||
"onlyAudits": null,
|
||||
"onlyCategories": null,
|
||||
"skipAudits": null
|
||||
},
|
||||
"environment": {
|
||||
"networkUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
|
||||
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||
"benchmarkIndex": 5179.5,
|
||||
"credits": {
|
||||
"axe-core": "4.13.0"
|
||||
}
|
||||
},
|
||||
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,106 @@
|
||||
[
|
||||
{
|
||||
"path": "/",
|
||||
"profile": "mobile",
|
||||
"scores": {
|
||||
"performance": 0.98,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
|
||||
},
|
||||
{
|
||||
"path": "/",
|
||||
"profile": "desktop",
|
||||
"scores": {
|
||||
"performance": 0.96,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
|
||||
},
|
||||
{
|
||||
"path": "/projekte",
|
||||
"profile": "mobile",
|
||||
"scores": {
|
||||
"performance": 1,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
|
||||
},
|
||||
{
|
||||
"path": "/projekte",
|
||||
"profile": "desktop",
|
||||
"scores": {
|
||||
"performance": 1,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
|
||||
},
|
||||
{
|
||||
"path": "/simplescan",
|
||||
"profile": "mobile",
|
||||
"scores": {
|
||||
"performance": 1,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
|
||||
},
|
||||
{
|
||||
"path": "/simplescan",
|
||||
"profile": "desktop",
|
||||
"scores": {
|
||||
"performance": 1,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
|
||||
},
|
||||
{
|
||||
"path": "/impressum",
|
||||
"profile": "mobile",
|
||||
"scores": {
|
||||
"performance": 1,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
|
||||
},
|
||||
{
|
||||
"path": "/impressum",
|
||||
"profile": "desktop",
|
||||
"scores": {
|
||||
"performance": 1,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260913000100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'A separate title for search results, independent of the page title.';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Der Titel in der Suche muss nicht der Titel der Seite sein: auf der Seite steht
|
||||
// "Leistungen", im Suchergebnis hilft "IT-Leistungen für Mittelbaden – Kant-IT". NULL
|
||||
// heisst: der Titel des Dokuments gilt, wie bisher.
|
||||
$schema->getTable('page')->addColumn('seo_title', 'string', ['length' => 160, 'notnull' => false]);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->throwIrreversibleMigrationException('Dropping this would silently discard every title written for search results. Restore a verified backup with matching code.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260914000100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Der Aufgabenplan: eine Zeile je wiederkehrender Aufgabe, mit Faelligkeit, Sperre und letztem Ergebnis.';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Warum eine Tabelle und keine Datei: die Sperre. Zwei Besucher koennen im selben
|
||||
// Moment dieselbe Aufgabe faellig sehen, und nur einer darf sie ausfuehren. Ein
|
||||
// bedingtes UPDATE ist auf MySQL wie auf SQLite atomar; zwei Prozesse, die eine Datei
|
||||
// sperren, sind es auf geteiltem Hosting nicht zuverlaessig.
|
||||
//
|
||||
// Die Tabelle bleibt klein - eine Zeile je registrierter Aufgabe, heute fuenf. Deshalb
|
||||
// steht hier kein Index: ein voller Durchlauf ueber fuenf Zeilen ist billiger als ein
|
||||
// Index, den jede Aenderung mitpflegen muss.
|
||||
$tasks = $schema->createTable('scheduled_task');
|
||||
// Der technische Name aus dem Code, zum Beispiel `publish-due`. Er ist der Schluessel:
|
||||
// verschwindet eine Aufgabe aus dem Code, verschwindet ihre Zeile beim naechsten Abgleich.
|
||||
$tasks->addColumn('name', 'string', ['length' => 64]);
|
||||
// Der Abstand zwischen zwei Laeufen. Kommt als Vorgabe aus dem Code und ist in der
|
||||
// Verwaltung aenderbar, weil eine taegliche Sicherung auf der einen Installation
|
||||
// richtig und auf der naechsten zu selten ist.
|
||||
$tasks->addColumn('interval_seconds', 'integer', ['default' => 3600]);
|
||||
$tasks->addColumn('enabled', 'boolean', ['default' => true]);
|
||||
// Alle Zeitpunkte in UTC, als Zeichenkette verglichen - wie ueberall sonst im Projekt.
|
||||
$tasks->addColumn('next_run_at', 'datetime_immutable');
|
||||
$tasks->addColumn('last_run_at', 'datetime_immutable', ['notnull' => false]);
|
||||
// Bis wann diese Aufgabe als "laeuft gerade" gilt. Danach darf sie ein anderer
|
||||
// Durchlauf uebernehmen: ein Prozess, den das Hosting nach 30 Sekunden abgeschossen
|
||||
// hat, kann seine Sperre nicht mehr selbst aufraeumen.
|
||||
$tasks->addColumn('locked_until', 'datetime_immutable', ['notnull' => false]);
|
||||
$tasks->addColumn('last_status', 'string', ['length' => 16, 'notnull' => false]);
|
||||
$tasks->addColumn('last_message', 'string', ['length' => 500, 'default' => '']);
|
||||
$tasks->addColumn('last_duration_ms', 'integer', ['default' => 0]);
|
||||
// Wodurch der letzte Lauf ausgeloest wurde: besuch, verwaltung, cron, konsole, hand.
|
||||
// Steht hier, weil die erste Frage bei einer Stoerung immer dieselbe ist: laeuft der
|
||||
// Ausloeser ueberhaupt, den der Betreiber eingerichtet zu haben glaubt?
|
||||
$tasks->addColumn('last_trigger', 'string', ['length' => 16, 'default' => '']);
|
||||
// Aufeinanderfolgende Fehlschlaege. Sie verlaengern den Abstand bis zum naechsten
|
||||
// Versuch, damit eine Aufgabe, die nicht laufen kann, nicht bei jedem Seitenaufruf
|
||||
// wieder anfaengt.
|
||||
$tasks->addColumn('failures', 'integer', ['default' => 0]);
|
||||
$tasks->setPrimaryKey(['name']);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Faellt die Tabelle, faellt nur der Plan: Zeitpunkte und letzte Meldungen. Kein Inhalt,
|
||||
// kein Konto, keine Datei. Beim naechsten Start legt der Abgleich sie neu an.
|
||||
$schema->dropTable('scheduled_task');
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"test:editor": "node scripts/editor-check.mjs",
|
||||
"test:browser": "node scripts/browser-check.mjs",
|
||||
"test:links": "node scripts/link-check.mjs",
|
||||
"audit": "node scripts/audit.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,77 @@
|
||||
DirectoryIndex index.php
|
||||
Options -Indexes -MultiViews
|
||||
|
||||
# Alles, was keine Datei ist, beantwortet PHP.
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
|
||||
# HTTPS erzwingen. Auskommentiert, weil diese Datei auch in Installationen ohne Zertifikat
|
||||
# liegt und eine Endlosschleife schlimmer waere als fehlendes HTTPS. Vor dem Livegang mit
|
||||
# Zertifikat: die drei Zeilen einschalten - und erst danach die HSTS-Zeile weiter unten,
|
||||
# denn HSTS laesst sich in einem Browser ein halbes Jahr lang nicht zuruecknehmen.
|
||||
#RewriteCond %{HTTPS} !=on
|
||||
#RewriteCond %{HTTP:X-Forwarded-Proto} !=https
|
||||
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
|
||||
|
||||
# Eine Domain, eine Adresse. Ohne diese Umleitung ist dieselbe Seite unter www und ohne www
|
||||
# erreichbar - zwei Adressen fuer denselben Inhalt, und Suchmaschinen muessen raten, welche gilt.
|
||||
# Die Richtung muss zu SITE_ORIGIN passen; hier die Fassung "immer mit www".
|
||||
#RewriteCond %{HTTP_HOST} !^www\. [NC]
|
||||
#RewriteCond %{HTTP_HOST} !^localhost [NC]
|
||||
#RewriteCond %{HTTP_HOST} !^127\.0\.0\.1 [NC]
|
||||
#RewriteRule ^ https://www.%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
|
||||
|
||||
# Uebertragen wird komprimiert. HTML, CSS, JavaScript und XML schrumpfen dabei auf etwa ein
|
||||
# Viertel; Bilder und Schriften sind bereits komprimiert und bleiben aussen vor.
|
||||
<IfModule mod_deflate.c>
|
||||
AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css
|
||||
AddOutputFilterByType DEFLATE application/javascript application/json application/xml
|
||||
AddOutputFilterByType DEFLATE application/rss+xml image/svg+xml
|
||||
</IfModule>
|
||||
<IfModule mod_brotli.c>
|
||||
AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript image/svg+xml
|
||||
</IfModule>
|
||||
|
||||
# Wie lange ein Browser eine Datei behalten darf.
|
||||
#
|
||||
# Bilder und Schriften tragen ihre Kennung im Namen: aendert sich der Inhalt, aendert sich die
|
||||
# Adresse. Sie duerfen deshalb ein Jahr bleiben. Stylesheets und Skripte tragen die Fassung als
|
||||
# Parameter, also ebenfalls ein Jahr. HTML wird nie zwischengespeichert - eine veroeffentlichte
|
||||
# Aenderung soll beim naechsten Aufruf sichtbar sein; die Seiten senden dafuer selbst no-store.
|
||||
<IfModule mod_expires.c>
|
||||
ExpiresActive On
|
||||
ExpiresByType image/webp "access plus 1 year"
|
||||
ExpiresByType image/png "access plus 1 year"
|
||||
ExpiresByType image/jpeg "access plus 1 year"
|
||||
ExpiresByType image/svg+xml "access plus 1 year"
|
||||
ExpiresByType font/woff2 "access plus 1 year"
|
||||
ExpiresByType text/css "access plus 1 year"
|
||||
ExpiresByType application/javascript "access plus 1 year"
|
||||
</IfModule>
|
||||
<IfModule mod_headers.c>
|
||||
<FilesMatch "\.(webp|png|jpe?g|gif|svg|woff2|css|js)$">
|
||||
Header set Cache-Control "public, max-age=31536000, immutable"
|
||||
</FilesMatch>
|
||||
# Schriften werden von derselben Adresse geladen; die Regel steht hier, damit sie auch dann
|
||||
# gilt, wenn eine Installation die Dateien ueber ein CDN spiegelt.
|
||||
<FilesMatch "\.woff2$">
|
||||
Header set Access-Control-Allow-Origin "*"
|
||||
</FilesMatch>
|
||||
# Was der Server ueber sich selbst erzaehlt, geht niemanden etwas an: die genaue PHP-Fassung
|
||||
# ist fuer einen Besucher wertlos und fuer einen Scanner eine Einkaufsliste.
|
||||
Header always unset X-Powered-By
|
||||
Header always unset X-AspNet-Version
|
||||
# Erst einschalten, wenn HTTPS steht und bleiben soll (siehe oben).
|
||||
#Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
</IfModule>
|
||||
|
||||
# Nichts ausliefern, was nur intern gebraucht wird - falls jemand das Paket versehentlich
|
||||
# vollstaendig in den oeffentlichen Ordner legt statt nur public/.
|
||||
<FilesMatch "^\.|\.(env|db|sqlite|lock|md|yaml|yml|dist|log)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
# Eine Ausnahme mit Grund: der Editor liest die Liste der Gestaltungswerte als JSON.
|
||||
<Files "design-tokens.json">
|
||||
Require all granted
|
||||
</Files>
|
||||
+105
-1
@@ -144,7 +144,7 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
|
||||
.nav-item:hover { background: #21362d }
|
||||
.nav-item[aria-current=page] { background: #2b4a3b; color: #fff; font-weight: 650 }
|
||||
.nav-item[aria-current=page] .nav-icon { color: var(--mint) }
|
||||
.nav-icon { width: 19px; text-align: center; font-size: 14px; color: var(--shell-muted); flex-shrink: 0 }
|
||||
.nav-icon { width: 18px; height: 18px; color: var(--shell-muted); flex-shrink: 0 }
|
||||
.nav-out::after { content: '↗'; margin-left: auto; color: var(--shell-muted); font-size: 11px }
|
||||
|
||||
.shell-foot { margin-top: auto; padding-top: 20px; border-top: 1px solid #2a3d35; display: grid; gap: 6px }
|
||||
@@ -446,6 +446,11 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.theme-table td input[type=color] { width: 52px }
|
||||
.theme-table .weights-cell { white-space: nowrap }
|
||||
.redirect-table td { padding: 8px 12px 8px 0; font-size: 12px }
|
||||
/* Der Aufgabenplan: die erste Spalte traegt Text, die schmalen tragen Zahlen und Schalter. */
|
||||
.tasks-table td:first-child { min-width: 220px; max-width: 340px }
|
||||
.tasks-table td input[type=number] { width: 92px }
|
||||
.tasks-table td .field-help { margin-top: 4px }
|
||||
.tasks-table .row-actions, .tasks-table td:last-child { white-space: nowrap }
|
||||
/* Headings inside a card separate its parts without a second border. */
|
||||
.card-body .section-head { margin: 28px 0 12px }
|
||||
.card-body .section-head:first-child { margin-top: 14px }
|
||||
@@ -601,3 +606,102 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.log-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 6px }
|
||||
.log-list code { font-size: 11px; overflow-wrap: anywhere }
|
||||
.shell-version { margin: 0 0 4px; padding: 0 10px; font-size: 10px; color: var(--shell-muted) }
|
||||
|
||||
/* ---- the installer: six screens on an otherwise empty server ---------------------------- */
|
||||
|
||||
.install-body {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: start center;
|
||||
background: radial-gradient(ellipse at 20% 0%, #dfeab0, transparent 60%), var(--ground);
|
||||
padding: 40px 20px 60px;
|
||||
}
|
||||
.install-shell { width: 100%; max-width: 640px }
|
||||
.install-shell .card { margin-top: 18px }
|
||||
.install-shell h1 { font-size: 24px }
|
||||
.install-shell form { margin-top: 4px }
|
||||
.install-shell .sheet-actions { margin-top: 24px }
|
||||
.install-shell h2 { font-size: 15px; margin: 30px 0 0 }
|
||||
|
||||
/* The step list is the only orientation there is: no navigation exists yet. */
|
||||
.install-steps {
|
||||
list-style: none;
|
||||
margin: 14px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.install-steps li { display: flex; align-items: center; gap: 7px }
|
||||
.install-steps .current { color: var(--ink); font-weight: 700 }
|
||||
.install-step-number {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 19px; height: 19px;
|
||||
border-radius: 50%;
|
||||
background: #e4e9df;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.install-steps .current .install-step-number { background: var(--accent); color: #fff }
|
||||
.install-steps .done .install-step-number { background: #e7f0d6; color: #2c5340 }
|
||||
|
||||
/* A finding that is worth saying but does not block anything. */
|
||||
.check-warn .check-mark { background: #f3f0e2; color: #6b6550 }
|
||||
|
||||
.button-link { text-decoration: none }
|
||||
.install-details { margin-top: 18px; border: 1px solid var(--line); border-radius: 10px; padding: 0 16px 16px }
|
||||
.install-details > summary { padding: 14px 0; font-size: 13px; font-weight: 600; cursor: pointer }
|
||||
.install-details[open] > summary { border-bottom: 1px solid var(--line); margin-bottom: 6px }
|
||||
|
||||
.favicon-field{display:flex;align-items:center;gap:12px}
|
||||
.favicon-field select{flex:1}
|
||||
.favicon-preview{border-radius:6px;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.12)}
|
||||
|
||||
.color-field { display: flex; gap: 8px; align-items: center }
|
||||
.color-field input[type="text"], .color-field > input:not([type="color"]) { flex: 1; min-width: 0 }
|
||||
.color-field-swatch { width: 46px; min-height: 38px; padding: 2px; flex: 0 0 auto; cursor: pointer }
|
||||
.color-field-palette { flex: 0 0 auto; max-width: 40% }
|
||||
/* Die Verwaltung auf dem Telefon.
|
||||
|
||||
Bis 1000px stand die Navigation als waagerechte Wischleiste da - zwei Listen untereinander,
|
||||
also zwei Zeilen, und der letzte Eintrag ausserhalb des Bildes. Auf einem Telefon ist ein
|
||||
Klappmenue ehrlicher: eine Zeile im Ruhezustand, aufgeklappt die vollstaendige Liste mit
|
||||
Flaechen, die ein Daumen trifft. Das details-Element macht das ohne Skript. */
|
||||
/* Der Schalter traegt seine Gestalt hier, nicht erst im Telefonabschnitt: am Schreibtisch
|
||||
wird er ausgeblendet, aber nur dort, wo der Inhalt sicher sichtbar ist. */
|
||||
.shell-menu > summary {
|
||||
display: flex; align-items: center; gap: 10px; list-style: none; cursor: pointer;
|
||||
min-height: 44px; padding: 0 14px; border-radius: 10px;
|
||||
background: #ffffff14; color: #fff; font-weight: 600; font-size: 15px;
|
||||
}
|
||||
.shell-menu > summary::-webkit-details-marker { display: none }
|
||||
.shell-menu > summary:focus-visible { outline: 3px solid var(--mint); outline-offset: 2px }
|
||||
.shell-menu[open] > summary { margin-bottom: 8px }
|
||||
.shell-menu-icon { font-size: 17px }
|
||||
/* Am Schreibtisch steht die Liste dauerhaft da. Ein geschlossenes details-Element versteckt
|
||||
seinen Inhalt aber von sich aus - der Schalter allein auszublenden loescht die Navigation.
|
||||
Erst ::details-content holt sie zurueck. Kennt ein Browser die Regel nicht, faellt die
|
||||
ganze Gruppe weg und der Schalter bleibt stehen: die Liste ist dann einen Klick entfernt
|
||||
statt gar nicht da. */
|
||||
@supports selector(::details-content) {
|
||||
.shell-menu > summary { display: none }
|
||||
.shell-menu::details-content { content-visibility: visible; block-size: auto }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.shell-nav { flex-wrap: wrap; padding: 10px 14px; gap: 10px; overflow: visible }
|
||||
.shell-menu { flex: 1 1 100%; order: 3 }
|
||||
.shell-menu > summary { display: flex }
|
||||
.shell-menu nav { display: block }
|
||||
.shell-menu:not([open]) nav { display: none }
|
||||
.shell-menu:not([open])::details-content { content-visibility: hidden; block-size: 0 }
|
||||
.nav-label { display: block; margin: 12px 4px 6px; font-size: 11px; letter-spacing: 1.4px; color: #9db3a8 }
|
||||
.nav-list { display: grid; gap: 2px }
|
||||
/* Vierundvierzig Pixel: die Flaeche, die ein Daumen zuverlaessig trifft. */
|
||||
.nav-item { min-height: 44px; display: flex; align-items: center; gap: 12px; padding: 10px 14px; white-space: normal }
|
||||
.shell-foot { margin-left: auto; border-left: 0; padding-left: 0; gap: 10px }
|
||||
.shell-version { display: none }
|
||||
.shell-user strong { display: none }
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import { blockLabel, locate, dropList, isContainer } from './editor-model.js';
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The layer above the preview.
|
||||
//
|
||||
// The preview is a sandboxed iframe without scripts of its own, and every change replaces its
|
||||
// whole document. The selection tools therefore live outside it and get rebound after a refresh.
|
||||
// Empty-container hit areas are rebuilt separately inside the frame by preview-containers.js.
|
||||
// The outline, element toolbar, insert points and drop marker live in the editor document, in an
|
||||
// absolutely positioned layer that sits exactly on top of the frame.
|
||||
//
|
||||
// Geometry: the layer is the same box as the frame and carries the same scale transform, so a
|
||||
// rectangle measured inside the frame can be used here unchanged. Measurements come from
|
||||
// getBoundingClientRect, which is relative to the frame's own viewport - that is why scrolling
|
||||
// the preview only needs a re-measure and no offset arithmetic.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Diameter of a plus sign, in the editor's own pixels. */
|
||||
const INSERT_SIZE = 22;
|
||||
|
||||
/**
|
||||
* What the toolbar offers, in the order it appears.
|
||||
*
|
||||
* Each label names the element it acts on - "Sektion duplizieren", not "Duplizieren". The
|
||||
* sidebar carries the same five actions, and two controls with the same name on one screen are
|
||||
* indistinguishable to anyone listening rather than looking. Naming the subject also says more
|
||||
* than the generic verb did: the toolbar floats next to the element, the announcement should
|
||||
* carry that context instead of assuming it was seen.
|
||||
*/
|
||||
const TOOLS = [
|
||||
{ action: 'parent', glyph: '⤴', title: label => `Element über ${label} auswählen` },
|
||||
{ action: 'up', glyph: '↑', title: label => `${label} nach oben verschieben` },
|
||||
{ action: 'down', glyph: '↓', title: label => `${label} nach unten verschieben` },
|
||||
{ action: 'duplicate', glyph: '⧉', title: label => `${label} duplizieren` },
|
||||
{ action: 'delete', glyph: '✕', title: label => `${label} löschen`, danger: true },
|
||||
];
|
||||
|
||||
export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSelected, onSelect, onAction, onInsert, onDrop }) {
|
||||
const layer = document.createElement('div');
|
||||
layer.className = 'canvas-overlay';
|
||||
const hover = element('div', 'ov-box ov-hover');
|
||||
const hoverLabel = element('span', 'ov-label');
|
||||
hover.append(hoverLabel);
|
||||
const select = element('div', 'ov-box ov-select');
|
||||
const selectName = document.createElement('span');
|
||||
selectName.className = 'ov-toolbar-name';
|
||||
selectName.setAttribute('aria-hidden', 'true');
|
||||
const toolbar = element('div', 'ov-toolbar');
|
||||
toolbar.removeAttribute('aria-hidden');
|
||||
toolbar.setAttribute('role', 'toolbar');
|
||||
toolbar.setAttribute('aria-label', 'Aktionen für das gewählte Element');
|
||||
const drop = element('div', 'ov-drop');
|
||||
const dropLabel = element('div', 'ov-drop-label');
|
||||
const insertBefore = insertButton('before', 'Element davor einfügen');
|
||||
const insertAfter = insertButton('after', 'Element dahinter einfügen');
|
||||
toolbar.append(selectName);
|
||||
layer.append(hover, select, toolbar, insertBefore, insertAfter, drop, dropLabel);
|
||||
stage.append(layer);
|
||||
|
||||
for (const tool of TOOLS) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `ov-tool${tool.danger ? ' ov-tool-danger' : ''}`;
|
||||
button.dataset.action = tool.action;
|
||||
button.textContent = tool.glyph;
|
||||
button.addEventListener('mousedown', event => event.preventDefault());
|
||||
button.addEventListener('click', () => onAction(tool.action, getSelected()));
|
||||
toolbar.append(button);
|
||||
}
|
||||
|
||||
/**
|
||||
* A part that only draws something. The whole layer must not be aria-hidden - it carries real
|
||||
* buttons, and a focusable control inside an aria-hidden subtree is exactly the contradiction
|
||||
* screen readers cannot resolve. So the decoration is hidden one piece at a time.
|
||||
*/
|
||||
function element(tag, className) {
|
||||
const node = document.createElement(tag);
|
||||
node.className = className;
|
||||
node.setAttribute('aria-hidden', 'true');
|
||||
node.hidden = true;
|
||||
return node;
|
||||
}
|
||||
|
||||
function insertButton(where, title) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'ov-insert';
|
||||
button.title = title;
|
||||
button.setAttribute('aria-label', title);
|
||||
button.textContent = '+';
|
||||
button.hidden = true;
|
||||
button.addEventListener('mousedown', event => event.preventDefault());
|
||||
button.addEventListener('click', () => onInsert(getSelected(), where));
|
||||
return button;
|
||||
}
|
||||
|
||||
let hovered = null, dragging = null, dropPlan = null;
|
||||
|
||||
const doc = () => frame.contentDocument;
|
||||
/** Is this id a block of the current document rather than some id in the markup? */
|
||||
const known = id => Boolean(id) && Boolean(locate(getDocument().blocks, id));
|
||||
|
||||
/** The nearest element that stands for a block of the document. */
|
||||
function blockElement(node) {
|
||||
for (let current = node; current && current !== doc(); current = current.parentElement) {
|
||||
if (current.id && known(current.id)) return current;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function rectOf(id) {
|
||||
const node = id ? doc()?.getElementById(id) : null;
|
||||
if (!node) return null;
|
||||
const box = node.getBoundingClientRect();
|
||||
return box.width || box.height ? box : null;
|
||||
}
|
||||
|
||||
/** Puts a box on a measured rectangle, or hides it when the block is not on screen. */
|
||||
function place(node, box, inset = 0) {
|
||||
if (!box) { node.hidden = true; return false; }
|
||||
node.hidden = false;
|
||||
node.style.left = `${box.left + inset}px`;
|
||||
node.style.top = `${box.top + inset}px`;
|
||||
node.style.width = `${Math.max(0, box.width - inset * 2)}px`;
|
||||
node.style.height = `${Math.max(0, box.height - inset * 2)}px`;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the chrome legible when the preview is scaled down. A tablet preview on a narrow
|
||||
* screen runs at about 0.6; without this the toolbar would shrink with it and the buttons
|
||||
* would fall under the size a finger or an eye can deal with.
|
||||
*/
|
||||
function counterScale(node, originX, originY) {
|
||||
const scale = getScale();
|
||||
node.style.transformOrigin = `${originX} ${originY}`;
|
||||
node.style.transform = scale < 1 ? `scale(${1 / scale})` : '';
|
||||
}
|
||||
|
||||
function positionToolbar(box, hasInsert) {
|
||||
if (!box) { toolbar.hidden = true; return; }
|
||||
toolbar.hidden = false;
|
||||
const scale = getScale();
|
||||
// The toolbar is drawn at its natural size and then counter-scaled, so the room it needs
|
||||
// inside the frame's coordinates shrinks as the preview shrinks.
|
||||
const needed = toolbar.offsetHeight / scale;
|
||||
// The upper plus sign straddles the top edge. On a narrow element - a button, a heading of
|
||||
// two words - it sits under the middle of the toolbar, so the toolbar makes room for it.
|
||||
const clearance = hasInsert ? INSERT_SIZE / scale / 2 : 0;
|
||||
const above = box.top >= needed + clearance + 2;
|
||||
toolbar.style.left = `${box.left}px`;
|
||||
toolbar.style.top = `${above
|
||||
? box.top - needed - clearance - 2
|
||||
: Math.min(box.bottom + clearance + 2, frame.clientHeight - needed)}px`;
|
||||
counterScale(toolbar, 'left', 'top');
|
||||
}
|
||||
|
||||
function positionInsert(button, box, where) {
|
||||
if (!box) { button.hidden = true; return; }
|
||||
const scale = getScale();
|
||||
const size = INSERT_SIZE / scale;
|
||||
button.hidden = false;
|
||||
button.style.left = `${box.left + box.width / 2 - size / 2}px`;
|
||||
button.style.top = `${(where === 'before' ? box.top : box.bottom) - size / 2}px`;
|
||||
button.style.width = `${size}px`;
|
||||
button.style.height = `${size}px`;
|
||||
counterScale(button, 'center', 'center');
|
||||
}
|
||||
|
||||
/** Renames the toolbar for whatever is selected right now. */
|
||||
function nameTools(id) {
|
||||
const block = locate(getDocument().blocks, id)?.block;
|
||||
const label = block ? blockLabel(block) : 'Element';
|
||||
selectName.textContent = label;
|
||||
for (const button of toolbar.children) {
|
||||
const tool = TOOLS.find(entry => entry.action === button.dataset.action);
|
||||
if (!tool) continue;
|
||||
button.title = tool.title(label);
|
||||
button.setAttribute('aria-label', tool.title(label));
|
||||
}
|
||||
}
|
||||
|
||||
function paintLabel(node, id) {
|
||||
const block = locate(getDocument().blocks, id)?.block;
|
||||
node.textContent = block ? blockLabel(block) : '';
|
||||
counterScale(node, 'left', 'bottom');
|
||||
}
|
||||
|
||||
/** Re-measures everything. Cheap enough to call on every scroll frame. */
|
||||
function sync() {
|
||||
const scale = getScale();
|
||||
layer.style.width = `${frame.clientWidth}px`;
|
||||
layer.style.height = `${frame.clientHeight}px`;
|
||||
layer.style.transform = `scale(${scale})`;
|
||||
if (!doc()) return;
|
||||
const selectedId = getSelected();
|
||||
const selectedBox = rectOf(selectedId);
|
||||
place(select, selectedBox);
|
||||
if (selectedBox) nameTools(selectedId);
|
||||
// Insert points only make sense for a block that has somewhere to sit next to.
|
||||
const item = selectedId ? locate(getDocument().blocks, selectedId) : null;
|
||||
const sibling = item && dropList(getDocument(), item.parent?.id ?? null, item.block);
|
||||
positionInsert(insertBefore, sibling ? selectedBox : null, 'before');
|
||||
positionInsert(insertAfter, sibling ? selectedBox : null, 'after');
|
||||
// The toolbar is placed last because it has to keep clear of the upper plus sign, and only
|
||||
// now is it settled whether there is one.
|
||||
positionToolbar(selectedBox, Boolean(sibling));
|
||||
const hoverBox = hovered && hovered !== selectedId ? rectOf(hovered) : null;
|
||||
if (place(hover, hoverBox)) paintLabel(hoverLabel, hovered);
|
||||
if (dragging) {
|
||||
dropPlan = dragPoint ? planDrop(dragPoint.x, dragPoint.y, dragging.block) : null;
|
||||
paintDrop();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Dropping.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/** The child elements of a block, in document order, that stand for its children. */
|
||||
function childElements(id) {
|
||||
const block = id ? locate(getDocument().blocks, id)?.block : null;
|
||||
const children = id ? (block?.children ?? []) : getDocument().blocks;
|
||||
return children.map(child => ({ id: child.id, box: rectOf(child.id) })).filter(entry => entry.box);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a drop would land inside a container. Children that sit side by side - the columns of
|
||||
* a row, the images of a gallery - are compared left to right, everything else top to bottom,
|
||||
* so the insertion line always runs across the gap the pointer is actually in.
|
||||
*/
|
||||
function indexInside(parentId, x, y) {
|
||||
const kids = childElements(parentId);
|
||||
if (!kids.length) return { index: 0, line: null };
|
||||
const sideBySide = kids.length > 1 && kids.some(kid => kid.box.left >= kids[0].box.right - 1);
|
||||
for (let index = 0; index < kids.length; index++) {
|
||||
const box = kids[index].box;
|
||||
const middle = sideBySide ? box.left + box.width / 2 : box.top + box.height / 2;
|
||||
if ((sideBySide ? x : y) < middle) return { index, line: { box, edge: sideBySide ? 'left' : 'top' }, sideBySide };
|
||||
}
|
||||
const last = kids[kids.length - 1].box;
|
||||
return { index: kids.length, line: { box: last, edge: sideBySide ? 'right' : 'bottom' }, sideBySide };
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a pointer position into a concrete destination. The rule, from the inside out: land
|
||||
* inside the hovered block when it is a container that takes this kind of child, otherwise
|
||||
* next to it, otherwise try the same two answers one level up.
|
||||
*/
|
||||
function planDrop(x, y, block) {
|
||||
const document_ = getDocument();
|
||||
if (x < 0 || y < 0 || x > frame.clientWidth || y > frame.clientHeight) return null;
|
||||
const start = blockElement(doc()?.elementFromPoint(x, y));
|
||||
for (let node = start; ; ) {
|
||||
const id = node?.id ?? null;
|
||||
const item = id ? locate(document_.blocks, id) : null;
|
||||
// Never offer to drop a block into itself or into its own subtree.
|
||||
const ownBranch = block && id && (id === block.id || Boolean(locate(block.children || [], id)));
|
||||
if (id && !ownBranch && isContainer(item?.block) && dropList(document_, id, block)) {
|
||||
const inside = indexInside(id, x, y);
|
||||
return { parentId: id, index: inside.index, line: inside.line ?? { box: rectOf(id), edge: 'inside' } };
|
||||
}
|
||||
const parentId = item?.parent?.id ?? null;
|
||||
if (id && !ownBranch && dropList(document_, parentId, block)) {
|
||||
const box = rectOf(id);
|
||||
const horizontal = item.parent?.type === 'row' && indexInside(parentId, x, y).sideBySide;
|
||||
const after = box ? (horizontal ? x > box.left + box.width / 2 : y > box.top + box.height / 2) : false;
|
||||
return { parentId, index: item.index + (after ? 1 : 0), line: box ? { box, edge: horizontal ? (after ? 'right' : 'left') : (after ? 'bottom' : 'top') } : null };
|
||||
}
|
||||
if (!node) break;
|
||||
node = item?.parent ? doc()?.getElementById(item.parent.id) : null;
|
||||
if (!node) {
|
||||
// Fell out of the tree: the top level is the last thing left to try.
|
||||
if (!dropList(document_, null, block)) break;
|
||||
const inside = indexInside(null, x, y);
|
||||
return { parentId: null, index: inside.index, line: inside.line };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function paintDrop() {
|
||||
const line = dropPlan?.line;
|
||||
if (!line?.box) { drop.hidden = true; dropLabel.hidden = true; return; }
|
||||
drop.hidden = false;
|
||||
const scale = getScale();
|
||||
const thickness = 3 / scale;
|
||||
const { box, edge } = line;
|
||||
const target = dropPlan.parentId ? doc()?.getElementById(dropPlan.parentId) : null;
|
||||
const label = target?.dataset.editorLabel || (dropPlan.parentId ? blockLabel(locate(getDocument().blocks, dropPlan.parentId)?.block) : 'Seite');
|
||||
dropLabel.textContent = `${edge === 'inside' ? 'Einfügen in' : 'Einfügen ·'} ${label}`;
|
||||
dropLabel.hidden = false;
|
||||
dropLabel.style.left = `${Math.max(0, box.left + 8)}px`;
|
||||
dropLabel.style.top = `${Math.max(2, box.top + 8)}px`;
|
||||
counterScale(dropLabel, 'left', 'top');
|
||||
drop.style.marginLeft = drop.style.marginTop = '0';
|
||||
drop.classList.toggle('ov-drop-inside', edge === 'inside');
|
||||
if (edge === 'inside') {
|
||||
drop.style.left = `${box.left}px`; drop.style.top = `${box.top}px`;
|
||||
drop.style.width = `${box.width}px`; drop.style.height = `${box.height}px`;
|
||||
return;
|
||||
}
|
||||
const vertical = edge === 'left' || edge === 'right';
|
||||
drop.style.left = `${edge === 'right' ? box.right : box.left}px`;
|
||||
drop.style.top = `${edge === 'bottom' ? box.bottom : box.top}px`;
|
||||
drop.style.width = `${vertical ? thickness : box.width}px`;
|
||||
drop.style.height = `${vertical ? box.height : thickness}px`;
|
||||
if (vertical) drop.style.marginLeft = `${-thickness / 2}px`; else drop.style.marginLeft = '0';
|
||||
if (!vertical) drop.style.marginTop = `${-thickness / 2}px`; else drop.style.marginTop = '0';
|
||||
}
|
||||
|
||||
/** Attaches to a freshly loaded preview document. Called after every preview refresh. */
|
||||
function attach() {
|
||||
const document_ = doc();
|
||||
if (!document_) return;
|
||||
document_.addEventListener('mousemove', event => {
|
||||
if (dragging) return;
|
||||
const node = blockElement(event.target);
|
||||
const id = node?.id ?? null;
|
||||
if (id === hovered) return;
|
||||
hovered = id;
|
||||
sync();
|
||||
});
|
||||
document_.documentElement.addEventListener('mouseleave', () => { hovered = null; sync(); });
|
||||
document_.addEventListener('scroll', sync, { passive: true, capture: true });
|
||||
document_.defaultView?.addEventListener('scroll', sync, { passive: true });
|
||||
// Inside the frame an event already carries frame coordinates, and those are exactly the
|
||||
// coordinates the layer draws in - no conversion, and none of the rounding a trip through
|
||||
// the editor viewport and back through the scale factor would add.
|
||||
document_.addEventListener('dragover', event => {
|
||||
if (!dragging) return;
|
||||
event.preventDefault();
|
||||
dragPoint = { x: event.clientX, y: event.clientY };
|
||||
dropPlan = planDrop(event.clientX, event.clientY, dragging.block);
|
||||
event.dataTransfer.dropEffect = dropPlan ? (dragging.kind === 'move' ? 'move' : 'copy') : 'none';
|
||||
paintDrop();
|
||||
if (!scrollFrame) scrollFrame = requestAnimationFrame(scrollDrag);
|
||||
});
|
||||
document_.addEventListener('dragleave', event => {
|
||||
if (!event.relatedTarget) { dragPoint = null; dropPlan = null; paintDrop(); }
|
||||
});
|
||||
document_.addEventListener('drop', event => {
|
||||
if (!dragging) return;
|
||||
event.preventDefault();
|
||||
// Both are read before the clean-up clears them.
|
||||
const payload = dragging, plan = planDrop(event.clientX, event.clientY, dragging.block);
|
||||
endDrag();
|
||||
if (plan) onDrop(payload, plan);
|
||||
});
|
||||
sync();
|
||||
}
|
||||
|
||||
function startDrag(payload) {
|
||||
dragging = payload;
|
||||
layer.classList.add('is-dragging');
|
||||
doc()?.documentElement.classList.add('editor-dragging');
|
||||
hovered = null;
|
||||
sync();
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
dragging = null;
|
||||
dragPoint = null;
|
||||
cancelAnimationFrame(scrollFrame); scrollFrame = null;
|
||||
dropPlan = null;
|
||||
drop.hidden = true;
|
||||
dropLabel.hidden = true;
|
||||
layer.classList.remove('is-dragging');
|
||||
doc()?.documentElement.classList.remove('editor-dragging');
|
||||
sync();
|
||||
}
|
||||
|
||||
let dragPoint = null, scrollFrame = null;
|
||||
function scrollDrag() {
|
||||
scrollFrame = null;
|
||||
if (!dragging || !dragPoint) return;
|
||||
const edge = 60 / getScale();
|
||||
const amount = dragPoint.y < edge ? -14 : dragPoint.y > frame.clientHeight - edge ? 14 : 0;
|
||||
if (amount) {
|
||||
doc()?.defaultView?.scrollBy(0, amount / getScale());
|
||||
sync();
|
||||
scrollFrame = requestAnimationFrame(scrollDrag);
|
||||
}
|
||||
}
|
||||
|
||||
return { sync, attach, startDrag, endDrag, get dragPayload() { return dragging; } };
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Farbfelder in den Verwaltungsformularen.
|
||||
*
|
||||
* Eine Farbe tippt man nicht, man wählt sie. Im Editor gibt es dafür längst ein Bedienelement aus
|
||||
* Farbwähler, Textfeld und Palettenliste - in den Formularen unter „Design" und „Einstellungen"
|
||||
* stand bisher nur ein nacktes Textfeld.
|
||||
*
|
||||
* Das Textfeld bleibt die Wahrheit: Es nimmt einen Wert wie `#1a2b3c` **oder** einen Schlüssel aus
|
||||
* der Palette (`@accent`), und das kann ein Farbwähler nicht. Deshalb wird es nicht ersetzt,
|
||||
* sondern ergänzt. Ohne JavaScript bleibt das Feld genau das, was es vorher war.
|
||||
*/
|
||||
(() => {
|
||||
const felder = [...document.querySelectorAll('input[data-color]')];
|
||||
if (0 === felder.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const feld of felder) {
|
||||
const palette = JSON.parse(feld.dataset.palette || '[]');
|
||||
const reihe = document.createElement('div');
|
||||
reihe.className = 'color-field';
|
||||
feld.parentNode.insertBefore(reihe, feld);
|
||||
|
||||
const waehler = document.createElement('input');
|
||||
waehler.type = 'color';
|
||||
waehler.className = 'color-field-swatch';
|
||||
waehler.setAttribute('aria-label', 'Farbwähler für ' + (feld.labels?.[0]?.textContent.trim() || 'diese Farbe'));
|
||||
// Der Wähler kennt nur Hexwerte. Steht im Feld ein Palettenschlüssel, zeigt er die Farbe,
|
||||
// für die der Schlüssel steht - sonst bliebe er auf Schwarz stehen und sähe falsch aus.
|
||||
const alsHex = wert => {
|
||||
if (/^#[0-9a-f]{6}$/i.test(wert)) {
|
||||
return wert;
|
||||
}
|
||||
const treffer = palette.find(eintrag => '@' + eintrag.key === wert);
|
||||
|
||||
return treffer ? treffer.value : '#000000';
|
||||
};
|
||||
waehler.value = alsHex(feld.value || feld.placeholder || '#000000');
|
||||
waehler.addEventListener('input', () => {
|
||||
feld.value = waehler.value;
|
||||
feld.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
|
||||
reihe.append(waehler, feld);
|
||||
|
||||
if (palette.length > 0) {
|
||||
const auswahl = document.createElement('select');
|
||||
auswahl.className = 'color-field-palette';
|
||||
auswahl.setAttribute('aria-label', 'Farbe aus der Palette');
|
||||
auswahl.append(new Option('Aus der Palette …', ''));
|
||||
for (const eintrag of palette) {
|
||||
auswahl.append(new Option(`${eintrag.name} (${eintrag.value})`, '@' + eintrag.key));
|
||||
}
|
||||
auswahl.value = palette.some(eintrag => '@' + eintrag.key === feld.value) ? feld.value : '';
|
||||
auswahl.addEventListener('change', () => {
|
||||
if ('' === auswahl.value) {
|
||||
return;
|
||||
}
|
||||
feld.value = auswahl.value;
|
||||
waehler.value = alsHex(auswahl.value);
|
||||
feld.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
reihe.append(auswahl);
|
||||
}
|
||||
|
||||
// Tippt jemand von Hand, folgt der Wähler mit - solange der Wert einer ist, den er kennt.
|
||||
feld.addEventListener('input', () => {
|
||||
const hex = alsHex(feld.value);
|
||||
if (waehler.value !== hex) {
|
||||
waehler.value = hex;
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
+258
-39
@@ -5,7 +5,7 @@
|
||||
* what the server accepts – nothing more, nothing less. Adding a property on the server makes it
|
||||
* appear here without touching this file.
|
||||
*/
|
||||
import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle } from './editor-model.js';
|
||||
import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle, isContainer, relevantProperty } from './editor-model.js';
|
||||
import { openMedia } from './media.js';
|
||||
|
||||
export const GROUPS = {
|
||||
@@ -44,6 +44,58 @@ export function contrastRatio(foreground, background) {
|
||||
return (light + 0.05) / (dark + 0.05);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a number field draggable sideways, the way the Flatsome scrubfield works.
|
||||
*
|
||||
* Typing "24" into a field to see whether 24 is right, then "26", then "22", is three round
|
||||
* trips for one decision. Dragging answers it in one motion. Typing and the arrow keys keep
|
||||
* working untouched - this is added, nothing is taken away.
|
||||
*
|
||||
* Only for mouse and pen: on a touch screen a horizontal drag over a field is how the panel is
|
||||
* scrolled, and stealing that would make the panel unusable on a phone.
|
||||
*/
|
||||
function makeScrubbable(input, { apply, step, mergeKey }) {
|
||||
const size = 'any' === step || !step ? 1 : Number(step);
|
||||
input.classList.add('scrubbable');
|
||||
let startX = 0, startValue = 0, active = false, moved = false;
|
||||
input.addEventListener('pointerdown', event => {
|
||||
if (0 !== event.button || input.disabled || 'touch' === event.pointerType) return;
|
||||
startX = event.clientX;
|
||||
startValue = '' === input.value ? 0 : Number(input.value);
|
||||
active = Number.isFinite(startValue);
|
||||
moved = false;
|
||||
});
|
||||
input.addEventListener('pointermove', event => {
|
||||
if (!active) return;
|
||||
const distance = event.clientX - startX;
|
||||
// A few pixels of slack, so a plain click still lands in the field to type in.
|
||||
if (!moved && Math.abs(distance) < 3) return;
|
||||
if (!moved) { moved = true; input.setPointerCapture(event.pointerId); }
|
||||
event.preventDefault();
|
||||
// Shift is coarse, Alt is fine - the two speeds a value needs while being judged by eye.
|
||||
const scale = event.shiftKey ? 10 : event.altKey ? 0.1 : 1;
|
||||
const raw = startValue + distance * size * scale;
|
||||
const rounded = Math.round(raw / (size * scale)) * (size * scale);
|
||||
const min = '' === input.min ? -Infinity : Number(input.min);
|
||||
const max = '' === input.max ? Infinity : Number(input.max);
|
||||
const value = Math.min(max, Math.max(min, Math.round(rounded * 1000) / 1000));
|
||||
if (String(value) === input.value) return;
|
||||
input.value = String(value);
|
||||
apply(String(value), mergeKey);
|
||||
});
|
||||
const stop = event => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
if (!moved) return;
|
||||
if (input.hasPointerCapture?.(event.pointerId)) input.releasePointerCapture(event.pointerId);
|
||||
// The drag was one decision, so it closes as one entry in the history.
|
||||
apply(input.value, null);
|
||||
};
|
||||
input.addEventListener('pointerup', stop);
|
||||
input.addEventListener('pointercancel', stop);
|
||||
input.title = 'Zum Ändern seitwärts ziehen. Umschalt grob, Alt fein.';
|
||||
}
|
||||
|
||||
function element(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
@@ -51,10 +103,28 @@ function element(tag, className, text) {
|
||||
return node;
|
||||
}
|
||||
|
||||
export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, getDevice, getSelection, getHistory }) {
|
||||
const change = mutate => { getHistory().change(mutate); onChange(); };
|
||||
export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, getDevice, onDevice, getSelection, getHistory }) {
|
||||
/**
|
||||
* The merge key lets a drag collapse into a single undo entry instead of one per pixel.
|
||||
*
|
||||
* `keepPanel` is what makes dragging possible at all: a change normally re-renders this panel,
|
||||
* which replaces the very field the pointer is holding on to. The drag would end after its
|
||||
* first pixel. While scrubbing, the document and the preview are updated and the panel is left
|
||||
* standing; it is redrawn once, when the pointer is released.
|
||||
*/
|
||||
const change = (mutate, mergeKey, keepPanel = false) => {
|
||||
getHistory().change(mutate, mergeKey);
|
||||
onChange({ keepPanel });
|
||||
};
|
||||
// Whether the sides of a property move together. This has to outlive the panel: switching the
|
||||
// chain triggers a change, a change re-renders the panel, and a state kept in the button alone
|
||||
// would be gone by the time the next keystroke arrives.
|
||||
const linkedSides = new Map();
|
||||
// Lives as long as the editor session, so a look can travel between blocks and pages.
|
||||
let clipboard = null;
|
||||
let showAll = false, lastBlockId = null;
|
||||
const openGroups = new Map();
|
||||
const renderedGroups = new Map();
|
||||
|
||||
/** Replaces the styling of a block with a copy, or removes it when nothing is given. */
|
||||
const applyStyle = (doc, id, style) => {
|
||||
@@ -73,13 +143,54 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
}
|
||||
};
|
||||
|
||||
/** Short marks for the chips; the full device name is in the title and the accessible name. */
|
||||
const SHORT = { desktop: 'D', tablet: 'T', mobile: 'M' };
|
||||
|
||||
/** Does this device carry an own value for this property - as a plain value or per side? */
|
||||
const hasOwn = (block, deviceName, key, hover) => {
|
||||
const bucket = hover ? block?.style?.[deviceName]?.hover : block?.style?.[deviceName];
|
||||
const value = bucket?.[key];
|
||||
if (value === undefined) return false;
|
||||
// A per-side property stores an object; an empty one is a leftover, not a value.
|
||||
return value && typeof value === 'object' ? Object.values(value).some(single => single !== '' && single !== undefined) : value !== '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Three chips per option showing which devices carry an own value, and switching to one.
|
||||
*
|
||||
* Until now a single switch above the preview decided which device the whole panel edited,
|
||||
* so "is the heading smaller on the phone" could only be answered by switching and looking.
|
||||
* The chips answer it where the question comes up. Clicking one switches the preview as well:
|
||||
* editing a value for a device you are not looking at is editing blind.
|
||||
*/
|
||||
const deviceChips = (block, key, labelText, hover) => {
|
||||
const current = getDevice();
|
||||
const group = element('div', 'device-chips');
|
||||
group.setAttribute('role', 'group');
|
||||
group.setAttribute('aria-label', `Gerät für ${labelText}`);
|
||||
for (const [name, deviceLabel] of Object.entries(devices)) {
|
||||
const own = hasOwn(block, name, key, hover);
|
||||
const chip = element('button', 'device-chip' + (own ? ' device-chip-own' : ''), SHORT[name]);
|
||||
chip.type = 'button';
|
||||
chip.dataset.device = name;
|
||||
chip.setAttribute('aria-pressed', String(name === current));
|
||||
const state = own ? 'eigener Wert' : 'erbt';
|
||||
chip.title = `${deviceLabel} – ${state}`;
|
||||
chip.setAttribute('aria-label', `${labelText} für ${deviceLabel} bearbeiten – ${state}`);
|
||||
chip.addEventListener('click', () => { if (name !== current) onDevice(name); });
|
||||
group.append(chip);
|
||||
}
|
||||
return group;
|
||||
};
|
||||
|
||||
/** One labelled row with a reset button that appears as soon as this device has an own value. */
|
||||
const row = (id, labelText, control, { own, inherited, onReset }) => {
|
||||
const row = (id, labelText, control, { own, inherited, onReset, chips }) => {
|
||||
const wrap = element('div', 'design-row' + (own ? ' design-own' : ''));
|
||||
const head = element('div', 'design-row-head');
|
||||
const label = element('label', null, labelText);
|
||||
label.htmlFor = id;
|
||||
head.append(label);
|
||||
if (chips) head.append(chips);
|
||||
if (own) {
|
||||
const reset = element('button', 'text-button design-reset-one', '⟲');
|
||||
reset.type = 'button';
|
||||
@@ -119,7 +230,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
return wrap;
|
||||
};
|
||||
|
||||
const lengthControl = (id, value, apply, placeholder) => {
|
||||
const lengthControl = (id, value, apply, placeholder, keywords = []) => {
|
||||
const wrap = element('div', 'length-control');
|
||||
const parts = splitLength(value);
|
||||
const number = document.createElement('input');
|
||||
@@ -132,10 +243,22 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
unit.setAttribute('aria-label', 'Einheit');
|
||||
for (const name of UNITS) unit.append(new Option(name, name));
|
||||
unit.value = parts.unit;
|
||||
const push = () => apply(number.value === '' ? '' : `${number.value}${unit.value}`);
|
||||
number.addEventListener('change', push);
|
||||
const push = (_value, mergeKey) => apply(number.value === '' ? '' : `${number.value}${unit.value}`, mergeKey);
|
||||
number.addEventListener('change', () => push());
|
||||
unit.addEventListener('change', () => { if (number.value !== '') push(); });
|
||||
makeScrubbable(number, { apply: push, step: 1, mergeKey: `scrub-${id}` });
|
||||
wrap.append(number, unit);
|
||||
if (keywords.length) {
|
||||
const mode = document.createElement('select');
|
||||
mode.className = 'length-mode';
|
||||
mode.setAttribute('aria-label', `${schema.properties[id.replace('style-', '')]?.label ?? 'Länge'} Vorgabe`);
|
||||
mode.append(new Option('Eigener Zahlenwert / geerbt', ''));
|
||||
for (const keyword of keywords) mode.append(new Option(keyword === 'none' ? 'Ohne Begrenzung' : 'Automatisch', keyword));
|
||||
mode.value = ['auto', 'none'].includes(value) ? value : '';
|
||||
number.hidden = unit.hidden = mode.value !== '';
|
||||
mode.addEventListener('change', () => apply(mode.value));
|
||||
wrap.prepend(mode);
|
||||
}
|
||||
return wrap;
|
||||
};
|
||||
|
||||
@@ -155,6 +278,11 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
slider.addEventListener('input', () => { exact.value = slider.value; });
|
||||
slider.addEventListener('change', () => apply(slider.value));
|
||||
exact.addEventListener('change', () => { slider.value = exact.value; apply(exact.value); });
|
||||
makeScrubbable(exact, {
|
||||
apply: (value, mergeKey) => { slider.value = value; apply(value, mergeKey); },
|
||||
step: property.step,
|
||||
mergeKey: `scrub-${id}`,
|
||||
});
|
||||
wrap.append(slider, exact);
|
||||
return wrap;
|
||||
};
|
||||
@@ -179,35 +307,110 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
return select;
|
||||
};
|
||||
|
||||
/** Four inputs for padding, margin, border width and corners. */
|
||||
/**
|
||||
* Padding, margin, border width and corners as a box.
|
||||
*
|
||||
* Four stacked fields labelled "Oben, Rechts, Unten, Links" made you read to find out which
|
||||
* edge you were setting. In a box each field sits on the edge it controls, so the position is
|
||||
* the label. The chain in the middle keeps all four equal, which is what most of them are.
|
||||
*
|
||||
* The unit stays a single choice for the whole property, because four unit menus do not fit a
|
||||
* 240-pixel panel. Sides that already carry different units are shown as "gemischt" and are
|
||||
* left alone until a unit is actually picked - a stored 2rem is not silently turned into 2px.
|
||||
*/
|
||||
const sideControl = (key, block, deviceName, property) => {
|
||||
const grid = element('div', 'side-control');
|
||||
const parts = property.corners ? schema.corners : schema.sides;
|
||||
for (const [side, label] of Object.entries(parts)) {
|
||||
const cell = element('div', 'side-cell');
|
||||
const id = `style-${key}-${side}`;
|
||||
const caption = element('label', null, label);
|
||||
caption.htmlFor = id;
|
||||
const corners = Boolean(property.corners);
|
||||
const parts = corners ? schema.corners : schema.sides;
|
||||
const names = Object.keys(parts);
|
||||
const wrap = element('div', 'side-wrap');
|
||||
const box = element('div', 'side-box' + (corners ? ' side-box-corners' : ''));
|
||||
|
||||
// What is stored right now, per side, split into number and unit.
|
||||
const stored = Object.fromEntries(names.map(side => {
|
||||
const current = effectiveStyle(block, deviceName, key, side);
|
||||
const control = lengthControl(id, current.source === deviceName ? current.value : '', value => {
|
||||
change(doc => setStyle(doc, getSelection(), deviceName, key, value, { side }));
|
||||
}, current.value ? String(current.value) : 'erbt');
|
||||
if (current.value && current.source !== deviceName) control.classList.add('is-inherited');
|
||||
cell.append(caption, control);
|
||||
grid.append(cell);
|
||||
}
|
||||
const all = element('button', 'text-button side-all', 'Alle Seiten gleich setzen');
|
||||
all.type = 'button';
|
||||
all.addEventListener('click', () => {
|
||||
const first = grid.querySelector('input[type=number]');
|
||||
const unit = grid.querySelector('select');
|
||||
if (first?.value === '') return;
|
||||
const own = current.source === deviceName ? current.value : '';
|
||||
return [side, { own, inherited: current.value, parts: splitLength(own) }];
|
||||
}));
|
||||
const usedUnits = [...new Set(names.map(side => stored[side].own ? stored[side].parts.unit : null).filter(Boolean))];
|
||||
const commonUnit = 1 === usedUnits.length ? usedUnits[0] : '';
|
||||
|
||||
const unit = document.createElement('select');
|
||||
unit.id = `style-${key}-unit`;
|
||||
unit.className = 'side-unit-select';
|
||||
unit.setAttribute('aria-label', `Einheit für ${property.label}`);
|
||||
if (usedUnits.length > 1) unit.append(new Option('gemischt', ''));
|
||||
for (const name of UNITS) unit.append(new Option(name, name));
|
||||
unit.value = commonUnit || 'px';
|
||||
|
||||
const linked = element('button', 'side-link', '⛓');
|
||||
linked.type = 'button';
|
||||
// Off to begin with. It replaces the old "Alle Seiten gleich setzen" button, and that was a
|
||||
// deliberate act: typing into one field must not quietly rewrite the three others.
|
||||
linked.setAttribute('aria-pressed', String(linkedSides.get(key) ?? false));
|
||||
linked.title = 'Alle Seiten gemeinsam ändern';
|
||||
linked.setAttribute('aria-label', `Alle Seiten von ${property.label} gemeinsam ändern`);
|
||||
|
||||
/** The unit a side should be written with: its own, else the chosen one. */
|
||||
const unitFor = side => (stored[side].own ? stored[side].parts.unit : null) ?? (unit.value || 'px');
|
||||
|
||||
const write = (side, number, mergeKey) => {
|
||||
const targets = linkedSides.get(key) ? names : [side];
|
||||
change(doc => {
|
||||
for (const side of Object.keys(parts)) setStyle(doc, getSelection(), deviceName, key, `${first.value}${unit.value}`, { side });
|
||||
for (const target of targets) {
|
||||
setStyle(doc, getSelection(), deviceName, key, '' === number ? '' : `${number}${unitFor(target)}`, { side: target });
|
||||
}
|
||||
}, mergeKey, Boolean(mergeKey));
|
||||
};
|
||||
|
||||
for (const side of names) {
|
||||
const id = `style-${key}-${side}`;
|
||||
const field = document.createElement('input');
|
||||
field.type = 'number';
|
||||
field.step = 'any';
|
||||
field.id = id;
|
||||
field.className = 'side-field';
|
||||
field.value = stored[side].parts.number;
|
||||
field.placeholder = stored[side].inherited ? String(stored[side].inherited) : 'erbt';
|
||||
field.setAttribute('aria-label', `${property.label} ${parts[side]}`);
|
||||
if (stored[side].inherited && !stored[side].own) field.classList.add('is-inherited');
|
||||
field.addEventListener('change', () => write(side, field.value, null));
|
||||
makeScrubbable(field, { apply: (value, mergeKey) => write(side, value, mergeKey), step: 1, mergeKey: `scrub-${id}` });
|
||||
const cell = element('div', `side-cell side-${side}`);
|
||||
cell.append(field);
|
||||
box.append(cell);
|
||||
}
|
||||
box.append(linked);
|
||||
|
||||
linked.addEventListener('click', () => {
|
||||
const now = !linkedSides.get(key);
|
||||
linkedSides.set(key, now);
|
||||
linked.setAttribute('aria-pressed', String(now));
|
||||
if (!now) return;
|
||||
// Switching the chain on levels the sides - otherwise it would claim a state the box does
|
||||
// not show. The value taken is the first one that exists: starting from an empty top field
|
||||
// would wipe the sides that do have a value.
|
||||
const set = names.map(side => stored[side].parts.number).find(number => '' !== number);
|
||||
if (undefined === set) return;
|
||||
change(doc => {
|
||||
for (const target of names) setStyle(doc, getSelection(), deviceName, key, `${set}${unit.value || 'px'}`, { side: target });
|
||||
});
|
||||
});
|
||||
const wrap = element('div', 'side-wrap');
|
||||
wrap.append(grid, all);
|
||||
|
||||
unit.addEventListener('change', () => {
|
||||
if ('' === unit.value) return;
|
||||
change(doc => {
|
||||
for (const side of names) {
|
||||
const number = stored[side].own ? stored[side].parts.number : '';
|
||||
if ('' !== number) setStyle(doc, getSelection(), deviceName, key, `${number}${unit.value}`, { side });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const unitRow = element('div', 'side-unit');
|
||||
const unitLabel = element('label', null, 'Einheit');
|
||||
unitLabel.htmlFor = unit.id;
|
||||
unitRow.append(unitLabel, unit);
|
||||
wrap.append(box, unitRow);
|
||||
return wrap;
|
||||
};
|
||||
|
||||
@@ -257,14 +460,22 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
|
||||
/** Renders the whole panel for one block into the given container. */
|
||||
return function render(panel) {
|
||||
// Read the previous nodes directly; a queued toggle event can be lost during a redraw.
|
||||
for (const [key, details] of renderedGroups) openGroups.set(key, details.open);
|
||||
renderedGroups.clear();
|
||||
const block = getBlock();
|
||||
const deviceName = getDevice();
|
||||
if (!block) return;
|
||||
if (lastBlockId !== block.id) { showAll = false; lastBlockId = block.id; }
|
||||
|
||||
const scope = element('p', 'design-scope');
|
||||
scope.textContent = `${devices[deviceName]} · ${deviceName === 'desktop' ? 'Basis für alle Geräte' : deviceName === 'tablet' ? 'bis 1024 px, gilt auch für Mobil' : 'bis 767 px'}`;
|
||||
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.'));
|
||||
const all = element('button', 'secondary design-context-toggle', showAll ? 'Passende Eigenschaften anzeigen' : 'Alle Eigenschaften anzeigen');
|
||||
all.type = 'button'; all.setAttribute('aria-pressed', String(showAll));
|
||||
all.addEventListener('click', () => { showAll = !showAll; onChange(); panel.querySelector('.design-context-toggle')?.focus(); });
|
||||
panel.append(all);
|
||||
|
||||
// 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.
|
||||
@@ -295,11 +506,16 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
});
|
||||
panel.append(searchLabel, search, found);
|
||||
|
||||
for (const [group, groupLabel] of Object.entries(GROUPS)) {
|
||||
const properties = Object.entries(schema.properties).filter(([, p]) => p.group === group);
|
||||
const priority = ['image', 'gallery', 'logos'].includes(block.type) ? ['image', 'size', 'spacing'] : isContainer(block) ? ['layout', 'size', 'spacing', 'background'] : ['spacer', 'divider', 'embed'].includes(block.type) ? ['size', 'spacing'] : ['text', 'size', 'spacing'];
|
||||
const orderedGroups = [...new Set([...priority, ...Object.keys(GROUPS)])];
|
||||
for (const group of orderedGroups) {
|
||||
const groupLabel = GROUPS[group];
|
||||
const properties = Object.entries(schema.properties).filter(([key, p]) => p.group === group && (showAll || relevantProperty(block, key, p)));
|
||||
if (properties.length === 0) continue;
|
||||
const details = element('details', 'design-group');
|
||||
if (group === 'text' || group === 'spacing') details.open = true;
|
||||
const groupKey = `${block.type}:${group}`;
|
||||
details.open = openGroups.get(groupKey) ?? (group === priority[0] || group === 'spacing');
|
||||
renderedGroups.set(groupKey, details);
|
||||
details.append(element('summary', null, groupLabel));
|
||||
const body = element('div', 'design-group-body');
|
||||
|
||||
@@ -307,7 +523,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
const id = `style-${key}`;
|
||||
const current = effectiveStyle(block, deviceName, key);
|
||||
const own = block.style?.[deviceName]?.[key] !== undefined;
|
||||
const apply = value => change(doc => setStyle(doc, getSelection(), deviceName, key, value));
|
||||
const apply = (value, mergeKey) => change(doc => setStyle(doc, getSelection(), deviceName, key, value), mergeKey, Boolean(mergeKey));
|
||||
const onReset = () => change(doc => setStyle(doc, getSelection(), deviceName, key, ''));
|
||||
const inheritedText = current.value !== undefined && !own
|
||||
? `Erbt ${describe(property, current.value)} von ${devices[current.source]}`
|
||||
@@ -315,23 +531,25 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
|
||||
let control;
|
||||
if (property.sides || property.corners) {
|
||||
body.append(element('p', 'design-group-label', property.label), sideControl(key, block, deviceName, property));
|
||||
const sideHead = element('div', 'design-row-head design-side-head');
|
||||
sideHead.append(element('p', 'design-group-label', property.label), deviceChips(block, key, property.label, false));
|
||||
body.append(sideHead, sideControl(key, block, deviceName, property));
|
||||
continue;
|
||||
}
|
||||
if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply);
|
||||
else if (property.type === 'length' || property.type === 'length-auto') control = lengthControl(id, own ? current.value : '', apply, current.value ? String(current.value) : 'erbt');
|
||||
else if (property.type === 'length' || property.type === 'length-auto') control = lengthControl(id, own ? current.value : '', apply, current.value ? String(current.value) : 'erbt', property.type === 'length-auto' ? (key.startsWith('max') ? ['none'] : ['auto']) : []);
|
||||
else if (property.type === 'number') control = numberControl(id, own ? current.value : '', property, apply);
|
||||
else if (property.type === 'choice') control = choiceControl(id, own ? current.value : '', property, apply, inheritedText);
|
||||
else if (property.type === 'font') control = fontControl(id, own ? current.value : '', apply, inheritedText);
|
||||
else if (property.type === 'media') control = mediaControl(key, own ? current.value : '', apply);
|
||||
else continue;
|
||||
|
||||
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset }));
|
||||
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset, chips: deviceChips(block, key, property.label, false) }));
|
||||
|
||||
if (schema.hoverable.includes(key)) {
|
||||
const hoverId = `${id}-hover`;
|
||||
const hoverValue = hoverStyle(block, deviceName, key);
|
||||
const hoverApply = value => change(doc => setStyle(doc, getSelection(), deviceName, key, value, { hover: true }));
|
||||
const hoverApply = (value, mergeKey) => change(doc => setStyle(doc, getSelection(), deviceName, key, value, { hover: true }), mergeKey, Boolean(mergeKey));
|
||||
const hoverControl = property.type === 'color'
|
||||
? colorControl(hoverId, hoverValue ?? '', hoverApply)
|
||||
: property.type === 'number'
|
||||
@@ -341,6 +559,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
own: hoverValue !== undefined,
|
||||
inherited: 'Kein eigener Hover-Wert',
|
||||
onReset: () => hoverApply(''),
|
||||
chips: deviceChips(block, key, `${property.label} beim Überfahren`, true),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
+193
-25
@@ -1,7 +1,34 @@
|
||||
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: '⇄' };
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider' , legal: 'Rechtszeile'};
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄' };
|
||||
// A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the
|
||||
// document, stay selectable and movable, but the editor never edits or fills them.
|
||||
/**
|
||||
* The toolbox, grouped the way the page is actually built. A flat list of thirty-one types said
|
||||
* nothing about which of them belong together or where they may go; the first group is the
|
||||
* scaffolding, the rest is what goes inside it.
|
||||
*/
|
||||
export const CATEGORIES = [
|
||||
{ name: 'Aufbau', hint: 'Sektion → Zeile → Spalte. Der Inhalt kommt in die Spalte.', types: ['section', 'row', 'column'] },
|
||||
{ name: 'Inhalt', types: ['heading', 'text', 'image', 'button', 'icon', 'divider', 'spacer'] },
|
||||
{ name: 'Bausteine', types: ['iconcard', 'stat', 'quote', 'cta', 'contact', 'logos', 'progress'] },
|
||||
{ name: 'Gruppen', types: ['gallery', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'] },
|
||||
{ name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'form', 'legal', 'partialref', 'embed'] },
|
||||
];
|
||||
|
||||
/** How many columns a row can be added with. */
|
||||
export const ROW_COLUMNS = [2, 3, 4];
|
||||
|
||||
/**
|
||||
* Why a type cannot be added where the selection currently is. The toolbox says this instead of
|
||||
* simply greying a button out: a rule you are told is one you learn, a button that merely stops
|
||||
* working is one you keep pressing.
|
||||
*/
|
||||
export function whyNot(type) {
|
||||
if (REQUIRE[type]) return `${labels[type]}: gehört in ${REQUIRE[type].map(parent => labels[parent]).join(' oder ')}. Wähle dort zuerst ein Element aus.`;
|
||||
if (ROOT_NEEDS_CONTAINER.includes(type)) return `${labels[type]}: gehört in eine Sektion. Füge zuerst eine Sektion ein oder wähle eine aus.`;
|
||||
return `${labels[type]}: passt an dieser Stelle nicht. Wähle ein anderes Element aus.`;
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -9,11 +36,37 @@ export const blockIcon = block => icons[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' };
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// What may sit inside what.
|
||||
//
|
||||
// Three rules, the same three the Flatsome UX Builder uses, because they cover the cases and
|
||||
// because they are the ones people already have in their hands:
|
||||
//
|
||||
// ALLOW a container that takes only these children and nothing else
|
||||
// REQUIRE a block that exists only inside one of these parents
|
||||
// ROOT_NEEDS_CONTAINER
|
||||
// a block that may sit in any container but not loose at the top of a document
|
||||
//
|
||||
// Until now a row took anything - a heading could land directly in it, next to the columns -
|
||||
// and a column could stand anywhere. That is why the toolbox offered rows and columns out of
|
||||
// nowhere. The structure the editor has always described in its own hint text is
|
||||
// section -> row -> column -> content, and these rules are that sentence made binding.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Containers that take one kind of child and nothing else. */
|
||||
export const ALLOW = { row: ['column'], gallery: ['image'], logos: ['image'], accordion: ['accordionitem'], tabs: ['tabitem'] };
|
||||
/** Blocks that exist only inside one of these parents. */
|
||||
export const REQUIRE = { column: ['row'], accordionitem: ['accordion'], tabitem: ['tabs'] };
|
||||
/**
|
||||
* A row is not refused by any particular parent - a tab panel or an accordion entry may well
|
||||
* hold one - but it is not a thing a document starts with. At the top level a page begins with
|
||||
* a section, and that is where a row belongs.
|
||||
*/
|
||||
export const ROOT_NEEDS_CONTAINER = ['row'];
|
||||
|
||||
export const canContain = (parent, block) => isContainer(parent) && !isPluginBlock(parent)
|
||||
&& (ONLY_CHILD[parent.type] === undefined || ONLY_CHILD[parent.type] === block.type)
|
||||
&& (block.type !== 'accordionitem' || parent.type === 'accordion')
|
||||
&& (block.type !== 'tabitem' || parent.type === 'tabs');
|
||||
&& (ALLOW[parent.type] === undefined || ALLOW[parent.type].includes(block?.type))
|
||||
&& (block?.type === undefined || REQUIRE[block.type] === undefined || REQUIRE[block.type].includes(parent.type));
|
||||
export const clone = value => structuredClone(value);
|
||||
|
||||
export function locate(blocks, id, parent = null) {
|
||||
@@ -36,7 +89,9 @@ export function createBlock(type) {
|
||||
if (type === 'heading') block.props = { text: 'Deine neue Überschrift', level: 2 };
|
||||
if (type === 'text') block.props = { text: 'Hier beginnt dein neuer Inhalt.' };
|
||||
if (type === 'button') block.props = { text: 'Mehr erfahren', href: '/' };
|
||||
if (type === 'postlist') block.props = { count: 3, category: '', tag: '' };
|
||||
if (type === 'postlist') block.props = { count: 3, category: '', tag: '', layout: 'karten' };
|
||||
if (type === 'postnav') block.props = { previousLabel: 'Vorheriger Beitrag', nextLabel: 'Nächster Beitrag' };
|
||||
if (type === 'legal') block.props = { label: 'Rechtliches' };
|
||||
if (type === 'iconcard') block.props = { icon: 'check', title: 'Deine Leistung', text: 'Beschreibe hier kurz, worum es geht.', href: '' };
|
||||
if (type === 'stat') block.props = { value: '15+', label: 'Jahre Erfahrung' };
|
||||
if (type === 'quote') block.props = { text: 'Ein Satz, den eine Kundin oder ein Kunde über dich gesagt hat.', author: 'Name', role: '', rating: 5 };
|
||||
@@ -52,15 +107,17 @@ export function createBlock(type) {
|
||||
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 === 'slider') block.props = { desktopVisible: 3, tabletVisible: 2, mobileVisible: 1, snap: true, dots: true, autoplay: 0 };
|
||||
if (type === 'image') block.props = { src: '/assets/image-placeholder.svg', alt: 'Abstrakte Landschaft als Platzhalter', decorative: false, width: 1000, height: 600 };
|
||||
return block;
|
||||
}
|
||||
|
||||
export function insertBlock(document, type, selected) {
|
||||
const block = createBlock(type);
|
||||
/** Raises the document schema to the version that knows this block type. */
|
||||
export function upgradeForType(document, type) {
|
||||
if (type === 'gallery') upgradeDocument(document, 3);
|
||||
if (type === 'postlist') upgradeDocument(document, 5);
|
||||
if (type === 'postnav') upgradeDocument(document, 18);
|
||||
if (type === 'legal') upgradeDocument(document, 19);
|
||||
if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6);
|
||||
if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7);
|
||||
if (type === 'form') upgradeDocument(document, 8);
|
||||
@@ -68,13 +125,42 @@ export function insertBlock(document, type, selected) {
|
||||
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);
|
||||
if (!target) document.blocks.push(block);
|
||||
else if (canContain(target.block, block)) (target.block.children ??= []).push(block);
|
||||
else target.siblings.splice(target.index + 1, 0, block);
|
||||
return block.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where clicking a type in the toolbox would put it - without putting it anywhere.
|
||||
*
|
||||
* Asking and doing used to be the same step, so nothing could tell beforehand whether a type
|
||||
* fits at all. The toolbox needs exactly that answer to grey out what does not belong here, and
|
||||
* the answer has to be the one the click will actually follow, or the two drift apart.
|
||||
*
|
||||
* The search goes from the inside out: into the selection when it is a container that takes
|
||||
* this type, otherwise next to it, climbing until some ancestor accepts it, and finally the top
|
||||
* level. Returns null when the type cannot go anywhere from this selection.
|
||||
*
|
||||
* @return {{parentId: string|null, index: number}|null}
|
||||
*/
|
||||
export function insertionTarget(document, selected, type) {
|
||||
// A section starts a page area. Explicit insertion points and drag/drop can still nest it.
|
||||
if (type === 'section') {
|
||||
const top = blockPath(document.blocks, selected)[0];
|
||||
return { parentId: null, index: top ? document.blocks.findIndex(block => block.id === top.id) + 1 : document.blocks.length };
|
||||
}
|
||||
const probe = { type };
|
||||
let item = locate(document.blocks, selected);
|
||||
if (item && canContain(item.block, probe)) return { parentId: item.block.id, index: (item.block.children ?? []).length };
|
||||
while (item) {
|
||||
const parentId = item.parent?.id ?? null;
|
||||
if (dropList(document, parentId, probe)) return { parentId, index: item.index + 1 };
|
||||
item = item.parent ? locate(document.blocks, item.parent.id) : null;
|
||||
}
|
||||
return dropList(document, null, probe) ? { parentId: null, index: document.blocks.length } : null;
|
||||
}
|
||||
|
||||
export function insertBlock(document, type, selected) {
|
||||
const target = insertionTarget(document, selected, type);
|
||||
if (!target) return null;
|
||||
return insertBlockAt(document, type, target.parentId, target.index);
|
||||
}
|
||||
|
||||
export function duplicateBlock(document, selected) {
|
||||
@@ -105,11 +191,72 @@ export function moveBlock(document, selected, targetId, inside = false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a block may land. `parentId` of null means the top level. Returns the list that would
|
||||
* hold the block, or null when the parent refuses this kind of child - a gallery takes images
|
||||
* and nothing else, an accordion takes entries.
|
||||
*/
|
||||
export function dropList(document, parentId, block) {
|
||||
if (parentId === null) {
|
||||
const type = block?.type;
|
||||
const loose = type !== undefined && (REQUIRE[type] !== undefined || ROOT_NEEDS_CONTAINER.includes(type));
|
||||
return loose ? null : document.blocks;
|
||||
}
|
||||
const parent = locate(document.blocks, parentId)?.block;
|
||||
return parent && canContain(parent, block) ? (parent.children ??= []) : null;
|
||||
}
|
||||
|
||||
/** Puts an already built block at an exact position, or refuses when the parent will not take it. */
|
||||
function place(document, block, parentId, index) {
|
||||
const list = dropList(document, parentId, block);
|
||||
if (!list) return null;
|
||||
list.splice(Math.max(0, Math.min(index, list.length)), 0, block);
|
||||
return block.id;
|
||||
}
|
||||
|
||||
/** Inserts a new block at an exact position instead of behind the selection. */
|
||||
export function insertBlockAt(document, type, parentId, index) {
|
||||
upgradeForType(document, type);
|
||||
return place(document, createBlock(type), parentId, index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves an existing block to an exact position. Unlike moveBlock this keeps the index, so a
|
||||
* block dragged between two others lands between them and not at the end of the container.
|
||||
*/
|
||||
export function moveBlockTo(document, selected, parentId, index) {
|
||||
const source = locate(document.blocks, selected);
|
||||
if (!source) return false;
|
||||
// Dropping a container into itself or into its own child would detach the branch.
|
||||
if (parentId === selected || (parentId !== null && locate(source.block.children || [], parentId))) return false;
|
||||
if (!dropList(document, parentId, source.block)) return false;
|
||||
const from = source.siblings, fromIndex = source.index;
|
||||
from.splice(fromIndex, 1);
|
||||
// Re-read: removing the block may have shifted the target list or emptied a parent.
|
||||
const list = dropList(document, parentId, source.block);
|
||||
if (!list) { from.splice(fromIndex, 0, source.block); return false; }
|
||||
// The index still counts in the list as it was before the block was taken out, so the shift
|
||||
// has to be settled first and only then clamped - the other way round an index pointing past
|
||||
// the end is clamped to the old length and then pulled one step too far forward.
|
||||
let at = index;
|
||||
if (list === from && fromIndex < index) at -= 1;
|
||||
at = Math.max(0, Math.min(at, list.length));
|
||||
list.splice(at, 0, source.block);
|
||||
return true;
|
||||
}
|
||||
|
||||
export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' };
|
||||
|
||||
// Called only inside a history transaction: all other fields and IDs stay untouched.
|
||||
// Die höchste Fassung, die dieser Editor kennt. Sie stand bisher als Liste an zwei Stellen und
|
||||
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
|
||||
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
|
||||
// nirgends. Eine Zahl, an einer Stelle.
|
||||
export const SCHEMA_VERSION = 19;
|
||||
const known = version => Number.isInteger(version) && version >= 1 && version <= SCHEMA_VERSION;
|
||||
|
||||
export function upgradeDocument(document, version = 2) {
|
||||
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].includes(document.schemaVersion) || ![2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].includes(version)) throw new Error('Unbekanntes Dokumentschema.');
|
||||
if (!known(document.schemaVersion) || !known(version) || version < 2) throw new Error('Unbekanntes Dokumentschema.');
|
||||
document.schemaVersion = Math.max(document.schemaVersion, version);
|
||||
return document;
|
||||
}
|
||||
@@ -126,7 +273,7 @@ export function effectiveDesign(block, device, key) {
|
||||
}
|
||||
|
||||
export function setDesign(document, id, device, key, value) {
|
||||
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
|
||||
if (!known(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
|
||||
if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.');
|
||||
const block = locate(document.blocks, id)?.block;
|
||||
if (!block) return;
|
||||
@@ -216,18 +363,29 @@ export function insertHero(document, selected) {
|
||||
return section.id;
|
||||
}
|
||||
|
||||
export function insertRow(document, columns, selected) {
|
||||
/** A row with its columns already in it. On its own an empty row is a box with nothing in it. */
|
||||
function buildRow(columns) {
|
||||
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'));
|
||||
return row;
|
||||
}
|
||||
|
||||
/** A row at an exact position, for a marked insertion point or a drop in the preview. */
|
||||
export function insertRowAt(document, columns, parentId, index) {
|
||||
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;
|
||||
return place(document, buildRow(columns), parentId, index);
|
||||
}
|
||||
|
||||
export function insertRow(document, columns, selected) {
|
||||
const row = buildRow(columns);
|
||||
upgradeDocument(document);
|
||||
// Same placement rule as a single element, so the row does not land somewhere else than the
|
||||
// toolbox promised.
|
||||
const target = insertionTarget(document, selected, 'row');
|
||||
return target ? place(document, row, target.parentId, target.index) : null;
|
||||
}
|
||||
|
||||
export function insertPreset(document, name, selected) {
|
||||
@@ -380,3 +538,13 @@ export function copyStyle(document, fromId, toId) {
|
||||
target.style = structuredClone(source.style);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** UI relevance only. Existing values remain editable, including on older documents. */
|
||||
export function relevantProperty(block, key, property) {
|
||||
if (Object.values(block.style ?? {}).some(values => values[key] !== undefined || values.hover?.[key] !== undefined)) return true;
|
||||
const container = isContainer(block);
|
||||
if (property.group === 'image') return ['image', 'gallery', 'logos', 'postlist'].includes(block.type);
|
||||
if (property.group === 'text' || key === 'textShadow') return !['image', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type);
|
||||
if (['flexDirection', 'flexWrap', 'justifyContent', 'alignItems', 'gridColumns', 'gap', 'rowGap', 'columnGap'].includes(key)) return container || ['button', 'menu', 'form', 'postlist', 'contact'].includes(block.type);
|
||||
return true;
|
||||
}
|
||||
+115
-15
@@ -50,10 +50,6 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.panel-section .eyebrow{margin-bottom:10px}
|
||||
.panel-section h2{font-size:14px;margin-bottom:12px}
|
||||
.panel-hint{font-size:11px;color:var(--muted);line-height:1.6;margin-bottom:17px}
|
||||
.library{display:grid;grid-template-columns:1fr 1fr;gap:7px}
|
||||
.library button{min-height:64px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink);font-size:10px}
|
||||
.library button:hover:not(:disabled){border-color:var(--accent);background:#f3f7e8}
|
||||
.library button span{font-size:19px;line-height:1}
|
||||
.panel-title{display:flex;justify-content:space-between;align-items:center}
|
||||
.panel-title h2{margin-bottom:0}
|
||||
.panel-title+.panel-hint{margin-top:10px}
|
||||
@@ -150,10 +146,33 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.number-control{display:grid;grid-template-columns:1fr 74px;gap:8px;align-items:center}
|
||||
.number-control input[type=range]{width:100%;min-height:24px;padding:0;accent-color:#437754}
|
||||
.number-control input[type=number]{font-size:12px;min-height:38px}
|
||||
.side-wrap{margin-bottom:6px}
|
||||
.side-control{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
||||
.side-cell label{margin:0 0 4px;font-size:10px;color:var(--muted)}
|
||||
.side-all{font-size:10px;margin-top:8px;padding:4px 6px}
|
||||
.side-wrap{margin-bottom:10px}
|
||||
/* Each field sits on the edge it controls, so the position is the label. */
|
||||
.side-box{display:grid;grid-template-columns:1fr 1fr 1fr;grid-template-rows:auto auto auto;gap:4px;align-items:center;justify-items:center;padding:7px;border:1px dashed var(--line);border-radius:8px;background:#fbfcf8}
|
||||
.side-top{grid-area:1/2}.side-left{grid-area:2/1}.side-right{grid-area:2/3}.side-bottom{grid-area:3/2}
|
||||
.side-box-corners .side-topLeft{grid-area:1/1}.side-box-corners .side-topRight{grid-area:1/3}
|
||||
.side-box-corners .side-bottomLeft{grid-area:3/1}.side-box-corners .side-bottomRight{grid-area:3/3}
|
||||
.side-cell{width:100%}
|
||||
.side-field{width:100%;min-width:0;text-align:center;padding:5px 2px;font-size:11px;min-height:30px}
|
||||
.side-field.is-inherited{color:var(--muted)}
|
||||
.side-link{grid-area:2/2;width:26px;height:26px;display:grid;place-items:center;padding:0;min-height:0;border:1px solid var(--line);border-radius:6px;background:#fff;font-size:12px;line-height:1;color:var(--muted);cursor:pointer}
|
||||
.side-link[aria-pressed=true]{border-color:var(--accent);background:#eef5e6;color:var(--accent)}
|
||||
.side-unit{display:flex;align-items:center;gap:7px;margin-top:7px}
|
||||
.side-unit label{margin:0;font-size:10px;color:var(--muted)}
|
||||
.side-unit-select{flex:1;min-width:0}
|
||||
|
||||
/* The device chips on a single option: which devices carry an own value, and switch to one. */
|
||||
.device-chips{display:flex;gap:2px;margin-left:auto}
|
||||
.device-chip{width:19px;height:19px;display:grid;place-items:center;padding:0;min-height:0;border:1px solid var(--line);border-radius:4px;background:#fff;color:var(--muted);font:700 9px/1 system-ui,sans-serif;cursor:pointer}
|
||||
.device-chip:hover{border-color:var(--accent)}
|
||||
.device-chip-own{background:#eef5e6;border-color:var(--accent);color:var(--accent)}
|
||||
.device-chip[aria-pressed=true]{outline:2px solid var(--ring,#b55b12);outline-offset:1px}
|
||||
.design-side-head{margin-bottom:5px}
|
||||
.design-side-head .design-group-label{margin:0}
|
||||
|
||||
/* A number field that can be dragged sideways. The cursor is the only announcement it needs. */
|
||||
.scrubbable{cursor:ew-resize}
|
||||
.scrubbable:focus{cursor:text}
|
||||
.design-actions{display:grid;gap:8px;margin-top:22px}
|
||||
.design-actions button{width:100%}
|
||||
|
||||
@@ -188,9 +207,6 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.editor-page-name h1{font-size:14px}
|
||||
.panel-section h2{font-size:13px;letter-spacing:0}
|
||||
.panel-section .eyebrow{font-size:9px;letter-spacing:1.4px}
|
||||
.library{gap:8px}
|
||||
.library button{min-height:68px;border-radius:9px;font-size:11px;gap:7px}
|
||||
.library button span{font-size:20px}
|
||||
.tree-item{font-size:12px;border-radius:6px}
|
||||
.right-panel label{font-size:12px}
|
||||
.right-panel input,.right-panel select,.right-panel textarea{font-size:12px;border-radius:7px}
|
||||
@@ -204,8 +220,92 @@ 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}
|
||||
|
||||
/* --------------------------------------------------------------------------------------------
|
||||
The working layer above the preview.
|
||||
|
||||
It sits in the editor document, not in the preview, and carries the same scale transform as
|
||||
the frame - so a rectangle measured inside the frame can be used here without conversion. The
|
||||
layer itself lets the mouse through; only the controls on it catch a click, and each of them
|
||||
is counter-scaled from JavaScript so it stays finger-sized when the preview is shrunk.
|
||||
-------------------------------------------------------------------------------------------- */
|
||||
.canvas-overlay{position:absolute;top:0;left:0;transform-origin:top left;pointer-events:none;overflow:hidden;z-index:2}
|
||||
.ov-box{position:absolute;pointer-events:none}
|
||||
.ov-hover{outline:1px dashed #7c8a80;outline-offset:-1px}
|
||||
.ov-select{outline:2px solid var(--ring,#b55b12);outline-offset:-2px}
|
||||
.ov-label{position:absolute;left:0;top:0;transform-origin:left bottom;background:var(--ring,#b55b12);color:#fff;font:700 10px/1 system-ui,sans-serif;letter-spacing:.3px;padding:4px 6px;border-radius:3px 3px 0 0;white-space:nowrap;translate:0 -100%}
|
||||
.ov-label{background:#5c6b62;font-weight:600}
|
||||
.ov-toolbar{position:absolute;display:flex;align-items:center;gap:2px;padding:3px;background:#243029;border-radius:7px;box-shadow:0 3px 12px #0000003d;pointer-events:auto}
|
||||
.ov-toolbar-name{color:#e7ece8;font:700 10px/1 system-ui,sans-serif;letter-spacing:.4px;padding:0 7px 0 5px;white-space:nowrap;max-width:150px;overflow:hidden;text-overflow:ellipsis;border-right:1px solid #ffffff24;margin-right:2px;align-self:stretch;display:flex;align-items:center}
|
||||
.ov-tool{width:26px;height:26px;display:grid;place-items:center;border:0;border-radius:5px;background:transparent;color:#e7ece8;font-size:13px;line-height:1;cursor:pointer;padding:0;min-height:0}
|
||||
.ov-tool:hover:not(:disabled){background:#3b4c42}
|
||||
.ov-tool:focus-visible{outline:2px solid #fff;outline-offset:-2px}
|
||||
.ov-tool-danger:hover:not(:disabled){background:#8d2f24}
|
||||
.ov-insert{position:absolute;display:grid;place-items:center;padding:0;min-height:0;border:2px solid #fff;border-radius:50%;background:var(--ring,#b55b12);color:#fff;font:700 14px/1 system-ui,sans-serif;cursor:pointer;box-shadow:0 2px 7px #0000003d;pointer-events:auto;opacity:.55;transition:opacity .12s}
|
||||
.ov-insert:hover,.ov-insert:focus-visible{opacity:1}
|
||||
.ov-drop{position:absolute;background:var(--ring,#b55b12);border-radius:2px;box-shadow:0 0 0 1px #fff8;pointer-events:none}
|
||||
.ov-drop-inside{background:color-mix(in srgb, var(--ring,#b55b12) 15%, transparent);border:2px dashed var(--ring,#b55b12);box-shadow:none}
|
||||
.ov-drop-label{position:absolute;pointer-events:none;background:#2c634a;color:#fff;border-radius:5px;padding:6px 9px;font:600 12px/1.4 system-ui,sans-serif;white-space:nowrap;box-shadow:0 2px 7px #0002}
|
||||
/* While something is being dragged the outlines would only add noise. */
|
||||
.canvas-overlay.is-dragging .ov-hover,.canvas-overlay.is-dragging .ov-toolbar,.canvas-overlay.is-dragging .ov-insert{display:none}
|
||||
|
||||
#library button[draggable=true]{cursor:grab}
|
||||
#library button[draggable=true]:active{cursor:grabbing}
|
||||
.insert-hint{background:color-mix(in srgb, var(--ring,#b55b12) 9%, #fff);border:1px solid var(--ring,#b55b12);border-radius:7px;padding:9px 11px;font-size:11px;line-height:1.5;margin:0 0 12px}
|
||||
.insert-hint .text-button{margin-left:4px}
|
||||
#toolbox.awaiting-insert .library button[data-add]:not(:disabled){box-shadow:inset 0 0 0 1px var(--ring,#b55b12)}
|
||||
|
||||
/* The reduced-motion setting is honoured: nothing here animates by itself anyway, but the
|
||||
fade on the insert points is a transition and is switched off with the rest. */
|
||||
@media(prefers-reduced-motion:reduce){.ov-insert{transition:none;opacity:1}}
|
||||
|
||||
/* --------------------------------------------------------------------------------------------
|
||||
The toolbox, grouped the way a page is built.
|
||||
|
||||
The first group is the scaffolding, the rest goes inside it. What does not fit where the next
|
||||
element would land is disabled and carries the reason in its title, so the rule is learned
|
||||
rather than merely enforced.
|
||||
-------------------------------------------------------------------------------------------- */
|
||||
.library-group + .library-group{margin-top:16px}
|
||||
.library-group-title{font-size:10px;font-weight:700;letter-spacing:1.1px;text-transform:uppercase;color:var(--muted);margin:0 0 7px}
|
||||
.library-group-hint{font-size:10px;line-height:1.5;color:var(--muted);margin:-3px 0 8px}
|
||||
.library-buttons{display:grid;grid-template-columns:1fr 1fr;gap:6px}
|
||||
.library-buttons button{display:flex;align-items:center;gap:8px;text-align:left;min-height:44px;padding:6px 8px;border:1px solid var(--line);border-radius:7px;background:#f7f9f2;color:var(--ink);font-size:11px;line-height:1.25}
|
||||
.library-preview{flex:none;display:grid;place-items:center;width:34px;height:23px;color:var(--accent);opacity:.85}
|
||||
.library-buttons button:disabled .library-preview{color:var(--muted);opacity:.6}
|
||||
.library-name{min-width:0}
|
||||
.library-buttons button:hover:not(:disabled){border-color:var(--accent);background:#fff}
|
||||
.library-buttons button:disabled{opacity:.4;background:transparent;cursor:not-allowed}
|
||||
.library-buttons button[data-row]{grid-column:span 2}
|
||||
@media(max-width:1150px){.library-buttons{grid-template-columns:1fr}.library-buttons button[data-row]{grid-column:span 1}}
|
||||
|
||||
#library-search{font-size:11px;min-height:36px;padding:9px;margin-bottom:4px}
|
||||
#library-found{margin:0 0 10px;min-height:0}
|
||||
|
||||
/* Flexible workspace: both panels use their full width, with accessible resize handles. */
|
||||
.editor-layout{grid-template-columns:var(--panel-left,280px) 6px minmax(280px,1fr) 6px var(--panel-right,320px)}
|
||||
.left-panel,.right-panel{min-width:0;overflow-x:hidden}
|
||||
.left-panel{container-type:inline-size}
|
||||
.left-panel>.section-launch{flex:none;padding:12px;border-bottom:1px solid var(--line)}
|
||||
.section-launch button{white-space:normal;font-size:12px;min-height:40px}
|
||||
.section-launch .field-help{margin:6px 0 0}
|
||||
.left-panel>.panel-section:not(.structure-section){max-height:55%;overflow:auto;flex-shrink:0}
|
||||
.panel-section{padding:16px 12px}
|
||||
.library-buttons{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.library-buttons button{min-width:0;white-space:normal;overflow-wrap:anywhere;gap:6px}
|
||||
.library-name{overflow-wrap:anywhere}
|
||||
.library-preview{width:28px}
|
||||
@container(max-width:270px){.library-buttons{grid-template-columns:minmax(0,1fr)}.library-buttons button[data-row]{grid-column:auto}}
|
||||
.canvas-scroll{padding:0}
|
||||
.panel-resizer{background:#e4e9df;cursor:col-resize;touch-action:none;position:relative;z-index:4}
|
||||
.panel-resizer::after{content:'';position:absolute;top:45%;left:1px;width:4px;height:36px;border-radius:4px;background:#7b8d80}
|
||||
.panel-resizer:hover,.panel-resizer:focus-visible{background:#b7cbbd;outline:2px solid var(--accent);outline-offset:-2px}
|
||||
.resizing-panels{cursor:col-resize;user-select:none}
|
||||
.resizing-panels iframe{pointer-events:none}
|
||||
.length-control,.number-control{grid-template-columns:minmax(0,1fr) 74px}
|
||||
.right-panel input,.right-panel select,.right-panel textarea{min-width:0;max-width:100%}
|
||||
.length-mode{grid-column:1/-1}
|
||||
.design-context-toggle{margin:10px 0;width:100%;white-space:normal}
|
||||
@media(max-width:850px){.panel-resizer{display:none}.editor-layout{display:flex}.left-panel,.right-panel{width:50%}.canvas-scroll{padding:0}.left-panel>.panel-section:not(.structure-section){max-height:50%}}
|
||||
@media(max-width:560px){.left-panel,.right-panel{width:100%;height:620px;max-height:620px}.canvas-toolbar{padding-inline:8px}.canvas-label{display:none}}
|
||||
+407
-33
@@ -1,7 +1,10 @@
|
||||
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 { EditorHistory, labels, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js';
|
||||
import { openMedia } from './media.js';
|
||||
import { createDesignPanel } from './design-panel.js';
|
||||
import { createRichText } from './rich-text.js';
|
||||
import { createCanvasOverlay } from './canvas-overlay.js';
|
||||
import { preview, rowPreview } from './library-previews.js';
|
||||
import { decorateContainers } from './preview-containers.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'];
|
||||
@@ -17,7 +20,12 @@ const mayScript = 'true' === root.dataset.canScript;
|
||||
let history, selected = null, version, revisionId, publishedRevisionId, csrfToken;
|
||||
let busy = false, conflict = false, previewTimer, previewController, previewSequence = 0;
|
||||
let device = 'desktop', propertyPanel = 'content', designTokens, designSchema, designPanel, pendingPreset = null;
|
||||
// The preview is drawn at a fixed width and scaled down to fit. The overlay above it needs the
|
||||
// same factor, so it is kept here instead of being recomputed from the transform string.
|
||||
let previewScale = 1, overlay = null, pendingInsert = null;
|
||||
const collapsed = new Set();
|
||||
let renderedProperties = null;
|
||||
let revealAfterPreview = null;
|
||||
|
||||
function notify(message = '', recover = false) {
|
||||
$('#message').textContent = message;
|
||||
@@ -61,13 +69,13 @@ function controls() {
|
||||
$('#nest').disabled ||= current.index === 0 || !canContain(current.siblings[current.index - 1], current.block);
|
||||
$('#outdent').disabled ||= !current.parent;
|
||||
}
|
||||
$('#library').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
||||
$('#rows').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
||||
updateLibrary();
|
||||
$('#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);
|
||||
document.querySelectorAll('[data-panel],[data-viewport],#tree-search,#library-search,#expand-all,#collapse-all').forEach(input => input.disabled = !ready || busy);
|
||||
$('[data-panel="design"]').disabled ||= !current;
|
||||
$('#select-page').disabled = !ready || busy;
|
||||
$('#add-root-section').disabled = !ready || busy;
|
||||
$('#open-media-library').disabled = !ready || busy;
|
||||
}
|
||||
|
||||
@@ -108,6 +116,7 @@ function renderTree() {
|
||||
|
||||
function choose(id, scroll = true) {
|
||||
if (busy) return;
|
||||
if (id !== selected && pendingInsert) setPendingInsert(null);
|
||||
selected = locate(history.document.blocks, id) ? id : null;
|
||||
blockPath(history.document.blocks, selected).slice(0, -1).forEach(block => collapsed.delete(block.id));
|
||||
if (!selected) propertyPanel = 'content';
|
||||
@@ -121,11 +130,15 @@ function highlight(scroll = false) {
|
||||
doc.querySelectorAll('.editor-selected').forEach(element => element.classList.remove('editor-selected'));
|
||||
const element = selected ? doc.getElementById(selected) : null;
|
||||
if (element) { element.classList.add('editor-selected'); if (scroll) element.scrollIntoView({ block: 'nearest' }); }
|
||||
overlay?.sync();
|
||||
}
|
||||
|
||||
function renderProperties() {
|
||||
const block = locate(history.document.blocks, selected)?.block;
|
||||
if (!block) propertyPanel = 'content';
|
||||
const context = `${selected ?? 'page'}:${propertyPanel}`;
|
||||
if (context !== renderedProperties) $('.right-panel').scrollTop = 0;
|
||||
renderedProperties = context;
|
||||
$('#properties-title').textContent = block ? blockLabel(block) : 'Seiteneinstellungen';
|
||||
const panel = $('#properties'); panel.replaceChildren();
|
||||
document.querySelectorAll('[data-panel]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.panel === propertyPanel)));
|
||||
@@ -170,6 +183,7 @@ function renderProperties() {
|
||||
}
|
||||
else current[key] = next;
|
||||
if (key === 'caption') upgradeDocument(doc, 3);
|
||||
if (key === 'click' || key === 'lightbox') upgradeDocument(doc, 19);
|
||||
if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
|
||||
}, `${selected || 'page'}:${key}`);
|
||||
if (key === 'decorative') renderProperties();
|
||||
@@ -260,6 +274,8 @@ function renderProperties() {
|
||||
field('Wert anzeigen', 'showValue', block.props.showValue, { checkbox: true });
|
||||
}
|
||||
if (block.type === 'slider') {
|
||||
field('Automatisch weiterblättern (Sekunden, 0 = aus)', 'autoplay', block.props.autoplay || 0, { number: true, help: 'Ab 3 Sekunden. Lädt ein kleines Skript, nur auf Seiten mit einem solchen Slider. Bei „weniger Bewegung“ im Betriebssystem bleibt er stehen, und die erste Berührung stoppt ihn endgültig.' });
|
||||
field('Punkte und Pfeile', 'dots', block.props.dots === true, { checkbox: true, help: 'Zeichnet der Browser selbst, ohne Skript. Wo er es noch nicht kann, bleibt ein Schiebefeld.' });
|
||||
for (const [key, label] of [['desktopVisible', 'Sichtbar auf Desktop'], ['tabletVisible', 'Sichtbar auf Tablet'], ['mobileVisible', 'Sichtbar auf Mobil']]) {
|
||||
field(label, key, block.props[key], { number: true, help: key === 'desktopVisible' ? 'Wie viele Folien gleichzeitig zu sehen sind, 1 bis 6.' : undefined });
|
||||
}
|
||||
@@ -279,13 +295,27 @@ function renderProperties() {
|
||||
field('Titel', 'title', block.props.title, { maxLength: 160 });
|
||||
field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true });
|
||||
}
|
||||
if (block.type === 'legal') {
|
||||
field('Beschriftung für Vorlesesoftware', 'label', block.props?.label || 'Rechtliches', { maxLength: 60, help: 'Der Baustein zeigt Impressum, Datenschutz und – sobald jemand gefragt wurde – den Weg zurück zur Einwilligung. Die Adressen stehen in den Einstellungen. Steht er im Fußbereich, entfällt die eingebaute Zeile am Seitenende.' });
|
||||
}
|
||||
if (block.type === 'postnav') {
|
||||
field('Beschriftung zurück', 'previousLabel', block.props.previousLabel, { maxLength: 60 });
|
||||
field('Beschriftung weiter', 'nextLabel', block.props.nextLabel, { maxLength: 60, help: 'Die Beiträge selbst sucht die Website beim Ausliefern - innerhalb derselben Kategorie.' });
|
||||
}
|
||||
if (block.type === 'postlist') {
|
||||
field('Anzahl Beiträge', 'count', block.props.count, { number: true, help: 'Zwischen 1 und 24. Die Beiträge selbst kommen beim Ausliefern dazu, nicht aus dem gespeicherten Inhalt.' });
|
||||
field('Nur Kategorie', 'category', block.props.category || '', { help: 'Adresse der Kategorie, zum Beispiel projekte. Leer lassen für alle Beiträge.' });
|
||||
field('Nur Schlagwort', 'tag', block.props.tag || '', { help: 'Adresse des Schlagworts. Leer lassen für alle Beiträge.' });
|
||||
field('Darstellung', 'layout', block.props.layout || 'karten', { choices: { karten: 'Karten (Raster mit Bild oben)', liste: 'Liste (Bild links, Text rechts)', kompakt: 'Kompakt (nur Titel und Datum)', wechsel: 'Wechselseitig (Bild mal links, mal rechts)' }, help: 'Dieselben Beiträge, drei Darstellungen. Karten für eine Übersicht, Liste für ein Archiv, Kompakt für eine Randspalte.' });
|
||||
field('Blättern statt abschneiden', 'paginate', block.props.paginate === true, { checkbox: true, help: 'Für eine Übersichtsseite. Eine Auswahl auf der Startseite lässt man aus.' });
|
||||
field('Datum und Autor zeigen', 'showMeta', block.props.showMeta !== false, { checkbox: true });
|
||||
field('Auszug zeigen', 'showExcerpt', block.props.showExcerpt !== false, { checkbox: true });
|
||||
field('Kategorie zeigen', 'showTerms', block.props.showTerms !== false, { checkbox: true, help: 'Alle drei aus: ein Raster aus Bild und Titel, wie es zu Projekten passt.' });
|
||||
}
|
||||
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
|
||||
if (block.type === 'image') {
|
||||
field('Beim Klick', 'click', block.props?.click || (block.props?.href ? 'link' : 'keine'), { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, help: 'Vergrößern öffnet das Bild über der Seite. Ohne JavaScript öffnet derselbe Klick die große Bilddatei – die Seite bleibt also in jedem Fall benutzbar.' });
|
||||
if ((block.props?.click || (block.props?.href ? 'link' : 'keine')) === 'link') field('Linkziel', 'href', block.props.href || '', { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse. Für ein Logo im Kopfbereich meist /.' });
|
||||
const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen';
|
||||
picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker);
|
||||
field('Bildadresse', 'src', block.props.src, { help: 'Vorhandene lokale Datei oder HTTPS-Adresse. Eine Mediathek folgt später.' });
|
||||
@@ -296,13 +326,14 @@ function renderProperties() {
|
||||
field('Bildunterschrift', 'caption', block.props.caption || '', { multiline: true, maxLength: 500 });
|
||||
}
|
||||
if (block.type === 'gallery') {
|
||||
field('Bilder vergrößerbar', 'lightbox', block.props?.lightbox === true, { checkbox: true, help: 'Ein Klick öffnet das Bild über der Seite, mit Blättern durch die Galerie. Die einzelnen Bilder brauchen dafür „Beim Klick: Bild vergrößern".' });
|
||||
const hint = document.createElement('p'); hint.className = 'panel-hint'; hint.textContent = `${block.children?.length || 0} Bilder · Bilder im Strukturbaum auswählen, beschriften und mit den Pfeilen sortieren. Spalten und Abstände stehen unter Design.`;
|
||||
const picker = document.createElement('button'); picker.className = 'primary'; picker.textContent = 'Galeriebilder auswählen';
|
||||
picker.addEventListener('click', () => chooseMedia(true)); panel.append(hint, picker);
|
||||
}
|
||||
if (block.type === 'section') {
|
||||
field('Farbwelt', 'tone', block.layout?.tone || 'paper', { layout: true, choices: { paper: 'Hell · Papier', mint: 'Hellgrün · Akzent', ink: 'Dunkel · Tinte' } });
|
||||
field('Inhaltsbreite', 'width', block.layout?.width || 'normal', { layout: true, choices: { normal: 'Normal (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.' });
|
||||
field('Inhaltsbreite', 'width', block.layout?.width || 'normal', { layout: true, choices: { normal: 'Websitebreite (aus dem Theme)', narrow: 'Schmal (800 px)', wide: 'Breit (1600 px)', full: 'Randlos (volle Breite)' }, help: 'Randlos nutzt die gesamte Sektion. Text und Überschriften füllen ihren Container; eine eigene Begrenzung stellst du unter Design → Größe ein.' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -522,14 +553,16 @@ function stopInlineEdit() {
|
||||
|
||||
function resizePreview() {
|
||||
const container = $('.canvas-scroll'), frame = $('#preview'), stage = $('#preview-stage');
|
||||
const width = { desktop: 1240, tablet: 768, mobile: 390 }[device];
|
||||
const padding = parseFloat(getComputedStyle(container).paddingLeft) * 2;
|
||||
const available = Math.max(100, container.clientWidth - padding);
|
||||
const width = { desktop: Math.max(1240, available), tablet: 768, mobile: 390 }[device];
|
||||
const scale = Math.min(1, available / width);
|
||||
const height = Math.max(550, (container.clientHeight - padding) / scale);
|
||||
previewScale = scale;
|
||||
frame.style.width = `${width}px`; frame.style.height = `${height}px`; frame.style.transform = `scale(${scale})`;
|
||||
stage.style.width = `${width * scale}px`; stage.style.height = `${height * scale}px`;
|
||||
frame.title = `Vorschau ${devices[device]} · ${width} px`;
|
||||
overlay?.sync();
|
||||
}
|
||||
|
||||
function schedulePreview() {
|
||||
@@ -551,10 +584,27 @@ async function updatePreview() {
|
||||
if (sequence !== previewSequence) return;
|
||||
const frame = $('#preview');
|
||||
const top = frame.contentWindow?.scrollY || 0;
|
||||
frame.onload = () => {
|
||||
if (sequence !== previewSequence) return;
|
||||
let angewandt = false;
|
||||
// Die Gestaltung muss sitzen, sobald der Kopf des Dokuments geparst ist. Vorher hing das an
|
||||
// frame.onload - und das feuert erst, wenn das letzte Bild geladen ist. Auf einer Seite mit
|
||||
// sechzehn Bildern stand die Vorschau dadurch sekundenlang ohne jede Gestaltung da: schwarze
|
||||
// Knöpfe statt roter, kein Hero, keine Farben. Genau das hat Umut im Bild gesehen.
|
||||
// Welches Dokument gerade im Rahmen steht. Ohne diesen Vergleich wird beim Warten auf das
|
||||
// neue Dokument das alte gestaltet - es hat dieselben Kennzeichen -, der Rahmen wird sichtbar
|
||||
// geschaltet, und gleich darauf ersetzt srcdoc den Inhalt durch den ungestalteten neuen.
|
||||
const altesDokument = frame.contentDocument;
|
||||
const anwenden = () => {
|
||||
if (angewandt || sequence !== previewSequence) return;
|
||||
const doc = frame.contentDocument;
|
||||
const style = doc.createElement('link'); style.rel = 'stylesheet'; style.href = '/assets/preview.css'; doc.head.append(style);
|
||||
if (!doc || doc === altesDokument || !doc.getElementById('kant-styles')) return;
|
||||
angewandt = true;
|
||||
const style = doc.createElement('link'); style.rel = 'stylesheet'; style.href = `/assets/preview.css?v=${new URL(import.meta.url).searchParams.get('v') ?? 'dev'}`;
|
||||
// Register before insertion: cached loads (or errors) can precede DOMContentLoaded.
|
||||
const styleReady = new Promise(resolve => {
|
||||
style.addEventListener('load', () => resolve(true), { once: true });
|
||||
style.addEventListener('error', () => resolve(false), { once: true });
|
||||
});
|
||||
doc.head.append(style);
|
||||
// The preview delivers the generated stylesheet as inert template text (see page.html.twig).
|
||||
// Applying it as a constructed stylesheet is not a source in the sense of the policy, so the
|
||||
// preview shows exactly what the published page shows, without a single blocked style.
|
||||
@@ -566,6 +616,22 @@ async function updatePreview() {
|
||||
}
|
||||
doc.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
const insert = event.target.closest('[data-editor-target]');
|
||||
if (insert && !busy && history) {
|
||||
stopInlineEdit();
|
||||
const id = insert.dataset.editorTarget;
|
||||
if (!locate(history.document.blocks, id)) return;
|
||||
choose(id, false);
|
||||
if (insert.hasAttribute('data-editor-row')) {
|
||||
setPendingInsert(null);
|
||||
selected = history.change(content => insertRowAt(content, Number(insert.dataset.editorRow), id, 0));
|
||||
propertyPanel = 'content'; changed();
|
||||
} else if (locate(history.document.blocks, id).block.type === 'row') {
|
||||
selected = history.change(content => insertBlockAt(content, 'column', id, 0));
|
||||
changed();
|
||||
} else setPendingInsert({ parentId: id, index: 0 });
|
||||
return;
|
||||
}
|
||||
if (inlineEdit) return;
|
||||
const element = event.target.closest('[id]');
|
||||
if (element && locate(history.document.blocks, element.id)) choose(element.id, false);
|
||||
@@ -577,10 +643,45 @@ async function updatePreview() {
|
||||
if (block && INLINE_TEXT[block.type]) { event.preventDefault(); choose(element.id, false); startInlineEdit(element.id); }
|
||||
});
|
||||
doc.addEventListener('submit', event => event.preventDefault());
|
||||
frame.contentWindow.scrollTo(0, top); highlight();
|
||||
$('#preview-status').textContent = 'Entwurf · Zum Auswählen direkt auf ein Element klicken.';
|
||||
// The head may arrive before the blocks. Decorate only once the complete DOM exists.
|
||||
const finish = () => {
|
||||
if (sequence !== previewSequence || doc !== frame.contentDocument) return;
|
||||
decorateContainers(doc, history.document);
|
||||
overlay?.attach();
|
||||
styleReady.then(loaded => {
|
||||
if (sequence !== previewSequence || doc !== frame.contentDocument) return;
|
||||
frame.contentWindow.scrollTo(0, top);
|
||||
highlight(revealAfterPreview === selected); revealAfterPreview = null;
|
||||
markInsertTarget();
|
||||
frame.style.visibility = '';
|
||||
$('#preview-status').textContent = loaded
|
||||
? 'Entwurf · Klicken wählt aus, Doppelklick bearbeitet Text, Elemente lassen sich hierher ziehen.'
|
||||
: 'Editor-Hilfsflächen konnten nicht geladen werden. Bitte den Editor neu laden.';
|
||||
});
|
||||
};
|
||||
if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', finish, { once: true }); else finish();
|
||||
};
|
||||
// Solange die Gestaltung fehlt, bleibt die Fläche leer statt falsch.
|
||||
frame.style.visibility = 'hidden';
|
||||
frame.srcdoc = html;
|
||||
// Sobald der Kopf da ist, wird gestaltet - geprüft Bild für Bild statt auf ein Ereignis zu
|
||||
// warten, das es für srcdoc nicht zuverlässig gibt.
|
||||
const warten = () => {
|
||||
if (angewandt || sequence !== previewSequence) return;
|
||||
anwenden();
|
||||
if (!angewandt) requestAnimationFrame(warten);
|
||||
};
|
||||
requestAnimationFrame(warten);
|
||||
// Das Netz darunter ist das Ladeereignis - und zwar ohne Uhr: bringt auch das fertig geladene
|
||||
// Dokument keine Gestaltung mit, stimmt etwas anderes nicht, und dann ist eine unfertige
|
||||
// Vorschau immer noch besser als eine unsichtbare.
|
||||
frame.onload = () => {
|
||||
anwenden();
|
||||
if (!angewandt) {
|
||||
frame.style.visibility = '';
|
||||
$('#preview-status').textContent = 'Vorschau ohne Gestaltung - bitte die Seite neu laden.';
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError' || sequence !== previewSequence) return;
|
||||
$('#preview-status').textContent = `Letzte gültige Vorschau: ${error.message}`;
|
||||
@@ -589,6 +690,7 @@ async function updatePreview() {
|
||||
|
||||
function changed() {
|
||||
if (!locate(history.document.blocks, selected)) selected = null;
|
||||
if (selected && !$('#preview').contentDocument?.getElementById(selected)) revealAfterPreview = selected;
|
||||
renderTree(); renderProperties(); controls(); schedulePreview();
|
||||
}
|
||||
|
||||
@@ -599,10 +701,11 @@ async function loadDesignSchema() {
|
||||
designSchema = await response.json();
|
||||
designPanel = createDesignPanel({
|
||||
schema: designSchema,
|
||||
onChange: () => { controls(); renderTree(); schedulePreview(); renderProperties(); },
|
||||
onChange: options => { controls(); renderTree(); schedulePreview(); if (!options?.keepPanel) renderProperties(); },
|
||||
onSavePreset: style => { pendingPreset = style; $('#preset-error').hidden = true; $('#preset-name').value = ''; $('#preset-dialog').showModal(); $('#preset-name').focus(); },
|
||||
getBlock: () => locate(history.document.blocks, selected)?.block,
|
||||
getDevice: () => device,
|
||||
onDevice: setViewport,
|
||||
getSelection: () => selected,
|
||||
getHistory: () => history,
|
||||
});
|
||||
@@ -641,16 +744,151 @@ async function save(publish = false) {
|
||||
finally { busy = false; controls(); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an element. Without a marked insertion point this behaves as it always did - behind the
|
||||
* selection. With one, set by the plus signs in the preview, it lands exactly there.
|
||||
*/
|
||||
function addElement(type) {
|
||||
if (busy || !history) return;
|
||||
const at = pendingInsert;
|
||||
setPendingInsert(null);
|
||||
selected = history.change(doc => at
|
||||
? insertBlockAt(doc, type, at.parentId, at.index) ?? insertBlock(doc, type, selected)
|
||||
: insertBlock(doc, type, selected));
|
||||
changed();
|
||||
}
|
||||
|
||||
/** Marks where the next added element goes, and says so instead of leaving it invisible. */
|
||||
function setPendingInsert(target) {
|
||||
pendingInsert = target;
|
||||
updateLibrary();
|
||||
$('#toolbox').classList.toggle('awaiting-insert', Boolean(target));
|
||||
$('#insert-hint').hidden = !target;
|
||||
markInsertTarget();
|
||||
if (target) {
|
||||
if ($('#toolbox').hidden) $('#toggle-library').click();
|
||||
$('#library-search').value = ''; filterLibrary();
|
||||
const name = target.parentId ? $('#preview').contentDocument?.getElementById(target.parentId)?.dataset.editorLabel || blockLabel(locate(history.document.blocks, target.parentId)?.block) : 'Seite';
|
||||
$('#insert-hint').firstChild.textContent = `Einfügen in ${name}. Wähle jetzt ein Element. `;
|
||||
$('#library-search').focus({ preventScroll: true });
|
||||
$('#library-search').scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
function markInsertTarget() {
|
||||
const doc = $('#preview').contentDocument;
|
||||
doc?.querySelectorAll('.editor-insert-target').forEach(node => node.classList.remove('editor-insert-target'));
|
||||
if (pendingInsert?.parentId) doc?.getElementById(pendingInsert.parentId)?.classList.add('editor-insert-target');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The toolbox.
|
||||
//
|
||||
// It is built here rather than listed in the template because it has to answer a question that
|
||||
// only the current selection can answer: what fits where the next element would go. A flat list
|
||||
// of every type offered a row on an empty page and a column next to nothing - the structure the
|
||||
// editor describes in its own hint was not the structure the buttons followed.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
function buildLibrary() {
|
||||
const library = $('#library');
|
||||
library.replaceChildren();
|
||||
for (const category of CATEGORIES) {
|
||||
const group = document.createElement('div'); group.className = 'library-group';
|
||||
const heading = document.createElement('h4'); heading.className = 'library-group-title'; heading.textContent = category.name;
|
||||
group.append(heading);
|
||||
if (category.hint) {
|
||||
const hint = document.createElement('p'); hint.className = 'library-group-hint'; hint.textContent = category.hint;
|
||||
group.append(hint);
|
||||
}
|
||||
const list = document.createElement('div'); list.className = 'library-buttons';
|
||||
for (const type of category.types) {
|
||||
// A row without columns is an empty box, so it is offered by column count instead - the
|
||||
// shape people actually want when they reach for a row.
|
||||
for (const columns of 'row' === type ? ROW_COLUMNS : [null]) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button'; button.draggable = true;
|
||||
button.dataset.add = type;
|
||||
if (columns) button.dataset.row = String(columns);
|
||||
const name = columns ? `${labels[type]}, ${columns} Spalten` : labels[type];
|
||||
// What is searched for: the name, the internal type and the group it sits in. Someone
|
||||
// looking for "slider" should not have to know it is filed under "Gruppen".
|
||||
button.dataset.search = `${name} ${type} ${category.name}`.toLocaleLowerCase('de');
|
||||
const picture = document.createElement('span');
|
||||
picture.className = 'library-preview';
|
||||
// Drawn, not written: the shapes come from this editor and carry no untrusted text.
|
||||
picture.innerHTML = columns ? rowPreview(columns) : preview(type);
|
||||
const text = document.createElement('span');
|
||||
text.className = 'library-name';
|
||||
text.textContent = name;
|
||||
button.append(picture, text);
|
||||
list.append(button);
|
||||
}
|
||||
}
|
||||
group.append(list);
|
||||
library.append(list.children.length ? group : document.createComment('leer'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Greys out what cannot go where the next element would land, and says where it will land when
|
||||
* it can. The answer comes from the same function the click follows, so the two cannot disagree.
|
||||
*/
|
||||
function updateLibrary() {
|
||||
const ready = Boolean(history);
|
||||
for (const button of $('#library').querySelectorAll('button')) {
|
||||
const type = button.dataset.add;
|
||||
if (!ready || busy) { button.disabled = true; button.removeAttribute('title'); continue; }
|
||||
const target = pendingInsert
|
||||
? (dropList(history.document, pendingInsert.parentId, { type }) ? pendingInsert : null)
|
||||
: insertionTarget(history.document, selected, type);
|
||||
button.disabled = !target;
|
||||
if (!target) { button.title = whyNot(type); continue; }
|
||||
const parent = target.parentId ? locate(history.document.blocks, target.parentId)?.block : null;
|
||||
button.title = `Wird in ${parent ? blockLabel(parent) : 'die Seite'} eingefügt.`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Filters the toolbox. A group without a hit disappears, so the list does not keep its gaps. */
|
||||
function filterLibrary() {
|
||||
const term = $('#library-search').value.trim().toLocaleLowerCase('de');
|
||||
let hits = 0;
|
||||
for (const group of $('#library').querySelectorAll('.library-group')) {
|
||||
let visible = 0;
|
||||
for (const button of group.querySelectorAll('button')) {
|
||||
const hit = '' === term || (button.dataset.search ?? '').includes(term);
|
||||
button.hidden = !hit;
|
||||
if (hit) visible++;
|
||||
}
|
||||
group.hidden = 0 === visible;
|
||||
hits += visible;
|
||||
}
|
||||
$('#library-found').textContent = '' === term ? '' : (0 === hits ? 'Kein Element mit diesem Namen.' : `${hits} von ${$('#library').querySelectorAll('button').length}`);
|
||||
}
|
||||
$('#library-search').addEventListener('input', filterLibrary);
|
||||
|
||||
$('#library').addEventListener('click', event => {
|
||||
const type = event.target.closest('[data-add]')?.dataset.add;
|
||||
if (!type || busy || !history) return;
|
||||
selected = history.change(doc => insertBlock(doc, type, selected)); changed();
|
||||
const button = event.target.closest('[data-add]');
|
||||
if (!button || button.disabled) return;
|
||||
if (button.dataset.row) {
|
||||
if (busy || !history) return;
|
||||
const at = pendingInsert;
|
||||
setPendingInsert(null);
|
||||
const id = history.change(doc => at
|
||||
? insertRowAt(doc, button.dataset.row, at.parentId, at.index)
|
||||
: insertRow(doc, button.dataset.row, selected));
|
||||
if (id) { selected = id; propertyPanel = 'design'; changed(); }
|
||||
return;
|
||||
}
|
||||
addElement(button.dataset.add);
|
||||
});
|
||||
// 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();
|
||||
$('#cancel-insert').addEventListener('click', () => setPendingInsert(null));
|
||||
$('#add-root-section').addEventListener('click', () => {
|
||||
if (busy || !history) return;
|
||||
stopInlineEdit();
|
||||
setPendingInsert(null);
|
||||
selected = history.change(doc => insertBlock(doc, 'section', selected));
|
||||
propertyPanel = 'content'; changed();
|
||||
});
|
||||
$('#presets').addEventListener('click', event => {
|
||||
const name = event.target.closest('[data-preset]')?.dataset.preset;
|
||||
@@ -678,8 +916,13 @@ document.querySelectorAll('[data-panel]').forEach(button => button.addEventListe
|
||||
$('#structure').addEventListener('dragstart', event => {
|
||||
if (busy) { event.preventDefault(); return; }
|
||||
const id = event.target.closest('[data-block-id]')?.dataset.blockId;
|
||||
if (id) { event.dataTransfer.setData('text/plain', id); event.dataTransfer.effectAllowed = 'move'; }
|
||||
if (!id) return;
|
||||
event.dataTransfer.setData('text/plain', id);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
const block = locate(history.document.blocks, id)?.block;
|
||||
if (block) overlay.startDrag({ kind: 'move', type: block.type, block });
|
||||
});
|
||||
$('#structure').addEventListener('dragend', () => overlay.endDrag());
|
||||
$('#structure').addEventListener('dragover', event => { if (event.target.closest('[data-block-id]') && !busy) event.preventDefault(); });
|
||||
$('#structure').addEventListener('drop', event => {
|
||||
event.preventDefault(); if (busy) return;
|
||||
@@ -689,17 +932,86 @@ $('#structure').addEventListener('drop', event => {
|
||||
history.change(doc => moveBlock(doc, source, target, isContainer(locate(doc.blocks, target)?.block)));
|
||||
selected = source; changed();
|
||||
});
|
||||
$('#duplicate').addEventListener('click', () => { selected = history.change(doc => duplicateBlock(doc, selected)); changed(); });
|
||||
$('#delete').addEventListener('click', () => {
|
||||
selected = history.change(doc => { const item = locate(doc.blocks, selected); if (!item) return null; item.siblings.splice(item.index, 1); return item.siblings[Math.min(item.index, item.siblings.length - 1)]?.id || item.parent?.id || null; }); changed();
|
||||
});
|
||||
for (const [id, direction] of [['move-up', -1], ['move-down', 1]]) $(`#${id}`).addEventListener('click', () => {
|
||||
history.change(doc => { const item = locate(doc.blocks, selected); const next = item.index + direction; if (next < 0 || next >= item.siblings.length) return; [item.siblings[item.index], item.siblings[next]] = [item.siblings[next], item.siblings[item.index]]; }); changed();
|
||||
});
|
||||
// The actions a block offers. The sidebar buttons and the toolbar in the preview are two ways
|
||||
// into the same functions, so the two can never drift apart.
|
||||
const actions = {
|
||||
duplicate: id => { selected = history.change(doc => duplicateBlock(doc, id)); changed(); },
|
||||
delete: id => {
|
||||
selected = history.change(doc => {
|
||||
const item = locate(doc.blocks, id);
|
||||
if (!item) return null;
|
||||
item.siblings.splice(item.index, 1);
|
||||
return item.siblings[Math.min(item.index, item.siblings.length - 1)]?.id || item.parent?.id || null;
|
||||
});
|
||||
changed();
|
||||
},
|
||||
move: (id, direction) => {
|
||||
history.change(doc => {
|
||||
const item = locate(doc.blocks, id);
|
||||
const next = item.index + direction;
|
||||
if (next < 0 || next >= item.siblings.length) return;
|
||||
[item.siblings[item.index], item.siblings[next]] = [item.siblings[next], item.siblings[item.index]];
|
||||
});
|
||||
selected = id; changed();
|
||||
},
|
||||
parent: id => choose(locate(history.document.blocks, id)?.parent?.id ?? null),
|
||||
};
|
||||
|
||||
$('#duplicate').addEventListener('click', () => actions.duplicate(selected));
|
||||
$('#delete').addEventListener('click', () => actions.delete(selected));
|
||||
for (const [id, direction] of [['move-up', -1], ['move-down', 1]]) $(`#${id}`).addEventListener('click', () => actions.move(selected, direction));
|
||||
$('#nest').addEventListener('click', () => { history.change(doc => { const item = locate(doc.blocks, selected); const previous = item.siblings[item.index - 1]; if (previous) moveBlock(doc, selected, previous.id, true); }); changed(); });
|
||||
$('#outdent').addEventListener('click', () => {
|
||||
history.change(doc => { const item = locate(doc.blocks, selected); if (!item?.parent) return; const parent = locate(doc.blocks, item.parent.id); item.siblings.splice(item.index, 1); parent.siblings.splice(parent.index + 1, 0, item.block); }); changed();
|
||||
});
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The preview as a working surface: outline, element toolbar, insert points, drag and drop.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
overlay = createCanvasOverlay({
|
||||
stage: $('#preview-stage'),
|
||||
frame: $('#preview'),
|
||||
getScale: () => previewScale,
|
||||
getDocument: () => history.document,
|
||||
getSelected: () => selected,
|
||||
onAction: (action, id) => {
|
||||
if (!id || busy || !history) return;
|
||||
if (action === 'up') actions.move(id, -1);
|
||||
else if (action === 'down') actions.move(id, 1);
|
||||
else actions[action]?.(id);
|
||||
},
|
||||
onInsert: (id, where) => {
|
||||
const item = locate(history.document.blocks, id);
|
||||
if (!item) return;
|
||||
setPendingInsert({ parentId: item.parent?.id ?? null, index: item.index + (where === 'after' ? 1 : 0) });
|
||||
},
|
||||
onDrop: (payload, plan) => {
|
||||
if (busy || !history) return;
|
||||
if (payload.kind === 'add') {
|
||||
const id = history.change(doc => payload.columns
|
||||
? insertRowAt(doc, payload.columns, plan.parentId, plan.index)
|
||||
: insertBlockAt(doc, payload.type, plan.parentId, plan.index));
|
||||
if (id) { selected = id; changed(); }
|
||||
return;
|
||||
}
|
||||
const moved = history.change(doc => moveBlockTo(doc, payload.block.id, plan.parentId, plan.index) ? payload.block.id : null);
|
||||
if (moved) { selected = moved; changed(); }
|
||||
},
|
||||
});
|
||||
|
||||
// Elements are dragged out of the toolbox straight onto the page. The click route stays, so the
|
||||
// toolbox remains usable with the keyboard alone.
|
||||
$('#library').addEventListener('dragstart', event => {
|
||||
const button = event.target.closest('[data-add]');
|
||||
if (!button || button.disabled || busy || !history) { event.preventDefault(); return; }
|
||||
const type = button.dataset.add;
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
event.dataTransfer.setData('text/plain', type);
|
||||
// A stand-in carrying only the type: the drop rules ask nothing else of a block, and the real
|
||||
// one does not exist until it lands.
|
||||
overlay.startDrag({ kind: 'add', type, columns: button.dataset.row ?? null, block: { type } });
|
||||
});
|
||||
$('#library').addEventListener('dragend', () => overlay.endDrag());
|
||||
|
||||
$('#select-page').addEventListener('click', () => choose(null));
|
||||
$('#undo').addEventListener('click', () => { history.undo(); changed(); });
|
||||
$('#redo').addEventListener('click', () => { history.redo(); changed(); });
|
||||
@@ -736,12 +1048,73 @@ $('#download').addEventListener('click', () => {
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(history.document, null, 2)], { type: 'application/json' }));
|
||||
const link = document.createElement('a'); link.href = url; link.download = 'kant-entwurf.json'; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
});
|
||||
document.querySelectorAll('[data-viewport]').forEach(button => button.addEventListener('click', () => {
|
||||
document.querySelectorAll('[data-viewport]').forEach(item => item.setAttribute('aria-pressed', String(item === button)));
|
||||
$('#preview').className = `viewport-${button.dataset.viewport}`;
|
||||
device = button.dataset.viewport; history.mergeKey = null; resizePreview(); renderProperties(); controls();
|
||||
}));
|
||||
/**
|
||||
* Switches the preview size and the device the design panel edits. Both move together on
|
||||
* purpose: editing the mobile value while the preview shows desktop means changing something
|
||||
* you cannot see.
|
||||
*/
|
||||
function setViewport(name) {
|
||||
if (!devices[name]) return;
|
||||
document.querySelectorAll('[data-viewport]').forEach(item => item.setAttribute('aria-pressed', String(item.dataset.viewport === name)));
|
||||
$('#preview').className = `viewport-${name}`;
|
||||
device = name;
|
||||
if (history) history.mergeKey = null;
|
||||
resizePreview(); renderProperties(); controls();
|
||||
}
|
||||
document.querySelectorAll('[data-viewport]').forEach(button => button.addEventListener('click', () => setViewport(button.dataset.viewport)));
|
||||
new ResizeObserver(resizePreview).observe($('.canvas-scroll'));
|
||||
// Panel sizes are workspace preferences, independent of document history and publication.
|
||||
const panelSizes = { left: 280, right: 320 };
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('kantsites.panel-widths'));
|
||||
for (const side of ['left', 'right']) if (Number.isFinite(saved?.[side])) panelSizes[side] = saved[side];
|
||||
} catch { /* Storage can be unavailable; resizing still works for this session. */ }
|
||||
const panelMinimum = { left: 240, right: 280 };
|
||||
function layoutPanels() {
|
||||
let left = Math.max(240, Math.min(560, panelSizes.left));
|
||||
let right = Math.max(280, Math.min(560, panelSizes.right));
|
||||
const budget = Math.max(520, $('.editor-layout').clientWidth - 292);
|
||||
const excess = Math.max(0, left + right - budget);
|
||||
const spare = left + right - 520;
|
||||
if (excess && spare) { left -= excess * (left - 240) / spare; right -= excess * (right - 280) / spare; }
|
||||
for (const [side, size] of Object.entries({ left, right })) {
|
||||
root.style.setProperty(`--panel-${side}`, `${size}px`);
|
||||
const handle = $(`[data-resize="${side}"]`);
|
||||
handle.setAttribute('aria-valuenow', String(Math.round(size)));
|
||||
handle.setAttribute('aria-valuemax', String(Math.floor(Math.min(560, budget - (side === 'left' ? right : left)))));
|
||||
handle.setAttribute('aria-valuetext', `${Math.round(size)} Pixel`);
|
||||
}
|
||||
}
|
||||
const persistPanels = () => { try { localStorage.setItem('kantsites.panel-widths', JSON.stringify(panelSizes)); } catch {} };
|
||||
for (const handle of document.querySelectorAll('[data-resize]')) {
|
||||
const side = handle.dataset.resize, direction = side === 'left' ? 1 : -1;
|
||||
let drag = null;
|
||||
const setSize = value => {
|
||||
panelSizes[side] = Math.max(panelMinimum[side], Math.min(Number(handle.getAttribute('aria-valuemax')), value));
|
||||
layoutPanels();
|
||||
};
|
||||
handle.addEventListener('pointerdown', event => {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault(); handle.focus();
|
||||
drag = { x: event.clientX, size: Number(handle.getAttribute('aria-valuenow')) };
|
||||
handle.setPointerCapture(event.pointerId); root.classList.add('resizing-panels');
|
||||
});
|
||||
handle.addEventListener('pointermove', event => { if (drag) setSize(drag.size + direction * (event.clientX - drag.x)); });
|
||||
const stop = () => { drag = null; root.classList.remove('resizing-panels'); persistPanels(); };
|
||||
handle.addEventListener('pointerup', stop);
|
||||
handle.addEventListener('pointercancel', stop);
|
||||
handle.addEventListener('lostpointercapture', stop);
|
||||
handle.addEventListener('dblclick', () => { panelSizes[side] = side === 'left' ? 280 : 320; layoutPanels(); persistPanels(); });
|
||||
handle.addEventListener('keydown', event => {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const current = Number(handle.getAttribute('aria-valuenow'));
|
||||
setSize(event.key === 'Home' ? panelMinimum[side] : event.key === 'End' ? 560 : current + (event.key === 'ArrowRight' ? 1 : -1) * direction * (event.shiftKey ? 40 : 10));
|
||||
persistPanels();
|
||||
});
|
||||
}
|
||||
new ResizeObserver(layoutPanels).observe($('.editor-layout'));
|
||||
layoutPanels();
|
||||
window.addEventListener('keydown', event => {
|
||||
if (!history || busy || !(event.ctrlKey || event.metaKey)) return;
|
||||
const key = event.key.toLowerCase();
|
||||
@@ -751,4 +1124,5 @@ window.addEventListener('keydown', event => {
|
||||
if (key === 'z' || key === 'y') { event.preventDefault(); (event.shiftKey || key === 'y') ? history.redo() : history.undo(); changed(); }
|
||||
});
|
||||
window.addEventListener('beforeunload', event => { if (history?.dirty || busy) { event.preventDefault(); event.returnValue = ''; } });
|
||||
buildLibrary();
|
||||
load();
|
||||
@@ -24,7 +24,7 @@
|
||||
.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-glow:hover { box-shadow: 0 0 0 4px color-mix(in srgb,var(--accent) 18%,transparent), 0 10px 30px color-mix(in srgb,var(--ink) 16%,transparent) }
|
||||
.fx-hover-tilt { transition: transform .2s ease }
|
||||
.fx-hover-tilt:hover { transform: rotate(-1.2deg) scale(1.01) }
|
||||
.fx-hover-grow-border { transition: box-shadow .2s ease }
|
||||
@@ -36,3 +36,73 @@
|
||||
@keyframes kant-zoom { from { opacity: 0; transform: scale(.92) } to { opacity: 1; transform: none } }
|
||||
@keyframes kant-flip { from { opacity: 0; transform: perspective(800px) rotateX(-12deg) } to { opacity: 1; transform: none } }
|
||||
@keyframes kant-unblur { from { opacity: 0; filter: blur(8px) } to { opacity: 1; filter: none } }
|
||||
|
||||
/* Beitragskarten und Knöpfe, ergänzt am 13.09.2026.
|
||||
|
||||
Umut hat gefragt, wo der Zeigereffekt bleibt: eine Karte, die sich beim Zeigen nicht rührt,
|
||||
wirkt tot. Angehoben wird die ganze Karte, das Bild zieht leicht auf, und der Hinweis darüber
|
||||
blendet ein. Alles innerhalb der Bewegungsschranke - wer weniger Bewegung eingestellt hat,
|
||||
bekommt den Hinweis trotzdem, nur ohne Fahrt. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.post-card { transition: transform .28s ease, box-shadow .28s ease, border-color .28s ease }
|
||||
/* Eine Zeile in der kompakten Liste hebt sich nicht - sie ist keine Karte. */
|
||||
.post-list-kompakt .post-card:hover, .post-list-kompakt .post-card:focus-within { transform: none; box-shadow: none; border-color: var(--accent) }
|
||||
/* Wechselseitig: die Karte selbst bleibt stehen, nur das Bild zieht auf. */
|
||||
.post-list-wechsel .post-card:hover, .post-list-wechsel .post-card:focus-within { transform: none; box-shadow: none }
|
||||
.post-list-wechsel .post-card h3 a { transition: color .2s ease }
|
||||
.post-list-wechsel .post-card:hover h3 a, .post-list-wechsel .post-card:focus-within h3 a { color: var(--accent) }
|
||||
.post-card:hover, .post-card:focus-within {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 18px 50px -16px color-mix(in srgb, var(--ink) 28%, transparent);
|
||||
border-color: transparent;
|
||||
}
|
||||
.post-image img { transition: transform .5s ease }
|
||||
.post-card:hover .post-image img, .post-card:focus-within .post-image img { transform: scale(1.06) }
|
||||
.post-view { transition: opacity .3s ease }
|
||||
.post-nav-link { transition: transform .2s ease, border-color .2s ease }
|
||||
.post-nav-link:hover, .post-nav-link:focus-visible { transform: translateY(-3px); border-color: var(--accent) }
|
||||
|
||||
/* Der Knopf hebt sich beim Zeigen um zwei Pixel - dieselbe Geste wie auf der Karte. */
|
||||
.button { transition: transform .2s ease, background-color .2s ease, border-color .2s ease }
|
||||
.button:hover { transform: translateY(-2px) }
|
||||
}
|
||||
|
||||
/* Einblenden beim Hineinscrollen, ergänzt am 13.09.2026.
|
||||
|
||||
Bisher liefen die Einblendungen beim Laden der Seite - was unterhalb der Falz stand, war
|
||||
fertig animiert, bevor es jemand gesehen hat. Mit einer Scroll-Zeitachse läuft dieselbe
|
||||
Animation genau dann, wenn das Element in den Blick kommt, und zwar ohne eine Zeile
|
||||
JavaScript: `animation-timeline: view()` bindet den Ablauf an die Sichtbarkeit.
|
||||
|
||||
Drei Bedingungen, alle bewusst:
|
||||
- Hinter der Bewegungsschranke: wer weniger Bewegung eingestellt hat, bekommt keine.
|
||||
- Hinter `@supports`: Browser ohne Scroll-Zeitachse (heute Firefox und Safari) behalten die
|
||||
bisherige Fassung - die Animation läuft beim Laden. Niemand sieht deshalb weniger Inhalt.
|
||||
- `animation-range` endet früh (`cover 26%`): die Bewegung ist vorbei, bevor das Element in
|
||||
der Mitte des Bildschirms steht. Eine Einblendung, die beim Lesen noch läuft, stört. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@supports (animation-timeline: view()) {
|
||||
/* Eigene Kurven für den Scrollweg: **nur Bewegung, keine Deckkraft**.
|
||||
Eine Scroll-Zeitachse ist umkehrbar - ein Abschnitt außerhalb des Blicks steht wieder am
|
||||
Anfang. Alles unterhalb der Falz wäre also dauerhaft blass, und blasser Text ist nicht
|
||||
weniger Effekt, sondern schlechterer Kontrast: #3d4d5c bei 85 Prozent ergibt 3,86:1
|
||||
statt 7,1:1 - axe meldet das zu Recht. Der Weg trägt den Effekt allein. */
|
||||
@keyframes kant-scroll-rise { from { transform: translateY(20px) } to { transform: none } }
|
||||
@keyframes kant-scroll-fade { from { transform: translateY(10px) } to { transform: none } }
|
||||
|
||||
.fx-entrance-fade, .fx-entrance-rise, .fx-entrance-sink, .fx-entrance-left,
|
||||
.fx-entrance-right, .fx-entrance-zoom, .fx-entrance-flip, .fx-entrance-blur {
|
||||
animation-name: kant-scroll-rise;
|
||||
animation-timeline: view();
|
||||
animation-range: entry 10% cover 28%;
|
||||
animation-duration: 1ms; /* Die Zeitachse bestimmt den Ablauf, nicht die Uhr. */
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
.fx-entrance-fade { animation-name: kant-scroll-fade }
|
||||
/* Die Verzögerungsstufen sind auf einer Scroll-Zeitachse eine Verschiebung im Weg,
|
||||
nicht in der Zeit - sonst würden sie gar nicht wirken. */
|
||||
.fx-delay-short { animation-range: entry 16% cover 32% }
|
||||
.fx-delay-medium { animation-range: entry 22% cover 36% }
|
||||
.fx-delay-long { animation-range: entry 28% cover 40% }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# Schriften in diesem Ordner
|
||||
|
||||
Hier liegen die Schriftdateien, die diese Website **selbst ausliefert**. Nichts davon wird zur
|
||||
Laufzeit von einem fremden Server geholt: keine Verbindung zu `fonts.googleapis.com`, keine
|
||||
IP-Adresse eines Besuchers, die dabei an einen Dritten geht.
|
||||
|
||||
## Woher sie kommen
|
||||
|
||||
`php bin/console cms:fonts:fetch` (oder der Knopf „Schriften auf diesen Server holen" im
|
||||
Designbereich) lädt die im Design gewählten Schnitte einmalig von Google Fonts herunter, behält
|
||||
nur die lateinischen Zeichenbereiche und schreibt `fonts.css` mit den passenden
|
||||
`@font-face`-Regeln daneben.
|
||||
|
||||
## Lizenz
|
||||
|
||||
**Lato** steht unter der SIL Open Font License 1.1 – der vollständige Text liegt als
|
||||
`lato-OFL.txt` daneben, wie die Lizenz es verlangt. Copyright (c) 2010–2014 tyPoland Łukasz
|
||||
Dziedzic, Reserved Font Name „Lato". Die Lizenz erlaubt ausdrücklich, die Dateien mitzuliefern und
|
||||
von einem eigenen Server auszuliefern; verboten ist der Verkauf der Schrift für sich genommen und
|
||||
die Verwendung des reservierten Namens für eine geänderte Fassung.
|
||||
|
||||
Wer hier andere Schriften ablegt, prüft deren Lizenz selbst: nicht jede Schrift, die Google Fonts
|
||||
ausliefert, steht unter der OFL – einige stehen unter der Apache-Lizenz 2.0, und eine gekaufte
|
||||
Schrift eines Schriftenhauses darf meist gar nicht weitergegeben werden. Die Lizenzdatei gehört in
|
||||
jedem Fall daneben.
|
||||
@@ -0,0 +1,12 @@
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Lato';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/lato-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Lato';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/lato-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Lato';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/lato-700-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Lato';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/lato-700-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Lato';font-style:normal;font-weight:900;font-display:swap;src:url(/assets/fonts/lato-900-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Lato';font-style:normal;font-weight:900;font-display:swap;src:url(/assets/fonts/lato-900-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright (c) 2010-2014 by tyPoland Lukasz Dziedzic (team@latofonts.com) with Reserved Font Name "Lato"
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Line art for the toolbox.
|
||||
*
|
||||
* Flatsome ships a PNG per element. Drawings are better here: they are a few hundred bytes
|
||||
* instead of a folder of bitmaps, they take the colour of the surface they sit on, and they stay
|
||||
* sharp on a screen of any density. Every shape lives in the same 24x16 field so the entries
|
||||
* line up in the grid.
|
||||
*
|
||||
* Each value is the inside of an `<svg>`; `stroke` and `fill` come from the wrapper, so a shape
|
||||
* only says what it is, not what colour it has.
|
||||
*/
|
||||
|
||||
/** A filled bar, the building block most of these are made of. */
|
||||
const bar = (x, y, width, height = 2) => `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="${Math.min(height, 2) / 2}" fill="currentColor" stroke="none"/>`;
|
||||
/** Several bars under each other, as a paragraph of text. */
|
||||
const lines = (...widths) => widths.map((width, index) => bar(3, 4 + index * 3.4, width, 1.7)).join('');
|
||||
|
||||
export const PREVIEWS = {
|
||||
// Aufbau
|
||||
section: '<rect x="1" y="3" width="22" height="10" rx="1.5" fill="currentColor" opacity=".14" stroke="none"/><rect x="1" y="3" width="22" height="10" rx="1.5"/>',
|
||||
row: '<rect x="1" y="3" width="22" height="10" rx="1.5"/><path d="M12 3.6v8.8"/>',
|
||||
column: '<rect x="8" y="1.5" width="8" height="13" rx="1.5"/>',
|
||||
|
||||
// Inhalt
|
||||
heading: `${bar(3, 3.6, 18, 3)}${bar(3, 9, 11, 2)}`,
|
||||
text: lines(18, 16, 11),
|
||||
image: '<rect x="2" y="2.5" width="20" height="11" rx="1.5"/><circle cx="7.5" cy="6.5" r="1.4" fill="currentColor" stroke="none"/><path d="M3.2 12l5-4.4 3.6 3.1 3-2.6 5 3.9"/>',
|
||||
button: '<rect x="4" y="4.5" width="16" height="7" rx="2"/>',
|
||||
icon: '<path d="M12 2.6l2.7 5.6 6 .8-4.4 4.1 1.1 5.8" transform="scale(.86) translate(2 .4)"/><path d="M12 2.6l-2.7 5.6-6 .8 4.4 4.1-1.1 5.8" transform="scale(.86) translate(2 .4)"/>',
|
||||
divider: '<path d="M2 8h20"/>',
|
||||
spacer: '<path d="M3 3h18M3 13h18"/><path d="M12 5.6v4.8M10.4 6.8L12 5.2l1.6 1.6M10.4 9.2L12 10.8l1.6-1.6"/>',
|
||||
|
||||
// Bausteine
|
||||
iconcard: `<rect x="3" y="2.5" width="4.5" height="4.5" rx="1.2"/>${bar(3, 9, 12, 1.9)}${bar(3, 12, 8, 1.5)}`,
|
||||
stat: `${bar(3, 3, 9, 5)}${bar(3, 10.5, 14, 1.7)}`,
|
||||
quote: `<path d="M3.4 7.6c0-2 1.3-3.2 2.9-3.4M8.4 7.6c0-2 1.3-3.2 2.9-3.4"/><path d="M3.4 7.6h2.9v3.2H3.4zM8.4 7.6h2.9v3.2H8.4z"/>${bar(14, 5.6, 7, 1.6)}${bar(14, 9, 5, 1.6)}`,
|
||||
cta: `${bar(2.5, 4, 12, 2.4)}<rect x="2.5" y="8.6" width="9" height="5" rx="1.6"/><rect x="14.5" y="8.6" width="7" height="5" rx="1.6" fill="currentColor" stroke="none"/>`,
|
||||
contact: '<rect x="2.5" y="3.5" width="19" height="9" rx="1.5"/><path d="M2.9 4.4l9.1 5 9.1-5"/>',
|
||||
logos: [2.5, 8, 13.5, 19].map(x => `<rect x="${x}" y="5.5" width="4.5" height="5" rx="1.2"/>`).join(''),
|
||||
progress: '<rect x="2" y="6" width="20" height="4" rx="2"/><rect x="2" y="6" width="12" height="4" rx="2" fill="currentColor" stroke="none"/>',
|
||||
|
||||
// Gruppen
|
||||
gallery: [2.5, 12.5].flatMap(x => [2.5, 8.5].map(y => `<rect x="${x}" y="${y}" width="9" height="5" rx="1.2"/>`)).join(''),
|
||||
accordion: '<rect x="2.5" y="2" width="19" height="4" rx="1.2"/><path d="M18 3.6l1.3 1.3 1.3-1.3"/><rect x="2.5" y="7.5" width="19" height="3" rx="1.2"/><rect x="2.5" y="12" width="19" height="3" rx="1.2"/>',
|
||||
accordionitem: '<rect x="2.5" y="5.5" width="19" height="5" rx="1.2"/><path d="M17.6 7.4l1.3 1.3 1.3-1.3"/>',
|
||||
tabs: '<rect x="2.5" y="2" width="6" height="3.5" rx="1" fill="currentColor" stroke="none"/><rect x="9.5" y="2" width="6" height="3.5" rx="1"/><rect x="2.5" y="6.5" width="19" height="7.5" rx="1.2"/>',
|
||||
tabitem: '<rect x="7" y="2.5" width="10" height="4" rx="1" fill="currentColor" stroke="none"/><rect x="2.5" y="7.5" width="19" height="6" rx="1.2"/>',
|
||||
slider: '<rect x="5.5" y="3" width="13" height="10" rx="1.5"/><path d="M3.4 6.6L1.6 8l1.8 1.4M20.6 6.6L22.4 8l-1.8 1.4"/>',
|
||||
|
||||
// Seite und Daten
|
||||
postlist: '<rect x="2.5" y="2.5" width="7" height="5" rx="1.2"/><rect x="2.5" y="8.5" width="7" height="5" rx="1.2"/>'
|
||||
+ `${bar(11.5, 3.2, 10, 1.6)}${bar(11.5, 6, 7, 1.4)}${bar(11.5, 9.2, 10, 1.6)}${bar(11.5, 12, 7, 1.4)}`,
|
||||
postnav: '<path d="M9.4 4.6L6 8l3.4 3.4M14.6 4.6L18 8l-3.4 3.4"/><path d="M6.4 8h3.6M14 8h3.6"/>',
|
||||
breadcrumb: `${bar(2, 7.2, 5, 1.7)}<path d="M8.6 6.3l1.6 1.7-1.6 1.7"/>${bar(11.6, 7.2, 4, 1.7)}<path d="M17.2 6.3l1.6 1.7-1.6 1.7"/>${bar(20.2, 7.2, 2, 1.7)}`,
|
||||
menu: `${bar(2, 7.2, 5, 1.8)}${bar(9, 7.2, 6, 1.8)}${bar(17, 7.2, 5, 1.8)}`,
|
||||
form: '<rect x="2.5" y="2" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="6.8" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="11.6" width="8" height="3.4" rx="1.2" fill="currentColor" stroke="none"/>',
|
||||
legal: `${bar(2, 4, 20, 1.6)}${bar(2, 7.4, 16, 1.6)}${bar(2, 10.8, 11, 1.6)}`,
|
||||
partialref: '<rect x="2.5" y="4.5" width="13" height="9" rx="1.5"/><path d="M6.5 4.5V2h13v9h-2.5"/>',
|
||||
embed: '<rect x="2" y="2.5" width="20" height="11" rx="1.5"/><path d="M10.2 5.6l4.6 2.4-4.6 2.4z" fill="currentColor" stroke="none"/>',
|
||||
};
|
||||
|
||||
/** The picture for one element type, ready to drop into the markup. */
|
||||
export function preview(type) {
|
||||
const shape = PREVIEWS[type];
|
||||
if (!shape) return '';
|
||||
return `<svg viewBox="0 0 24 16" width="34" height="23" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${shape}</svg>`;
|
||||
}
|
||||
|
||||
/** A row of columns, for the entries that add a row with a column count. */
|
||||
export function rowPreview(columns) {
|
||||
const gap = 1.4;
|
||||
const inner = (22 - gap * (columns - 1)) / columns;
|
||||
const cells = Array.from({ length: columns }, (_, index) =>
|
||||
`<rect x="${(1 + index * (inner + gap)).toFixed(2)}" y="3" width="${inner.toFixed(2)}" height="10" rx="1.2"/>`).join('');
|
||||
return `<svg viewBox="0 0 24 16" width="34" height="23" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round" aria-hidden="true" focusable="false">${cells}</svg>`;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Der Bildbetrachter.
|
||||
*
|
||||
* Ohne dieses Skript ist ein vergrößerbares Bild schlicht ein Verweis auf die große Fassung: ein
|
||||
* Klick öffnet sie, zurück führt die Zurück-Taste. Das ist die Grundlage, und sie funktioniert
|
||||
* immer. Dieses Skript fängt den Klick ab und zeigt das Bild stattdessen über der Seite - mit
|
||||
* Blättern, wenn es zu einer Galerie gehört.
|
||||
*
|
||||
* Benutzt wird <dialog>: der Browser bringt damit das Fangen des Fokus, das Schließen mit Escape
|
||||
* und die Auszeichnung als Dialog schon mit. Eine nachgebaute Fassung aus <div> und Tastatur-
|
||||
* abfragen wäre mehr Code und weniger zugänglich.
|
||||
*/
|
||||
(() => {
|
||||
const zoomable = [...document.querySelectorAll('a[data-zoom]')];
|
||||
if (0 === zoomable.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.className = 'zoom-dialog';
|
||||
dialog.innerHTML = `
|
||||
<figure class="zoom-figure">
|
||||
<img class="zoom-image" alt="">
|
||||
<figcaption class="zoom-caption"></figcaption>
|
||||
</figure>
|
||||
<button class="zoom-close" type="button" aria-label="Schließen">×</button>
|
||||
<button class="zoom-prev" type="button" aria-label="Vorheriges Bild">‹</button>
|
||||
<button class="zoom-next" type="button" aria-label="Nächstes Bild">›</button>
|
||||
<p class="zoom-counter" aria-live="polite"></p>
|
||||
`;
|
||||
document.body.append(dialog);
|
||||
|
||||
const bild = dialog.querySelector('.zoom-image');
|
||||
const beschriftung = dialog.querySelector('.zoom-caption');
|
||||
const zaehler = dialog.querySelector('.zoom-counter');
|
||||
const zurueck = dialog.querySelector('.zoom-prev');
|
||||
const vor = dialog.querySelector('.zoom-next');
|
||||
|
||||
/** Die Bilder, zwischen denen geblättert wird: die der Galerie, sonst nur dieses eine. */
|
||||
const gruppeVon = (link) => {
|
||||
const galerie = link.closest('[data-zoom-group]');
|
||||
|
||||
return galerie ? [...galerie.querySelectorAll('a[data-zoom]')] : [link];
|
||||
};
|
||||
|
||||
let gruppe = [];
|
||||
let stelle = 0;
|
||||
|
||||
const zeige = (index) => {
|
||||
stelle = (index + gruppe.length) % gruppe.length;
|
||||
const link = gruppe[stelle];
|
||||
const inneres = link.querySelector('img');
|
||||
bild.src = link.href;
|
||||
bild.alt = inneres ? inneres.alt : '';
|
||||
const text = link.dataset.caption || '';
|
||||
beschriftung.textContent = text;
|
||||
beschriftung.hidden = '' === text;
|
||||
const mehrere = gruppe.length > 1;
|
||||
zurueck.hidden = !mehrere;
|
||||
vor.hidden = !mehrere;
|
||||
zaehler.hidden = !mehrere;
|
||||
zaehler.textContent = mehrere ? `${stelle + 1} von ${gruppe.length}` : '';
|
||||
};
|
||||
|
||||
for (const link of zoomable) {
|
||||
link.addEventListener('click', event => {
|
||||
// Mit gedrückter Zusatztaste oder mittlerer Maustaste bleibt es ein normaler Verweis:
|
||||
// wer ein Bild in einem neuen Reiter öffnen will, soll das können.
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || 1 === event.button) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
gruppe = gruppeVon(link);
|
||||
zeige(gruppe.indexOf(link));
|
||||
dialog.showModal();
|
||||
});
|
||||
}
|
||||
|
||||
zurueck.addEventListener('click', () => zeige(stelle - 1));
|
||||
vor.addEventListener('click', () => zeige(stelle + 1));
|
||||
dialog.querySelector('.zoom-close').addEventListener('click', () => dialog.close());
|
||||
// Ein Klick neben das Bild schließt - erwartet, und der Dialog selbst ist die Fläche daneben.
|
||||
dialog.addEventListener('click', event => {
|
||||
if (event.target === dialog) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
dialog.addEventListener('keydown', event => {
|
||||
if (1 === gruppe.length) {
|
||||
return;
|
||||
}
|
||||
if ('ArrowLeft' === event.key) {
|
||||
event.preventDefault();
|
||||
zeige(stelle - 1);
|
||||
}
|
||||
if ('ArrowRight' === event.key) {
|
||||
event.preventDefault();
|
||||
zeige(stelle + 1);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,45 @@
|
||||
import { blockLabel } from './editor-model.js';
|
||||
|
||||
/** Editing aids are created only in the preview DOM, never in a saved block or public HTML. */
|
||||
export function decorateContainers(preview, content) {
|
||||
const visit = (blocks, parent = null) => {
|
||||
blocks.forEach((block, index) => {
|
||||
const node = preview.getElementById(block.id);
|
||||
if (node && ['section', 'row', 'column'].includes(block.type)) {
|
||||
const label = block.type === 'column' && parent?.type === 'row'
|
||||
? `Spalte ${index + 1} von ${blocks.length}` : blockLabel(block);
|
||||
node.classList.add('editor-container');
|
||||
node.dataset.editorLabel = label;
|
||||
if (!block.children?.length) {
|
||||
node.classList.add('editor-empty-container');
|
||||
const host = block.type === 'section' ? node.querySelector(':scope > .section-inner') : node;
|
||||
if (!host) return;
|
||||
const empty = preview.createElement('div');
|
||||
empty.className = 'editor-empty';
|
||||
const name = preview.createElement('span');
|
||||
name.className = 'editor-empty-name'; name.textContent = label;
|
||||
const hint = preview.createElement('span');
|
||||
hint.className = 'editor-empty-hint';
|
||||
hint.textContent = block.type === 'row' ? 'Spalte hierher ziehen oder hinzufügen' : 'Element hierher ziehen oder hinzufügen';
|
||||
const actions = preview.createElement('div'); actions.className = 'editor-empty-actions';
|
||||
const add = (text, attribute, value, accessibleName) => {
|
||||
const button = preview.createElement('button');
|
||||
button.type = 'button'; button.textContent = text;
|
||||
button.dataset.editorTarget = block.id;
|
||||
button.setAttribute(attribute, value);
|
||||
button.setAttribute('aria-label', accessibleName);
|
||||
actions.append(button);
|
||||
};
|
||||
add(block.type === 'row' ? '+ Spalte hinzufügen' : '+ Inhalt hinzufügen', 'data-editor-insert', '', `${label}: ${block.type === 'row' ? 'Spalte' : 'Inhalt'} hinzufügen`);
|
||||
if (block.type === 'section') {
|
||||
for (const count of [1, 2, 3, 4]) add(`${count} ${count === 1 ? 'Spalte' : 'Spalten'}`, 'data-editor-row', String(count), `${count} ${count === 1 ? 'Spalte' : 'Spalten'} in dieser Sektion anlegen`);
|
||||
}
|
||||
empty.append(name, hint, actions);
|
||||
host.append(empty);
|
||||
}
|
||||
}
|
||||
visit(block.children ?? [], block);
|
||||
});
|
||||
};
|
||||
visit(content.blocks);
|
||||
}
|
||||
@@ -7,3 +7,17 @@
|
||||
|
||||
/* Inline editing. Only ever active in the editor preview, never on a published page. */
|
||||
[contenteditable].editor-inline{outline:3px solid #b55b12;outline-offset:4px;border-radius:3px;cursor:text;background:#fffdf7}
|
||||
|
||||
/* Empty layout areas must have a real hit area, including when sibling columns have content. */
|
||||
.editor-empty-container{min-height:130px!important}
|
||||
.editor-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;min-height:130px;width:100%;padding:22px 14px;border:2px dashed #93aa9a;border-radius:7px;background:#f3f8f4;color:#264f3d;text-align:center;font:14px/1.5 system-ui,sans-serif;box-sizing:border-box;grid-column:1/-1}
|
||||
.editor-empty-name{font-size:12px;font-weight:700;letter-spacing:.04em}
|
||||
.editor-empty-hint{font-size:13px;color:#496351}
|
||||
.editor-empty-actions{display:flex;justify-content:center;flex-wrap:wrap;gap:8px}
|
||||
.editor-empty button{appearance:none;font:600 13px/1.3 system-ui,sans-serif;color:#264f3d;background:white;border:1px solid #94ad9d;border-radius:6px;min-height:40px;padding:9px 13px;cursor:pointer;letter-spacing:normal;text-transform:none;box-shadow:none;margin:0}
|
||||
.editor-empty button:hover,.editor-empty button:focus-visible{background:#deeee2;border-color:#32644a;outline:2px solid #32644a;outline-offset:2px}
|
||||
.editor-empty button[data-editor-insert]{background:#2c634a;color:white;border-color:#2c634a}
|
||||
.editor-container:hover:not(.editor-selected),.editor-dragging .editor-container{outline:1px dashed #6b927a;outline-offset:-1px}
|
||||
.editor-container.editor-insert-target{outline:3px solid #b55b12!important;outline-offset:-3px}
|
||||
.editor-dragging .editor-empty{background:#e6f1e9;border-color:#467858}
|
||||
@media(max-width:650px){.editor-empty{padding:16px 10px}.editor-empty button{min-height:44px}}
|
||||
+90
-41
@@ -1,15 +1,19 @@
|
||||
@charset "UTF-8";
|
||||
h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
|
||||
.block-image{margin:0;max-width:100%}.block-image img{max-width:100%;height:auto}
|
||||
/* Lange Wörter dürfen umbrechen, damit nichts aus der Spalte läuft. Getrennt wird nur im
|
||||
Fließtext: eine Überschrift mit Trennstrich („di-gitale") sieht nach Fehler aus. */
|
||||
h1,h2,h3,h4,h5,h6,p,a{overflow-wrap:break-word}
|
||||
h1,h2,h3,h4,h5,h6{hyphens:manual}
|
||||
p{hyphens:auto}
|
||||
.visually-hidden{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}.block-image{margin:0;max-width:100%}.block-image img{max-width:100%;height:auto}
|
||||
:root{color-scheme:light;--paper:#f7f8f4;--ink:#182d2b;--muted:#50625d;--line:#d7dfd7;--mint:#e5f0b6;--accent:#315b49;font-family:Arial,Helvetica,sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}
|
||||
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid #b85c12;outline-offset:6px}main:focus{outline:none}.shell{width:min(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}}
|
||||
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid var(--accent);outline-offset:6px}main:focus{outline:none}.shell{width:min(var(--k-container,1180px),calc(100% - 64px));margin-inline:auto}.site-header{border-bottom:1px solid var(--line)}.site-header.is-sticky{position:sticky;top:0;z-index:40;background:var(--paper);box-shadow:0 1px 0 var(--line),0 10px 30px color-mix(in srgb,var(--ink) 8%,transparent)}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:none;font-weight:800}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500}h2{font-size:clamp(32px,4vw,49px);font-weight:500}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
|
||||
@media(max-width:650px){.shell{width:calc(100% - 36px)}.site-header nav{width:100%;justify-content:space-between;gap:12px;font-size:12px}.site-header nav .nav-link{padding:10px}.nav-link span{margin-left:6px}.block-section,.block-section:first-child{padding-block:48px}h1{font-size:49px}.block-text{font-size:16px}.site-footer{align-items:flex-start;flex-direction:column;gap:10px}.site-footer p{margin:0}.block-row{gap:16px}}
|
||||
|
||||
/* Menus. Submenus open with details/summary, so the navigation works without JavaScript. */
|
||||
.menu-list{list-style:none;display:flex;flex-wrap:wrap;align-items:center;gap:4px 22px;margin:0;padding:0}
|
||||
.menu-list a{text-decoration:none;padding:6px 2px;display:inline-block}
|
||||
.menu-list a:hover,.menu-list summary:hover{text-decoration:underline}
|
||||
.menu-list [aria-current=page]{font-weight:700;text-decoration:underline;text-underline-offset:6px}
|
||||
.menu-list [aria-current=page]{font-weight:700;text-decoration:none}
|
||||
.menu-branch>summary{cursor:pointer;padding:6px 2px;list-style:none}
|
||||
.menu-branch>summary::after{content:' ▾'}
|
||||
.menu-branch[open]>summary::after{content:' ▴'}
|
||||
@@ -17,16 +21,19 @@ h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
|
||||
.menu-level-2{flex-direction:column;align-items:flex-start;gap:2px}
|
||||
|
||||
/* Posts */
|
||||
.post-list{list-style:none;margin:0;padding:0;display:grid;gap:26px;grid-template-columns:repeat(auto-fill,minmax(300px,1fr))}
|
||||
.post-card{display:flex;flex-direction:column;gap:14px}
|
||||
.post-image img{width:100%;height:210px;object-fit:cover;border-radius:10px;display:block}
|
||||
.post-card h3{margin:0;font-size:19px;line-height:1.35}
|
||||
.post-list{list-style:none;margin:0;padding:0;display:grid;gap:26px}.post-list-karten{grid-template-columns:repeat(auto-fill,minmax(260px,1fr))}.post-list-liste{gap:18px}.post-list-liste .post-card{flex-direction:row;align-items:stretch}.post-list-liste .post-image{flex:0 0 34%;max-width:300px}.post-list-liste .post-image img{height:100%;aspect-ratio:auto;min-height:170px}.post-list-liste .post-body{flex:1 1 auto;align-content:center;padding:22px 26px}.post-list-wechsel{gap:0;counter-reset:beitrag}.post-list-wechsel .post-card{position:relative;counter-increment:beitrag;display:grid;grid-template-columns:1fr 1fr;align-items:center;gap:0 56px;padding:38px 0;background:transparent;border:0;border-radius:0;overflow:visible}.post-list-wechsel .post-card + .post-card{border-top:1px solid var(--line)}.post-list-wechsel .post-image{border-radius:20px;overflow:hidden;box-shadow:0 18px 46px -22px color-mix(in srgb,var(--ink) 55%,transparent)}.post-list-wechsel .post-image img{aspect-ratio:16/10;border-radius:20px}.post-list-wechsel .post-body{padding:0;display:grid;gap:12px;align-content:center}.post-list-wechsel .post-body::before{content:counter(beitrag,decimal-leading-zero);font-size:13px;font-weight:800;letter-spacing:2px;color:var(--accent)}.post-list-wechsel .post-card h3{font-size:28px;line-height:1.18;letter-spacing:-.5px}.post-list-wechsel .post-excerpt{margin:0;max-width:46ch}.post-list-wechsel .post-meta,.post-list-wechsel .post-terms{margin:0}.post-list-wechsel .post-more{display:inline-flex;align-items:center;gap:8px;margin-top:4px;font-size:15px;font-weight:700;color:var(--accent);text-decoration:none;width:fit-content}
|
||||
.post-more::after{content:'→';transition:transform .2s}
|
||||
.post-more:hover::after{transform:translateX(4px)}.post-list-wechsel .post-more::after{content:'\2192'}.post-list-wechsel .post-card:nth-child(even) .post-image{order:2}.post-list-wechsel .post-card:nth-child(even) .post-body{order:1}@media(max-width:760px){.post-list-wechsel .post-card{grid-template-columns:1fr;gap:18px;padding:26px 0}.post-list-wechsel .post-card:nth-child(even) .post-image{order:0}.post-list-wechsel .post-card:nth-child(even) .post-body{order:0}.post-list-wechsel .post-card h3{font-size:22px}}.post-list-kompakt{gap:0}.post-list-kompakt .post-card{flex-direction:row;align-items:baseline;justify-content:space-between;gap:18px;background:transparent;border:0;border-bottom:1px solid var(--line);border-radius:0;overflow:visible}.post-list-kompakt .post-body{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 18px;padding:16px 2px}.post-list-kompakt .post-meta,.post-list-kompakt .post-terms{margin:0}.post-list-kompakt .post-excerpt{flex:1 1 100%;margin:0}
|
||||
.post-card{display:flex;flex-direction:column;background:var(--paper);border:1px solid var(--line);border-radius:18px;overflow:hidden}.post-card>.post-body{display:grid;gap:10px;padding:20px 22px 22px}
|
||||
.post-image{display:block;position:relative;overflow:hidden;text-decoration:none}.post-view{position:absolute;inset:0;display:grid;place-items:center;background:color-mix(in srgb,var(--ink) 55%,transparent);color:#fff;font-weight:700;font-size:15px;letter-spacing:.4px;opacity:0}.post-card:hover .post-view,.post-card:focus-within .post-view{opacity:1}.post-image img{width:100%;height:auto;aspect-ratio:16/10;object-fit:cover;border-radius:0;display:block;background:var(--ink)}
|
||||
.kant h1 mark,.kant h2 mark,.kant h3 mark{background:transparent;color:var(--accent);padding:0}
|
||||
.kant .post-card h3{margin:0;font-size:19px;font-weight:800;line-height:1.12;color:var(--ink);letter-spacing:-.2px}
|
||||
.post-card h3 a{text-decoration:none}
|
||||
.post-card h3 a:hover{text-decoration:underline}
|
||||
.post-meta,.post-header{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:baseline;font-size:13px;margin:0}
|
||||
.post-header{padding-block:22px 0}
|
||||
.post-excerpt{margin:0;line-height:1.65}
|
||||
.post-terms{display:flex;flex-wrap:wrap;gap:8px;margin:0;font-size:13px}
|
||||
.block-postnav{margin-top:10px}.post-nav{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:18px}.post-nav-link{display:grid;gap:8px;padding:22px 24px;border:1px solid var(--line);border-radius:18px;text-decoration:none;background:var(--paper)}.post-nav-next{text-align:right}.post-nav-label{font-size:12px;font-weight:700;letter-spacing:1.4px;text-transform:uppercase;color:var(--accent)}.post-nav-title{font-size:18px;font-weight:800;line-height:1.25;color:var(--ink)}.post-pager{display:flex;flex-wrap:wrap;gap:10px 22px;align-items:center;justify-content:center;margin-top:34px;font-size:14px}.post-pager span{color:var(--muted)}.post-terms{display:flex;flex-wrap:wrap;gap:8px;margin:0;font-size:13px}
|
||||
.post-empty{padding:26px;border:1px dashed currentColor;border-radius:10px}
|
||||
.pagination{display:flex;flex-wrap:wrap;gap:12px 24px;align-items:center;margin-top:38px;padding-top:22px;border-top:1px solid currentColor;font-size:14px}
|
||||
.post-terms-label{font-size:11px;font-weight:700;letter-spacing:1.6px;text-transform:uppercase;margin-bottom:10px}
|
||||
@@ -34,26 +41,30 @@ h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
|
||||
/* Blocks introduced with schema v6. No JavaScript: the accordion is details/summary. */
|
||||
.block-icon{flex-shrink:0;display:block}
|
||||
.block-iconcard{display:grid;gap:12px;padding:26px;border:1px solid currentColor;border-radius:12px}
|
||||
.block-iconcard h3{margin:0;font-size:19px;line-height:1.3}
|
||||
.block-iconcard + .block-iconcard,.block-quote + .block-quote,.block-cta + .block-cta,.block-iconcard + .block-quote,.block-quote + .block-iconcard{margin-top:18px}.kant .block-iconcard h3{margin:0;font-size:19px;line-height:1.3}
|
||||
.block-iconcard h3 a{text-decoration:none}
|
||||
.block-iconcard h3 a:hover{text-decoration:underline}
|
||||
.block-iconcard p{margin:0;line-height:1.65}
|
||||
.block-stat{display:grid;gap:4px;margin:0}
|
||||
.block-stat strong{font-size:44px;font-weight:600;line-height:1.05;letter-spacing:-1.5px}
|
||||
.block-stat{display:grid;gap:4px;margin:0;font-size:44px;font-weight:600}
|
||||
.block-stat strong{font-size:inherit;font-weight:inherit;line-height:1.05;letter-spacing:-1.5px}
|
||||
.block-stat span{font-size:14px;line-height:1.5}
|
||||
.block-quote{margin:0;display:grid;gap:14px;padding:28px;border:1px solid currentColor;border-radius:12px}
|
||||
.block-quote{margin:0;display:grid;gap:14px;padding:28px;border:1px solid currentColor;border-radius:12px;font-size:18px}
|
||||
.block-quote blockquote{margin:0}
|
||||
.block-quote blockquote p{margin:0;font-size:18px;line-height:1.65}
|
||||
.block-quote blockquote p{margin:0;font-size:inherit;line-height:1.65}
|
||||
.block-quote figcaption{font-size:14px;font-weight:600}
|
||||
.block-quote figcaption span{font-weight:400;opacity:.75}
|
||||
.block-quote figcaption span{font-weight:400;opacity:.85}
|
||||
.quote-rating{margin:0;letter-spacing:3px}
|
||||
.block-logos{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));align-items:center;gap:24px}
|
||||
.block-logos img{max-height:52px;width:auto;margin-inline:auto;display:block}
|
||||
.block-cta{display:flex;flex-wrap:wrap;gap:20px 34px;align-items:center;justify-content:space-between;padding:32px;border:1px solid currentColor;border-radius:14px}
|
||||
.block-cta h2{margin:0 0 6px;font-size:26px;letter-spacing:-.6px}
|
||||
.kant .block-form button[type=submit]{background:var(--accent);color:#fff;border-color:var(--accent)}.kant .block-form button[type=submit]:hover{background:color-mix(in srgb,var(--accent) 86%,#000)}.kant .block-cta .button{background:var(--accent);color:#fff;border-color:var(--accent)}.kant .block-cta .button:hover{background:color-mix(in srgb,var(--accent) 86%,#000)}.block-cta{display:flex;flex-wrap:wrap;gap:20px 34px;align-items:center;justify-content:space-between;padding:32px;border:1px solid currentColor;border-radius:14px}
|
||||
.kant .block-cta h2{margin:0 0 6px;font-size:26px;letter-spacing:-.6px}
|
||||
.block-cta p{margin:0;line-height:1.6}
|
||||
.block-contact{display:grid;gap:14px;font-style:normal}
|
||||
.block-contact span{display:flex;align-items:center;gap:12px;line-height:1.5}
|
||||
/* Telefonnummer und E-Mail stehen neben ihrem Symbol - das sagt deutlicher als ein Strich,
|
||||
dass man darauf tippen kann. Beim Überfahren erscheint er trotzdem. */
|
||||
.block-contact a{text-decoration:none}
|
||||
.block-contact a:hover{text-decoration:underline}
|
||||
.block-divider{border:0;border-top:1px solid currentColor;opacity:.35;margin:0}
|
||||
.block-spacer{display:block}
|
||||
.spacer-small{height:18px}.spacer-medium{height:38px}.spacer-large{height:68px}.spacer-huge{height:110px}
|
||||
@@ -71,7 +82,7 @@ h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
|
||||
.tabs-nav [aria-selected=true]{border-color:currentColor;background:#fff}
|
||||
.tabs-ready .tabs-nav a{cursor:pointer}
|
||||
.block-tabitem{padding-block:4px}
|
||||
.block-tabitem:focus-visible{outline:3px solid #b55b12;outline-offset:4px}
|
||||
.block-tabitem:focus-visible{outline:3px solid var(--accent);outline-offset:4px}
|
||||
.tabs-heading{margin:0 0 12px;font-size:17px}
|
||||
|
||||
/* Forms. Server rendered, no JavaScript; every message sits at its field. */
|
||||
@@ -84,7 +95,7 @@ h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
|
||||
.field-error{margin:0;font-size:13px;font-weight:600;color:#8b2f22}
|
||||
.field-note{margin:0;font-size:13px;opacity:.8}
|
||||
.form-error-summary{margin:0;padding:14px 18px;border-radius:8px;background:#fff0eb;color:#8b2f22;border:1px solid #e2b2a7;font-weight:600}
|
||||
.form-success{margin:0;padding:16px 20px;border-radius:8px;background:#edf3dc;color:#315b49;border:1px solid #c5d89a;font-weight:600}
|
||||
.form-success{margin:0;padding:16px 20px;border-radius:8px;background:color-mix(in srgb,var(--accent) 10%,var(--paper));color:var(--accent);border:1px solid color-mix(in srgb,var(--accent) 35%,var(--paper));font-weight:600}
|
||||
.form-missing{margin:0;padding:14px 18px;border:1px dashed currentColor;border-radius:8px}
|
||||
.check-row{display:flex;align-items:flex-start;gap:11px}
|
||||
.check-row input{width:20px;min-height:20px;flex-shrink:0;margin-top:3px}
|
||||
@@ -102,28 +113,27 @@ h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
|
||||
for light backgrounds. Inside a dark tone they won on specificity, so text in a dark column was
|
||||
unreadable - 1.87:1 in the seeded footer. These two selectors match that specificity and come
|
||||
later, so the dark tone wins where it has to. */
|
||||
.tone-ink .block-column .block-text,
|
||||
.tone-ink .block-column>.block-text:first-child{color:#dae3da}
|
||||
.tone-ink .block-column .block-text{color:#dae3da}
|
||||
|
||||
/* The consent question. Fixed to the bottom, but it never covers the whole page: the content
|
||||
stays readable while the decision is open, because a wall would make "accept" the only way
|
||||
forward. Accepting and refusing are the same button in the same row. */
|
||||
.consent-bar{position:fixed;inset-inline:12px;bottom:12px;z-index:50;max-width:620px;margin-inline:auto;background:#fff;color:#203b34;border:1px solid #cfd8d1;border-radius:14px;padding:20px 22px;box-shadow:0 18px 50px #1b2a2426;font-size:14px;line-height:1.6}
|
||||
.consent-bar h2{font-size:16px;margin:0 0 8px}
|
||||
.consent-bar{position:fixed;inset-inline:12px;bottom:12px;z-index:50;max-width:620px;margin-inline:auto;background:var(--paper);color:var(--ink);border:1px solid var(--line);border-radius:14px;padding:20px 22px;box-shadow:0 18px 50px color-mix(in srgb,var(--ink) 15%,transparent);font-size:14px;line-height:1.6}
|
||||
.kant .consent-bar h2{font-size:16px;margin:0 0 8px}
|
||||
.consent-bar p{margin:0 0 14px}
|
||||
.consent-actions{display:flex;flex-wrap:wrap;gap:10px}
|
||||
.consent-button{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:11px 18px;border-radius:8px;border:1px solid #2c6b4f;background:#2c6b4f;color:#fff;font:inherit;font-weight:600;cursor:pointer}
|
||||
.consent-actions .consent-button:last-child{background:#fff;color:#1b2a24;border-color:#b3c0b7}
|
||||
.consent-button{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:11px 18px;border-radius:8px;border:1px solid var(--k-consent,var(--accent));background:var(--k-consent,var(--accent));color:#fff;font:inherit;font-weight:600;cursor:pointer}
|
||||
.consent-actions .consent-button:last-child{background:var(--paper);color:var(--ink);border-color:var(--line)}
|
||||
.consent-button:hover{filter:brightness(.94)}
|
||||
.consent-button:focus-visible,.consent-bar a:focus-visible,.consent-bar summary:focus-visible,.consent-bar input:focus-visible{outline:3px solid #b55b12;outline-offset:2px}
|
||||
.consent-button:focus-visible,.consent-bar a:focus-visible,.consent-bar summary:focus-visible,.consent-bar input:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
|
||||
.consent-details{margin-top:14px}
|
||||
.consent-details>summary{cursor:pointer;font-size:13px;font-weight:600;padding:4px 0}
|
||||
.consent-groups{list-style:none;margin:10px 0 0;padding:0;display:grid;gap:10px}
|
||||
.consent-groups label{display:flex;gap:10px;align-items:flex-start;font-size:13px;line-height:1.5}
|
||||
.consent-groups input{margin-top:3px;width:18px;height:18px;flex-shrink:0}
|
||||
.consent-choice{margin-top:14px;background:#fff;color:#1b2a24;border-color:#b3c0b7}
|
||||
.consent-choice{margin-top:14px;background:var(--paper);color:var(--ink);border-color:var(--line)}
|
||||
.consent-links{margin:14px 0 0;font-size:12px}
|
||||
.consent-links a{color:#a4510f}
|
||||
.consent-links a{color:var(--accent)}
|
||||
/* While the question is open the bar is fixed at the bottom, so the page has to end above it -
|
||||
otherwise it covers the last element, and an embed's own consent button sat right underneath.
|
||||
Found by the browser test, which could not click it. */
|
||||
@@ -134,11 +144,11 @@ body.consent-open{padding-bottom:260px}
|
||||
@media (max-width:620px){.consent-bar{inset-inline:0;bottom:0;border-radius:14px 14px 0 0;padding:16px}.consent-actions .consent-button{flex:1 1 140px}}
|
||||
|
||||
/* An embedded third party, and what stands in its place until it may load. */
|
||||
.block-embed iframe{display:block;width:100%;border:0;border-radius:10px;background:#eef1ed}
|
||||
.embed-blocked{border:1px dashed #b3c0b7;border-radius:10px;padding:24px;background:#f6f7f4;text-align:center}
|
||||
.block-embed iframe{display:block;width:100%;border:0;border-radius:10px;background:color-mix(in srgb,var(--ink) 8%,var(--paper))}
|
||||
.embed-blocked{border:1px dashed var(--line);border-radius:10px;padding:24px;background:color-mix(in srgb,var(--ink) 4%,var(--paper));text-align:center}
|
||||
.embed-title{margin:0 0 8px;font-weight:600}
|
||||
.embed-hint{margin:0 0 10px;font-size:14px;line-height:1.6;color:#4a5751}
|
||||
.embed-provider{margin:0 0 16px;font-size:13px;color:#4a5751}
|
||||
.embed-hint{margin:0 0 10px;font-size:14px;line-height:1.6;color:var(--muted)}
|
||||
.embed-provider{margin:0 0 16px;font-size:13px;color:var(--muted)}
|
||||
|
||||
/* Header and footer hold block documents of their own (ADR 026). They are therefore ordinary
|
||||
block containers - as flex containers they shrank the section inside them until a single word
|
||||
@@ -152,14 +162,11 @@ body.consent-open{padding-bottom:260px}
|
||||
.site-footer .block-column{border:0;border-radius:0;padding:0}
|
||||
.site-header .block-text,
|
||||
.site-footer .block-text{margin:0}
|
||||
/* Size and weight only, never colour: the tone of the area decides that, and a colour here won
|
||||
against .tone-ink and put 2.24:1 grey text on the dark footer. The browser check caught it. */
|
||||
.site-header .block-column>.block-text:first-child{font-size:24px;font-weight:800;letter-spacing:-1px;text-transform:none;margin:0}
|
||||
.site-footer .block-column>.block-text:first-child{font-size:14px;font-weight:400;letter-spacing:0;text-transform:none;margin:0}
|
||||
/* One landmark for the three links that must be on every page. Loose text outside a landmark is
|
||||
also what axe objects to, and rightly: a screen reader then has no way to skip to it. */
|
||||
.legal-bar{padding:16px 20px;text-align:center;font-size:12px;color:var(--muted);background:#f1f3ef}
|
||||
.legal-bar a{color:#a4510f}
|
||||
.legal-bar{padding:16px 20px;text-align:center;font-size:12px;color:var(--muted);background:color-mix(in srgb,var(--ink) 6%,var(--paper))}
|
||||
.legal-bar a{color:var(--accent);text-decoration:none}
|
||||
.legal-bar a:hover{text-decoration:underline}
|
||||
|
||||
/* An image takes the rounding of its figure. Without this a radius on an image block rounded the
|
||||
figure and left the picture square - which looked like the setting did nothing. */
|
||||
@@ -178,7 +185,7 @@ body.consent-open{padding-bottom:260px}
|
||||
.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-track{height:10px;border-radius:999px;background:color-mix(in srgb,var(--ink) 12%,var(--paper));overflow:hidden}
|
||||
.progress-fill{height:100%;border-radius:999px;background:var(--accent)}
|
||||
.tone-ink .progress-track{background:#ffffff26}
|
||||
.tone-ink .progress-fill{background:var(--mint)}
|
||||
@@ -189,7 +196,8 @@ body.consent-open{padding-bottom:260px}
|
||||
.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}
|
||||
.block-slider:focus-visible{outline:3px solid var(--accent);outline-offset:6px}/* Mit Punkten braucht es die Rollleiste nicht mehr - sie stand quer unter dem Bild. */
|
||||
.slider-dots{scroll-marker-group:after;scroll-behavior:smooth;position:relative;padding-bottom:0;scrollbar-width:none;scrollbar-gutter:auto}.slider-dots::-webkit-scrollbar{display:none}.slider-dots>*::scroll-marker{content:'';width:12px;height:12px;border-radius:50%;background:color-mix(in srgb,currentColor 30%,transparent);margin:0 6px;cursor:pointer}.slider-dots>*::scroll-marker:target-current{background:var(--accent)}.slider-dots::scroll-marker-group{display:flex;justify-content:center;align-items:center;padding:14px 0 2px}
|
||||
.slide-desktop-1{grid-auto-columns:100%}.slide-desktop-2{grid-auto-columns:calc(50% - 10px)}
|
||||
.slide-desktop-3{grid-auto-columns:calc(33.333% - 14px)}.slide-desktop-4{grid-auto-columns:calc(25% - 15px)}
|
||||
.slide-desktop-5{grid-auto-columns:calc(20% - 16px)}.slide-desktop-6{grid-auto-columns:calc(16.666% - 17px)}
|
||||
@@ -325,3 +333,44 @@ body.consent-open{padding-bottom:260px}
|
||||
.menu-burger[open]>summary{border-radius:8px 8px 0 0}
|
||||
.menu-burger-body{padding:14px 0 4px}
|
||||
.menu-burger-body .menu-list{flex-direction:column;align-items:flex-start;gap:8px}
|
||||
/* Der Knopf „nach oben". Ein Anker, kein Skript: er springt an den Anfang der Seite, und das
|
||||
kann ein Browser seit jeher allein. Eingeblendet wird er beim Scrollen über eine Scroll-
|
||||
Zeitachse - dieselbe Technik wie bei den Einblendungen. Wo es die nicht gibt (heute noch
|
||||
Safari), steht er von Anfang an da: sichtbar und nutzbar ist besser als unsichtbar.
|
||||
Aussehen, Größe, Form, Seite und Schwelle kommen aus den Einstellungen. */
|
||||
.scroll-top{position:fixed;bottom:22px;right:22px;z-index:40;display:grid;place-items:center;width:var(--kant-top-size,48px);height:var(--kant-top-size,48px);border-radius:var(--kant-top-radius,999px);background:var(--kant-top-bg,var(--accent));color:var(--kant-top-ink,#fff);text-decoration:none;box-shadow:0 8px 24px color-mix(in srgb,var(--ink) 28%,transparent);font-size:calc(var(--kant-top-size,48px) / 2.4);line-height:1}
|
||||
.scroll-top-links{right:auto;left:22px}
|
||||
.scroll-top:hover{filter:brightness(1.08)}
|
||||
.scroll-top:focus-visible{outline:3px solid var(--accent);outline-offset:3px}
|
||||
@media (max-width:767px){.scroll-top{bottom:16px;right:16px}.scroll-top-links{left:16px;right:auto}}
|
||||
/* Solange der Einwilligungsdialog unten steht, würde der Knopf darauf liegen. */
|
||||
body.consent-open .scroll-top{display:none}
|
||||
@supports (animation-timeline: scroll()){
|
||||
@media (prefers-reduced-motion: no-preference){
|
||||
.scroll-top{animation:kant-scroll-top linear both;animation-timeline:scroll(root);animation-range:var(--kant-top-after,400px) calc(var(--kant-top-after,400px) + 160px)}
|
||||
@keyframes kant-scroll-top{from{opacity:0;visibility:hidden;transform:translateY(10px)}to{opacity:1;visibility:visible;transform:none}}
|
||||
}
|
||||
}
|
||||
/* Der Bildbetrachter. Die Fläche daneben schließt, das Bild bleibt in seinen Grenzen, und die
|
||||
Bedienknöpfe sind groß genug für einen Daumen. Ohne Skript wird nichts davon gebraucht - dann
|
||||
öffnet der Verweis einfach das große Bild. */
|
||||
.block-legal{display:flex;flex-wrap:wrap;gap:6px;align-items:center;font-size:13px}
|
||||
.block-legal a{color:inherit;text-decoration:none}
|
||||
.block-legal a:hover{text-decoration:underline}
|
||||
.image-zoom{cursor:zoom-in}
|
||||
.zoom-dialog{border:0;padding:0;max-width:min(96vw,1400px);max-height:94vh;background:transparent;color:#fff;overflow:visible}
|
||||
.zoom-dialog::backdrop{background:rgba(8,12,20,.88)}
|
||||
.zoom-figure{margin:0;display:grid;gap:12px;justify-items:center}
|
||||
.zoom-image{max-width:100%;max-height:82vh;width:auto;height:auto;border-radius:10px;background:#0c1220}
|
||||
.zoom-caption{margin:0;font-size:14px;text-align:center;max-width:70ch;color:#e7ecf3}
|
||||
.zoom-close,.zoom-prev,.zoom-next{position:fixed;display:grid;place-items:center;width:52px;height:52px;border-radius:999px;border:0;background:rgba(255,255,255,.14);color:#fff;font-size:26px;line-height:1;cursor:pointer}
|
||||
.zoom-close:hover,.zoom-prev:hover,.zoom-next:hover{background:rgba(255,255,255,.26)}
|
||||
.zoom-close:focus-visible,.zoom-prev:focus-visible,.zoom-next:focus-visible{outline:3px solid #fff;outline-offset:3px}
|
||||
.zoom-close{top:18px;right:18px}
|
||||
.zoom-prev{left:18px;top:50%;transform:translateY(-50%)}
|
||||
.zoom-next{right:18px;top:50%;transform:translateY(-50%)}
|
||||
.zoom-counter{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);margin:0;font-size:13px;color:#e7ecf3}
|
||||
@media (max-width:767px){
|
||||
.zoom-close,.zoom-prev,.zoom-next{width:46px;height:46px}
|
||||
.zoom-prev{left:8px}.zoom-next{right:8px}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Lets a slider advance by itself - and stop as soon as somebody takes over.
|
||||
*
|
||||
* Without this script a slider is a scroll container with snap points and, where the browser can
|
||||
* draw them, dots: complete, swipeable and keyboard operable. The script only adds the automatic
|
||||
* step, and it is loaded exclusively on pages that carry a slider which asked for it.
|
||||
*
|
||||
* What it refuses to do:
|
||||
* - it does not run when the system asks for reduced motion,
|
||||
* - it stops for good at the first touch, click, key press or manual scroll,
|
||||
* - it pauses while the pointer is inside, while something in it has focus, and while the tab is
|
||||
* in the background or the slider is off screen.
|
||||
*
|
||||
* A carousel that keeps moving while somebody reads it is a nuisance, and one that cannot be
|
||||
* stopped is a barrier.
|
||||
*/
|
||||
(() => {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
|
||||
const setup = slider => {
|
||||
const seconds = Number(slider.dataset.autoplay);
|
||||
if (!Number.isFinite(seconds) || seconds < 3) return;
|
||||
|
||||
let paused = false;
|
||||
let stopped = false;
|
||||
let visible = true;
|
||||
let ownScroll = 0;
|
||||
|
||||
const slides = [...slider.children];
|
||||
if (slides.length < 2) return;
|
||||
|
||||
const step = () => {
|
||||
if (stopped || paused || !visible || document.hidden) return;
|
||||
const last = slider.scrollLeft + slider.clientWidth >= slider.scrollWidth - 2;
|
||||
// Measured, not computed from offsetLeft: whether a slide's offset parent is the slider or
|
||||
// something further up decides the number, and getting that wrong means scrolling nowhere.
|
||||
const left = slide => slide.getBoundingClientRect().left - slider.getBoundingClientRect().left + slider.scrollLeft;
|
||||
const next = last ? slides[0] : slides.find(slide => left(slide) > slider.scrollLeft + 2);
|
||||
if (!next) return;
|
||||
// Remember that the next scroll event is ours, so it does not look like a person.
|
||||
ownScroll = Date.now();
|
||||
slider.scrollTo({ left: last ? 0 : left(next), behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const stop = () => { stopped = true; clearInterval(timer); };
|
||||
const timer = setInterval(step, seconds * 1000);
|
||||
|
||||
slider.addEventListener('pointerenter', () => { paused = true; });
|
||||
slider.addEventListener('pointerleave', () => { paused = false; });
|
||||
slider.addEventListener('focusin', () => { paused = true; });
|
||||
slider.addEventListener('focusout', () => { paused = false; });
|
||||
for (const event of ['pointerdown', 'keydown', 'touchstart', 'wheel']) {
|
||||
slider.addEventListener(event, stop, { passive: true });
|
||||
}
|
||||
slider.addEventListener('scroll', () => {
|
||||
// A scroll that is not the one we just started belongs to a person; then we are done.
|
||||
if (Date.now() - ownScroll > 1200) stop();
|
||||
}, { passive: true });
|
||||
|
||||
if ('IntersectionObserver' in window) {
|
||||
new IntersectionObserver(entries => { visible = entries[0].isIntersecting; }, { threshold: 0.35 })
|
||||
.observe(slider);
|
||||
}
|
||||
};
|
||||
|
||||
document.querySelectorAll('.block-slider[data-autoplay]').forEach(setup);
|
||||
})();
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Der Takt des geöffneten Backends.
|
||||
*
|
||||
* Auf einer Website ohne Besucher gibt es keinen Auslöser für wiederkehrende Aufgaben - außer
|
||||
* dem Menschen, der gerade in der Verwaltung arbeitet. Dieses Skript klopft in seinem Namen alle
|
||||
* 60 Sekunden an; der Server entscheidet, ob überhaupt etwas fällig ist.
|
||||
*
|
||||
* Drei Eigenschaften, die es unauffällig machen:
|
||||
*
|
||||
* - Es fragt nur, während der Reiter sichtbar ist. Ein vergessener Hintergrundreiter soll keine
|
||||
* Sitzung endlos offen halten und keine Anfragen erzeugen, die niemand sehen will.
|
||||
* - Es fragt nie zwei Mal gleichzeitig: eine laufende Anfrage blockiert die nächste.
|
||||
* - Ein Fehlschlag ist kein Ereignis. Kein Hinweis, keine Meldung - beim nächsten Takt wieder.
|
||||
*/
|
||||
(function () {
|
||||
var endpoint = document.querySelector('meta[name="kant-tick"]');
|
||||
var token = document.querySelector('meta[name="kant-tick-token"]');
|
||||
if (!endpoint || !token || !window.fetch) {
|
||||
return;
|
||||
}
|
||||
var busy = false;
|
||||
|
||||
function tick() {
|
||||
if (busy || document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
var body = new FormData();
|
||||
body.append('_csrf_token', token.content);
|
||||
fetch(endpoint.content, { method: 'POST', body: body, credentials: 'same-origin' })
|
||||
.catch(function () {})
|
||||
.then(function () {
|
||||
busy = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Der erste Takt kommt verzögert: beim Seitenaufbau hat der Browser Wichtigeres zu tun.
|
||||
window.setTimeout(tick, 5000);
|
||||
window.setInterval(tick, 60000);
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.visibilityState === 'visible') {
|
||||
tick();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -3,6 +3,11 @@
|
||||
use App\Kernel;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
// PHP nennt in jeder Antwort seine genaue Fassung („X-Powered-By: PHP/8.4.22"). Das nützt
|
||||
// niemandem ausser dem, der nach einer Luecke genau dieser Fassung sucht. Auf gewoehnlichem
|
||||
// Hosting laesst sich expose_php nicht umstellen, dieser Aufruf wirkt trotzdem.
|
||||
header_remove('X-Powered-By');
|
||||
|
||||
require dirname(__DIR__).'/config/bootstrap.php';
|
||||
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
|
||||
$request = Request::createFromGlobals();
|
||||
|
||||
+13
-4
@@ -5,6 +5,9 @@ import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
|
||||
const origin = process.env.CMS_URL || 'http://127.0.0.1:8080';
|
||||
// Eine Startseite ist nicht die Website. Geprüft wird, was CMS_PATHS nennt - mehrere Adressen mit
|
||||
// Komma getrennt; ohne Angabe die Startseite.
|
||||
const paths = (process.env.CMS_PATHS || '/').split(',').map(p => p.trim()).filter(Boolean);
|
||||
const output = process.env.CMS_AUDIT_DIR || 'reports/lighthouse';
|
||||
const preflight = await fetch(origin);
|
||||
if (!preflight.ok || /noindex/i.test(preflight.headers.get('x-robots-tag') || '')) {
|
||||
@@ -18,10 +21,12 @@ await new Promise(resolve => portProbe.close(resolve));
|
||||
const chrome = await chromium.launch({ args: [`--remote-debugging-port=${port}`] });
|
||||
const summary = [];
|
||||
try {
|
||||
for (const path of paths) {
|
||||
const label = '/' === path ? 'home' : path.replace(/^\//, '').replace(/\//g, '-');
|
||||
for (const profile of ['mobile', 'desktop']) {
|
||||
const runs = [];
|
||||
for (let run = 1; run <= 3; run++) {
|
||||
const result = await lighthouse(origin, {
|
||||
const result = await lighthouse(origin + ('/' === path ? '' : path), {
|
||||
port,
|
||||
output: 'json',
|
||||
logLevel: 'error',
|
||||
@@ -30,18 +35,22 @@ try {
|
||||
if (lhr.configSettings.formFactor !== profile) {
|
||||
throw new Error(`Requested ${profile}, measured ${lhr.configSettings.formFactor}`);
|
||||
}
|
||||
await writeFile(`${output}/${profile}-${run}.json`, result.report);
|
||||
await writeFile(`${output}/${label}-${profile}-${run}.json`, result.report);
|
||||
runs.push(lhr);
|
||||
console.log(profile, run, Object.fromEntries(Object.entries(lhr.categories).map(([key, value]) => [key, value.score])));
|
||||
console.log(label, profile, run, Object.fromEntries(Object.entries(lhr.categories).map(([key, value]) => [key, value.score])));
|
||||
}
|
||||
const scores = {};
|
||||
for (const category of Object.keys(runs[0].categories)) {
|
||||
const values = runs.map(r => r.categories[category].score).filter(v => v !== null).sort((a, b) => a - b);
|
||||
scores[category] = values.length ? values[Math.floor(values.length / 2)] : null;
|
||||
}
|
||||
summary.push({ profile, scores, lighthouseVersion: runs[0].lighthouseVersion, settings: runs[0].configSettings, environment: runs[0].environment, note: 'Local prototype only. No customer-site acceptance or field-data claim.' });
|
||||
summary.push({ path, profile, scores, lighthouseVersion: runs[0].lighthouseVersion, settings: runs[0].configSettings, environment: runs[0].environment, note: 'Local prototype only. No customer-site acceptance or field-data claim.' });
|
||||
}
|
||||
}
|
||||
await writeFile(`${output}/summary.json`, JSON.stringify(summary, null, 2));
|
||||
for (const row of summary) {
|
||||
console.log(`${row.path} ${row.profile}: ${Object.entries(row.scores).map(([k, v]) => k + ' ' + Math.round(v * 100)).join(', ')}`);
|
||||
}
|
||||
const passed = summary.every(r => r.scores.performance >= .95 && ['accessibility', 'best-practices', 'seo'].every(c => r.scores[c] === 1) && (r.scores['agentic-browsing'] === undefined || r.scores['agentic-browsing'] === 1));
|
||||
if (!passed) process.exitCode = 1;
|
||||
} finally {
|
||||
|
||||
+89
-19
@@ -14,12 +14,41 @@ try {
|
||||
// itself. Without bin/dev-router.php they never reach the front controller - exactly the gap
|
||||
// that let them stay broken locally while every kernel test passed.
|
||||
const context = await browser.newContext();
|
||||
for (const [path, type] of [['/sitemap.xml', 'application/xml'], ['/robots.txt', 'text/plain'], ['/feed.xml', 'application/xml']]) {
|
||||
const sitemap = await context.request.get(origin + '/sitemap.xml');
|
||||
assert.equal(sitemap.status(), 200, '/sitemap.xml');
|
||||
assert.match(sitemap.headers()['content-type'] ?? '', /application\/xml/, '/sitemap.xml');
|
||||
// Eine Website ohne Beiträge kennt weder Archiv noch Feed: dann ist 404 die richtige Antwort,
|
||||
// und die Sitemap führt die Adresse gar nicht erst auf.
|
||||
const sitemapText = await sitemap.text();
|
||||
const hasPosts = sitemapText.includes('/beitraege');
|
||||
for (const [path, type] of [['/robots.txt', 'text/plain'], ['/feed.xml', 'application/xml']]) {
|
||||
const response = await context.request.get(origin + path);
|
||||
assert.equal(response.status(), 200, path);
|
||||
assert.match(response.headers()['content-type'] ?? '', new RegExp(type), path);
|
||||
const expected = '/feed.xml' === path && !hasPosts ? 404 : 200;
|
||||
assert.equal(response.status(), expected, path);
|
||||
// Auch die 404 muss aus der Anwendung kommen - der eingebaute PHP-Server beantwortet Dateien
|
||||
// mit Endung sonst selbst, und genau das soll dieser Test aufdecken.
|
||||
assert.ok('x-kant-queries' in response.headers(), `${path}: nicht von der Anwendung beantwortet`);
|
||||
if (200 === expected) assert.match(response.headers()['content-type'] ?? '', new RegExp(type), path);
|
||||
}
|
||||
results.push({ name: 'machine readable routes reachable', paths: 3, hasPosts });
|
||||
|
||||
// Die Schutzkopfzeilen einer öffentlichen Seite. Sie fehlen lautlos, wenn jemand eine Antwort
|
||||
// an der Stelle vorbeibaut, die sie setzt - deshalb werden sie gemessen und nicht angenommen.
|
||||
{
|
||||
const seite = await context.request.get(origin + '/');
|
||||
const kopf = seite.headers();
|
||||
const csp = kopf['content-security-policy'] ?? '';
|
||||
assert.match(csp, /default-src 'self'/, 'Content-Security-Policy fehlt');
|
||||
assert.match(csp, /object-src 'none'/, 'Plugins müssen verboten bleiben');
|
||||
assert.match(csp, /frame-ancestors 'none'/, 'Die Seite darf nicht in einen fremden Rahmen');
|
||||
assert.match(csp, /base-uri 'none'/, 'Ein eingeschleustes <base> würde jeden Verweis umlenken');
|
||||
assert.equal(kopf['x-content-type-options'], 'nosniff');
|
||||
assert.match(kopf['referrer-policy'] ?? '', /strict-origin/);
|
||||
assert.match(kopf['permissions-policy'] ?? '', /camera=\(\)/);
|
||||
// Und was nicht dastehen darf: die Serverkennung verrät, wonach ein Angreifer suchen kann.
|
||||
assert.ok(!('x-powered-by' in kopf) || !/php/i.test(kopf['x-powered-by']), 'X-Powered-By nennt die PHP-Fassung');
|
||||
results.push({ name: 'security headers', csp: csp.length });
|
||||
}
|
||||
results.push({ name: 'machine readable routes reachable', paths: 3 });
|
||||
await context.close();
|
||||
|
||||
// A load budget, measured rather than assumed: what a page actually transfers, and how many
|
||||
@@ -34,7 +63,7 @@ try {
|
||||
const length = Number(response.headers()['content-length'] ?? 0);
|
||||
transferred.set(response.url(), { type, length });
|
||||
});
|
||||
for (const path of ['/', '/beitraege']) {
|
||||
for (const path of hasPosts ? ['/', '/beitraege'] : ['/']) {
|
||||
transferred.clear();
|
||||
const response = await page.goto(origin + path, { waitUntil: 'networkidle' });
|
||||
const queries = Number(response.headers()['x-kant-queries'] ?? -1);
|
||||
@@ -42,29 +71,49 @@ try {
|
||||
const styles = [...transferred.values()].filter(entry => entry.type === 'text/css').reduce((sum, entry) => sum + entry.length, 0);
|
||||
const scripts = [...transferred.values()].filter(entry => entry.type.includes('javascript')).length;
|
||||
const requests = transferred.size;
|
||||
// Bilder gehören zum Inhalt: eine bebilderte Startseite lädt zu Recht mehr Dateien als eine
|
||||
// Textseite. Gedeckelt wird deshalb das Beiwerk (Stile, Schriften, Skripte) und das
|
||||
// übertragene Gewicht - nicht die Zahl der Bilder.
|
||||
const beiwerk = [...transferred.values()].filter(entry => !entry.type.startsWith('image/')).length;
|
||||
const gewicht = [...transferred.values()].reduce((sum, entry) => sum + entry.length, 0);
|
||||
|
||||
assert.ok(html < 150000, `${path}: HTML ${html} Bytes, Obergrenze 150000`);
|
||||
assert.ok(styles < 120000, `${path}: CSS ${styles} Bytes, Obergrenze 120000`);
|
||||
// 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`);
|
||||
// Eine öffentliche Seite lädt kein Skript, solange sie keines braucht - und das steht in
|
||||
// ihrem eigenen Kopf: nur wenn die Seite Skripte erlaubt (Reiter, Slider mit Autolauf,
|
||||
// eigener Code), darf überhaupt eines kommen, dann höchstens eines.
|
||||
const erlaubt = !/script-src 'none'/.test(response.headers()['content-security-policy'] ?? '');
|
||||
assert.ok(erlaubt ? scripts <= 1 : 0 === scripts, `${path}: ${scripts} Skript(e) geladen, erlaubt ${erlaubt ? 1 : 0}`);
|
||||
assert.ok(beiwerk <= 12, `${path}: ${beiwerk} Anfragen ohne Bilder, Obergrenze 12`);
|
||||
assert.ok(gewicht < 1500000, `${path}: ${gewicht} Bytes übertragen, Obergrenze 1500000`);
|
||||
if (queries >= 0) assert.ok(queries <= 20, `${path}: ${queries} Datenbankabfragen, Obergrenze 20`);
|
||||
results.push({ name: `load budget ${path}`, html, styles, requests, queries });
|
||||
console.log(`Budget ${path}: HTML ${html} B, CSS ${styles} B, ${requests} Anfragen, ${queries} Abfragen, ${scripts} Skripte`);
|
||||
results.push({ name: `load budget ${path}`, html, styles, requests, beiwerk, gewicht, queries });
|
||||
console.log(`Budget ${path}: HTML ${html} B, CSS ${styles} B, ${requests} Anfragen (${beiwerk} ohne Bilder), ${gewicht} B gesamt, ${queries} Abfragen, ${scripts} Skripte`);
|
||||
}
|
||||
await budget.close();
|
||||
}
|
||||
|
||||
// Die Seiten, die geprüft werden, stehen in der Sitemap - vorher waren es drei fest
|
||||
// geschriebene Adressen der Demo, und gegen eine andere Installation lief der Check ins Leere.
|
||||
const seiten = ['/', ...[...sitemapText.matchAll(/<loc>([^<]+)<\/loc>/g)]
|
||||
.map(treffer => new URL(treffer[1]).pathname)
|
||||
.filter(pfad => '/' !== pfad && !pfad.startsWith('/beitraege') && !pfad.startsWith('/kategorie/') && !pfad.startsWith('/schlagwort/'))
|
||||
.slice(0, 2)];
|
||||
|
||||
for (const width of [1440, 390]) {
|
||||
const context = await browser.newContext({ viewport: { width, height: 1000 } });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
for (const path of ['/', '/grundlagen', '/projektstand']) {
|
||||
for (const path of seiten) {
|
||||
const response = await page.goto(origin + path);
|
||||
assert.equal(response.status(), 200);
|
||||
assert.equal(await page.locator('h1').count(), 1);
|
||||
assert.equal(await page.locator('script').count(), 0);
|
||||
// Strukturierte Daten stehen zwar in einem script-Element, führen aber nichts aus. Gezählt
|
||||
// wird, was laufen kann - und das nur, wenn die Seite es in ihrer eigenen Richtlinie erlaubt.
|
||||
const laufend = await page.locator('script:not([type="application/ld+json"])').count();
|
||||
const darfSkripte = !/script-src 'none'/.test(response.headers()['content-security-policy'] ?? '');
|
||||
assert.ok(darfSkripte ? laufend <= 1 : 0 === laufend, `${path}: ${laufend} Skript(e), erlaubt ${darfSkripte ? 1 : 0}`);
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'Horizontal overflow');
|
||||
const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'best-practice']).analyze();
|
||||
|
||||
@@ -100,22 +149,43 @@ try {
|
||||
await page.screenshot({ path: `reports/${width}-${path === '/' ? 'home' : path.slice(1)}.png`, fullPage: true });
|
||||
}
|
||||
await page.goto(origin);
|
||||
// Steht der Einwilligungshinweis offen, liegt er über dem Inhalt - ein Besucher entscheidet
|
||||
// sich zuerst, und danach ist die Seite frei. Genau so geht dieser Test auch vor.
|
||||
const hinweis = page.locator('.consent-bar button[value="none"]');
|
||||
if (await hinweis.count()) {
|
||||
await hinweis.click();
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
}
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await page.locator(':focus').innerText(), 'Zum Inhalt springen');
|
||||
await page.keyboard.press('Enter');
|
||||
assert.equal(await page.locator(':focus').getAttribute('id'), 'main');
|
||||
await page.getByRole('link', { name: 'Die Grundlage entdecken' }).click();
|
||||
assert.equal(new URL(page.url()).pathname, '/grundlagen');
|
||||
await page.getByRole('link', { name: 'Projektstand' }).click();
|
||||
assert.equal(new URL(page.url()).pathname, '/projektstand');
|
||||
// Weiter mit der Tastatur: der erste Verweis im Inhalt muss anklickbar sein und tatsächlich
|
||||
// woandershin führen. Vorher standen hier die Linktexte der Demo - gegen jede andere
|
||||
// Installation lief die Prüfung damit ins Leere statt etwas zu prüfen.
|
||||
const ersterLink = page.locator('main a[href^="/"]').first();
|
||||
const ziel = await ersterLink.getAttribute('href');
|
||||
await ersterLink.click();
|
||||
assert.equal(new URL(page.url()).pathname, ziel.split('#')[0] || '/');
|
||||
assert.equal(await page.locator('h1').count(), 1, ziel + ': keine Überschrift');
|
||||
assert.deepEqual(errors, []);
|
||||
await context.close();
|
||||
}
|
||||
// Ohne JavaScript muss dieselbe Website benutzbar sein - eine öffentliche Seite lädt ohnehin
|
||||
// keines, aber das soll gemessen und nicht geglaubt werden.
|
||||
const noJs = await browser.newPage({ javaScriptEnabled: false });
|
||||
await noJs.goto(origin);
|
||||
assert.match(await noJs.locator('h1').innerText(), /Deine Inhalte/);
|
||||
await noJs.getByRole('link', { name: 'Die Grundlage entdecken' }).click();
|
||||
assert.equal(new URL(noJs.url()).pathname, '/grundlagen');
|
||||
const hinweisOhneJs = noJs.locator('.consent-bar button[value="none"]');
|
||||
if (await hinweisOhneJs.count()) {
|
||||
await hinweisOhneJs.click();
|
||||
await noJs.waitForLoadState('domcontentloaded');
|
||||
}
|
||||
assert.equal(await noJs.locator('h1').count(), 1);
|
||||
const ohneJs = noJs.locator('main a[href^="/"]').first();
|
||||
const zielOhneJs = await ohneJs.getAttribute('href');
|
||||
await ohneJs.click();
|
||||
assert.equal(new URL(noJs.url()).pathname, zielOhneJs.split('#')[0] || '/');
|
||||
assert.equal(await noJs.locator('h1').count(), 1);
|
||||
await writeFile('reports/browser.json', JSON.stringify({ origin, results, editorialFindings: findings, keyboard: 'passed', noJavaScript: 'passed' }, null, 2));
|
||||
console.log('Passed: 6 page/viewport accessibility checks, keyboard navigation, no-JS navigation.');
|
||||
if (findings.length > 0) {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Builds a release archive that runs on hosting without Composer.
|
||||
*
|
||||
* Umut is right: shared hosting usually has no Composer. A release therefore has to carry
|
||||
* `vendor/` with it, and that folder has to be built by somebody - here, on a machine that does
|
||||
* have Composer. The archive is what `install.php` downloads and what a host without Composer
|
||||
* unpacks; the Git repository stays free of `vendor/`.
|
||||
*
|
||||
* Run: php scripts/build-release.php [--out=var/releases]
|
||||
*
|
||||
* The script refuses to build rather than produce something misleading: without Composer there is
|
||||
* no way to get a dependency tree without development packages, and shipping the development one
|
||||
* would mean an archive with PHPUnit in it.
|
||||
*/
|
||||
$root = dirname(__DIR__);
|
||||
$options = getopt('', ['out::', 'keep-dev', 'url::']);
|
||||
// Wohin das Archiv beim Veröffentlichen gelegt wird. Steht fest in install.php, deshalb muss
|
||||
// sie hier bekannt sein - eine Adresse, die erst im Browser abgefragt wird, wäre eine Lücke.
|
||||
$releaseBase = rtrim($options['url'] ?? 'https://git.kant-it.de/kantarci/KantSites/releases/download', '/');
|
||||
$outDirectory = $root.'/'.trim($options['out'] ?? 'var/releases', '/');
|
||||
$version = trim((string) file_get_contents($root.'/VERSION'));
|
||||
if (1 !== preg_match('/^\d+\.\d+\.\d+$/D', $version)) {
|
||||
fwrite(STDERR, "VERSION enthält keine Fassung der Form 1.2.3.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/** Runs a command and returns [ok, output]; the shell is used because this is a build tool. */
|
||||
$run = static function (string $command) use ($root): array {
|
||||
$output = [];
|
||||
$status = 0;
|
||||
exec('cd '.escapeshellarg($root).' && '.$command.' 2>&1', $output, $status);
|
||||
|
||||
return [0 === $status, implode("\n", $output)];
|
||||
};
|
||||
|
||||
// Composer, in the order that works on a developer machine.
|
||||
$composer = null;
|
||||
foreach (['php '.escapeshellarg($root.'/composer.phar'), 'composer'] as $candidate) {
|
||||
[$ok] = $run($candidate.' --version');
|
||||
if ($ok) {
|
||||
$composer = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (null === $composer) {
|
||||
fwrite(STDERR, "Composer ist auf diesem Rechner nicht aufrufbar.\n");
|
||||
fwrite(STDERR, "Ein Release wird auf einem Build-Rechner gebaut, nicht auf dem Hosting - genau deshalb gibt es dieses Skript.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$staging = $outDirectory.'/kantsites-'.$version;
|
||||
$archive = $outDirectory.'/kantsites-'.$version.'.zip';
|
||||
if (is_dir($staging) || is_file($archive)) {
|
||||
fwrite(STDERR, sprintf("Für Fassung %s gibt es schon ein Release. Erst entfernen oder VERSION erhöhen.\n", $version));
|
||||
exit(1);
|
||||
}
|
||||
if (!is_dir($outDirectory) && !mkdir($outDirectory, 0o775, true)) {
|
||||
fwrite(STDERR, sprintf("%s ließ sich nicht anlegen.\n", $outDirectory));
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "Fassung {$version}\n";
|
||||
|
||||
// What belongs into a release: the application, and nothing that only a developer needs.
|
||||
// migrations.php und cli-config.php gehoeren dazu: ohne die erste findet Doctrine beim
|
||||
// Einrichten keine Migrationsliste und bricht mit "configuration file does not exist" ab - genau
|
||||
// dort, wo eine frische Installation ihre Tabellen anlegen will.
|
||||
$include = ['bin', 'config', 'migrations', 'public', 'src', 'templates', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION', 'README.md', 'LICENSE', '.env.example'];
|
||||
$skip = ['.git', '.github', '.claude', '.gitattributes', '.gitignore', '.php-cs-fixer.cache', 'node_modules', 'var', 'tests', 'scripts', 'reports', 'phpunit.xml.dist', '.php-cs-fixer.dist.php', 'package.json', 'package-lock.json', 'AGENTS.md', 'ENTWICKLUNGSPLAN.md', 'docs', 'composer.phar', '.venv', 'vendor', 'phpunit.cache', 'migrations.php.bak'];
|
||||
|
||||
$copy = static function (string $from, string $to) use (&$copy, $skip): int {
|
||||
$count = 0;
|
||||
if (is_file($from)) {
|
||||
@mkdir(dirname($to), 0o775, true);
|
||||
|
||||
return @copy($from, $to) ? 1 : 0;
|
||||
}
|
||||
foreach (scandir($from) ?: [] as $entry) {
|
||||
if ('.' === $entry || '..' === $entry || in_array($entry, $skip, true)) {
|
||||
continue;
|
||||
}
|
||||
$count += $copy($from.'/'.$entry, $to.'/'.$entry);
|
||||
}
|
||||
|
||||
return $count;
|
||||
};
|
||||
|
||||
$files = 0;
|
||||
foreach ($include as $entry) {
|
||||
if (file_exists($root.'/'.$entry)) {
|
||||
$files += $copy($root.'/'.$entry, $staging.'/'.$entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Die .env des Entwicklungsrechners gehört nicht in ein Release; gebraucht wird trotzdem eine,
|
||||
// denn ohne sie bootet die Anwendung nicht einmal so weit, dass der Installer erscheint. Also
|
||||
// wird die Vorlage zur .env des Releases - mit Produktionswerten und ohne Geheimnis. Das
|
||||
// Geheimnis erzeugt der erste Aufruf selbst, die Zugangsdaten schreibt der Installer nach
|
||||
// .env.local.
|
||||
copy($root.'/.env.example', $staging.'/.env');
|
||||
++$files;
|
||||
echo "Quelldateien: {$files}\n";
|
||||
|
||||
// The dependencies, without the development ones - that is the whole point of building here.
|
||||
$flags = isset($options['keep-dev']) ? '' : ' --no-dev';
|
||||
[$ok, $output] = $run(sprintf(
|
||||
'%s install%s --optimize-autoloader --no-interaction --no-progress --working-dir=%s',
|
||||
$composer,
|
||||
$flags,
|
||||
escapeshellarg($staging),
|
||||
));
|
||||
if (!$ok) {
|
||||
fwrite(STDERR, "composer install ist gescheitert:\n".$output."\n");
|
||||
exit(1);
|
||||
}
|
||||
echo "vendor/ gebaut".($flags ? ' ohne Entwicklungspakete' : ' MIT Entwicklungspaketen')."\n";
|
||||
|
||||
// The environment file is not part of a release: it holds the secret of one installation.
|
||||
foreach (['.env.local', '.env.prod.local'] as $secret) {
|
||||
if (is_file($staging.'/'.$secret)) {
|
||||
unlink($staging.'/'.$secret);
|
||||
}
|
||||
}
|
||||
|
||||
if (!class_exists(ZipArchive::class)) {
|
||||
fwrite(STDERR, "Die PHP-Erweiterung zip fehlt; ohne sie lässt sich kein Archiv schreiben.\n");
|
||||
exit(1);
|
||||
}
|
||||
$zip = new ZipArchive();
|
||||
if (true !== $zip->open($archive, ZipArchive::CREATE | ZipArchive::OVERWRITE)) {
|
||||
fwrite(STDERR, "Archiv ließ sich nicht anlegen.\n");
|
||||
exit(1);
|
||||
}
|
||||
$entries = 0;
|
||||
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($staging, FilesystemIterator::SKIP_DOTS));
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isFile()) {
|
||||
// Paths inside the archive start with the version folder, so unpacking never scatters
|
||||
// files across an existing installation.
|
||||
$zip->addFile($file->getPathname(), 'kantsites-'.$version.'/'.str_replace('\\', '/', substr($file->getPathname(), strlen($staging) + 1)));
|
||||
++$entries;
|
||||
}
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
$hash = hash_file('sha256', $archive);
|
||||
file_put_contents($archive.'.sha256', $hash.' '.basename($archive)."\n");
|
||||
|
||||
printf("\nArchiv: %s\n", $archive);
|
||||
printf("Dateien: %d, Größe: %.1f MB\n", $entries, filesize($archive) / 1048576);
|
||||
printf("SHA-256: %s\n", $hash);
|
||||
|
||||
// Und der einzelne Dateidownload: dieselbe install.php, nur mit eingetragener Adresse und
|
||||
// Prüfsumme. Ohne beides verweigert sie die Arbeit, deshalb entsteht sie erst hier.
|
||||
$installer = strtr((string) file_get_contents($root.'/scripts/install.php'), [
|
||||
"const KANT_ARCHIVE_URL = '';" => sprintf("const KANT_ARCHIVE_URL = '%s';", $releaseBase.'/v'.$version.'/'.basename($archive)),
|
||||
"const KANT_ARCHIVE_SHA256 = '';" => sprintf("const KANT_ARCHIVE_SHA256 = '%s';", $hash),
|
||||
"const KANT_VERSION = '';" => sprintf("const KANT_VERSION = '%s';", $version),
|
||||
]);
|
||||
file_put_contents($outDirectory.'/install.php', $installer);
|
||||
|
||||
printf("Installer: %s (lädt %s)\n", $outDirectory.'/install.php', $releaseBase.'/v'.$version.'/'.basename($archive));
|
||||
echo "\nBeides gehört auf die Releaseseite: das Archiv unter genau dieser Adresse, und install.php zum Herunterladen.\n";
|
||||
echo "Die Prüfsumme steckt jetzt in install.php, damit ein manipuliertes Archiv nicht entpackt wird.\n";
|
||||
+425
-4
@@ -39,6 +39,14 @@ try {
|
||||
page.on('pageerror', error => { errors.push(error.message); console.error('Editor script error:', error.message); });
|
||||
page.on('console', message => { if (message.type() === 'error' && /Content Security Policy/.test(message.text())) errors.push(message.text()); });
|
||||
page.on('dialog', dialog => dialog.accept());
|
||||
// Jede angeforderte Skriptdatei mitschreiben. `public/.htaccess` laesst .js ein Jahr lang als
|
||||
// `immutable` zwischenspeichern; eine Datei ohne Fassungskennung waere damit nach einem Update
|
||||
// fuer immer veraltet - genau so brach der Editor mit "does not provide an export named ...".
|
||||
const skripte = [];
|
||||
page.on('request', anfrage => {
|
||||
const url = new URL(anfrage.url());
|
||||
if (url.pathname.startsWith('/assets/') && url.pathname.endsWith('.js')) skripte.push(url);
|
||||
});
|
||||
const axe = async name => {
|
||||
console.log('Accessibility check:', name);
|
||||
// The no-script preview sandbox cannot host axe's frame worker. Audit the identical
|
||||
@@ -56,15 +64,218 @@ try {
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
|
||||
await page.waitForURL(origin + '/admin');
|
||||
console.log('Authenticated in isolated test installation.');
|
||||
|
||||
// --- Die Verwaltungsnavigation ------------------------------------------------------------
|
||||
// Sie steckt in einem details-Element: auf dem Telefon ein Klappmenue, am Schreibtisch die
|
||||
// stehende Spalte. Am Schreibtisch ist der Schalter ausgeblendet - und ein geschlossenes
|
||||
// details versteckt seinen Inhalt von sich aus. Ohne eine Regel, die ihn zurueckholt, ist die
|
||||
// Navigation dort nicht bloss zugeklappt, sondern unerreichbar. Genau so ist 1.5.4 ausgeliefert
|
||||
// worden, und genau das faellt hier auf.
|
||||
const navigation = page.getByRole('navigation', { name: 'Verwaltung' });
|
||||
const schalter = page.locator('.shell-menu > summary');
|
||||
assert.equal(await schalter.isVisible(), false, 'am Schreibtisch gibt es keinen Menueschalter');
|
||||
assert.equal(await navigation.isVisible(), true, 'am Schreibtisch steht die Navigation da');
|
||||
const eintrag = navigation.getByRole('link', { name: 'Seiten', exact: true });
|
||||
const flaeche = await eintrag.boundingBox();
|
||||
assert.ok(flaeche && flaeche.height > 0 && flaeche.width > 0, 'ihre Eintraege sind anklickbar');
|
||||
|
||||
// Auf dem Telefon dagegen: ein Schalter, zunaechst zu, ein Klick oeffnet.
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
assert.equal(await schalter.isVisible(), true, 'auf dem Telefon gibt es den Schalter');
|
||||
assert.equal(await navigation.isVisible(), false, 'und die Liste ist zunaechst zu');
|
||||
await schalter.click();
|
||||
assert.equal(await navigation.isVisible(), true, 'ein Klick oeffnet sie');
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
checks.push({ name: 'the administration navigation is reachable on both sizes', passed: true });
|
||||
console.log('Administration navigation passed.');
|
||||
await axe('page list');
|
||||
await page.screenshot({ path: 'reports/editor/pages.png', fullPage: true });
|
||||
await page.locator('a[href="/admin/pages/test-home"]').first().click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
console.log('Editor data loaded.');
|
||||
// Der Editor laedt fuenf Module ueber Importe, die selbst keine Fassung tragen; die
|
||||
// Importkarte in base.html.twig haengt sie an. Ohne sie kaeme das alte Modul aus dem Cache.
|
||||
assert.ok(skripte.some(url => url.pathname === '/assets/editor-model.js'), 'editor-model.js wird geladen');
|
||||
const ohneFassung = [...new Set(skripte.filter(url => !url.searchParams.has('v')).map(url => url.pathname))];
|
||||
assert.deepEqual(ohneFassung, [], `Skripte ohne Fassungskennung: ${ohneFassung.join(', ')}`);
|
||||
checks.push({ name: 'every module is requested with a version key', passed: true });
|
||||
console.log(`Fassungskennung an allen ${new Set(skripte.map(u => u.pathname)).size} Skriptdateien.`);
|
||||
console.log('Preview status:', await page.locator('#preview-status').innerText());
|
||||
const frame = page.frameLocator('#preview');
|
||||
await frame.locator('h1').waitFor();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Zum Auswählen' }).waitFor();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
// Flexible panels, contextual fields and a root insertion from a deeply selected element.
|
||||
const leftHandle = page.locator('[data-resize="left"]'), rightHandle = page.locator('[data-resize="right"]');
|
||||
await leftHandle.focus(); await page.keyboard.press('ArrowRight');
|
||||
assert.equal(await leftHandle.getAttribute('aria-valuenow'), '290');
|
||||
const rightBox = await rightHandle.boundingBox();
|
||||
await page.mouse.move(rightBox.x + 3, rightBox.y + 100); await page.mouse.down();
|
||||
await page.mouse.move(rightBox.x - 57, rightBox.y + 100, { steps: 6 }); await page.mouse.up();
|
||||
assert.equal(await rightHandle.getAttribute('aria-valuenow'), '380');
|
||||
await page.reload();
|
||||
await frame.locator('h1').waitFor();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
assert.equal(await leftHandle.getAttribute('aria-valuenow'), '290');
|
||||
assert.equal(await rightHandle.getAttribute('aria-valuenow'), '380');
|
||||
for (const width of [1920, 1100, 850, 390]) {
|
||||
await page.setViewportSize({ width, height: 1100 });
|
||||
await page.waitForTimeout(100);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true, `workspace fits ${width}`);
|
||||
for (const selector of ['.left-panel', '.right-panel', '.library-buttons']) {
|
||||
assert.equal(await page.locator(selector).first().evaluate(el => el.scrollWidth <= el.clientWidth + 1), true, `${selector} fits ${width}`);
|
||||
}
|
||||
}
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await frame.locator('h1').click();
|
||||
const sectionCount = await frame.locator('main > .block-section').count();
|
||||
await page.locator('#add-root-section').click();
|
||||
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count + 1, sectionCount);
|
||||
await page.locator('#undo').click();
|
||||
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count, sectionCount);
|
||||
await page.locator('#redo').click();
|
||||
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count + 1, sectionCount);
|
||||
await page.locator('#undo').click();
|
||||
await frame.locator('h1').click();
|
||||
await page.locator('#library [data-add="image"]').click();
|
||||
await page.locator('[data-panel="design"]').click();
|
||||
assert.equal(await page.locator('#style-fontSize').count(), 0);
|
||||
assert.equal(await page.locator('#style-objectFit').isVisible(), true);
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await page.screenshot({ path: 'reports/editor/context-image.png', fullPage: true });
|
||||
await page.getByRole('button', { name: 'Alle Eigenschaften anzeigen', exact: true }).click();
|
||||
assert.equal(await page.locator('#style-fontSize').count(), 1);
|
||||
await page.locator('#undo').click();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await frame.locator('main .block-text').first().click();
|
||||
await page.locator('[data-panel="design"]').click();
|
||||
assert.equal(await page.locator('#style-objectFit').count(), 0);
|
||||
assert.equal(await page.locator('#style-fontSize').isVisible(), true);
|
||||
const textWidth = await frame.locator('main .block-text').first().evaluate(el => ({ width: el.getBoundingClientRect().width, parent: el.parentElement.clientWidth }));
|
||||
assert.ok(Math.abs(textWidth.width - textWidth.parent) < 2, 'text uses its container width');
|
||||
const sizeGroup = page.locator('.design-group').filter({ has: page.locator('summary', { hasText: /^Größe$/ }) });
|
||||
await sizeGroup.locator('summary').click();
|
||||
await page.locator('#style-maxWidth').locator('xpath=following-sibling::select').selectOption('ch');
|
||||
await page.locator('#style-maxWidth').fill('60');
|
||||
await page.locator('#style-maxWidth').blur();
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('#preview').contentDocument.querySelector('main .block-text');
|
||||
return Boolean(el && el.getBoundingClientRect().width < el.parentElement.clientWidth - 100);
|
||||
});
|
||||
await page.getByLabel('Maximale Breite Vorgabe', { exact: true }).selectOption('none');
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('#preview').contentDocument.querySelector('main .block-text');
|
||||
return Boolean(el && getComputedStyle(el).maxWidth === 'none');
|
||||
});
|
||||
await page.locator('#undo').click();
|
||||
await page.locator('#undo').click();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await page.locator('.right-panel').evaluate(el => el.scrollTop = 0);
|
||||
await page.locator('.left-panel > .panel-section').first().evaluate(el => el.scrollTop = 0);
|
||||
await page.screenshot({ path: 'reports/editor/flexible-workspace.png', fullPage: true });
|
||||
await leftHandle.dblclick(); await rightHandle.dblclick();
|
||||
await page.locator('[data-panel="content"]').click();
|
||||
checks.push({ name: 'resizable persistent panels, root sections, contextual image/text properties and full text width', passed: true });
|
||||
|
||||
// A layout starts empty: it must be visible, reachable and editable before it has any text.
|
||||
const previewReady = () => page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await frame.locator('h1').click();
|
||||
await page.locator('#add-root-section').click();
|
||||
await previewReady();
|
||||
const emptySection = frame.locator('main > .editor-empty-container');
|
||||
await emptySection.waitFor();
|
||||
const newSectionId = await emptySection.getAttribute('id');
|
||||
assert.ok((await emptySection.boundingBox()).height > 100, 'new empty section has a visible hit area');
|
||||
await page.screenshot({ path: 'reports/editor/empty-section.png', fullPage: true });
|
||||
await frame.getByRole('button', { name: '2 Spalten in dieser Sektion anlegen', exact: true }).press('Enter');
|
||||
await previewReady();
|
||||
const emptyColumns = frame.locator(`#${newSectionId} .block-column`);
|
||||
assert.equal(await emptyColumns.count(), 2);
|
||||
for (let index = 0; index < 2; index++) assert.ok((await emptyColumns.nth(index).boundingBox()).height > 80, 'empty column has a hit area');
|
||||
const secondColumnId = await emptyColumns.nth(1).getAttribute('id');
|
||||
const firstColumnId = await emptyColumns.first().getAttribute('id');
|
||||
await frame.getByRole('button', { name: 'Spalte 2 von 2: Inhalt hinzufügen', exact: true }).press('Enter');
|
||||
assert.equal(await page.locator('#library-search').evaluate(el => el === document.activeElement), true);
|
||||
assert.match(await page.locator('#insert-hint').innerText(), /Spalte 2 von 2/);
|
||||
await page.locator('#library-search').fill('Text');
|
||||
await page.locator('#library [data-add="text"]').click();
|
||||
await frame.locator(`#${secondColumnId} .block-text`).waitFor();
|
||||
await previewReady();
|
||||
assert.equal(await frame.locator(`#${firstColumnId} .editor-empty`).count(), 1, 'unfilled sibling stays visible');
|
||||
await page.locator('#library-search').fill('');
|
||||
const dragSource = page.locator('#library [data-add="button"]');
|
||||
await frame.locator(`#${firstColumnId} .editor-empty`).scrollIntoViewIfNeeded();
|
||||
await dragSource.scrollIntoViewIfNeeded();
|
||||
const sourceBox = await dragSource.boundingBox();
|
||||
let targetBox = await frame.locator(`#${firstColumnId} .editor-empty`).boundingBox();
|
||||
await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(sourceBox.x + sourceBox.width / 2 + 10, sourceBox.y + sourceBox.height / 2, { steps: 3 });
|
||||
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { steps: 10 });
|
||||
// Chromium dispatches dragenter on the first move into a target, dragover on the next.
|
||||
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2);
|
||||
await page.locator('.ov-drop-label').filter({ hasText: 'Spalte 1 von 2' }).waitFor();
|
||||
await page.screenshot({ path: 'reports/editor/empty-layout-drag.png', fullPage: true });
|
||||
const beforeDragScroll = await page.locator('#preview').evaluate(el => el.contentWindow.scrollY);
|
||||
const frameBox = await page.locator('#preview').boundingBox();
|
||||
await page.mouse.move(frameBox.x + 50, frameBox.y + frameBox.height - 8, { steps: 5 });
|
||||
await page.mouse.move(frameBox.x + 50, frameBox.y + frameBox.height - 8);
|
||||
await page.waitForFunction(top => document.querySelector('#preview').contentWindow.scrollY > top + 10, beforeDragScroll);
|
||||
await page.mouse.move(frameBox.x + 50, frameBox.y + frameBox.height / 2);
|
||||
await page.mouse.move(frameBox.x + 50, frameBox.y + frameBox.height / 2);
|
||||
await frame.locator(`#${firstColumnId} .editor-empty`).scrollIntoViewIfNeeded();
|
||||
targetBox = await frame.locator(`#${firstColumnId} .editor-empty`).boundingBox();
|
||||
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { steps: 6 });
|
||||
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2);
|
||||
await page.mouse.up();
|
||||
await frame.locator(`#${firstColumnId} .button`).waitFor();
|
||||
assert.equal(await frame.locator(`#${newSectionId} .editor-empty`).count(), 0, 'placeholders disappear after filling');
|
||||
await page.locator('#undo').click(); await previewReady();
|
||||
assert.equal(await frame.locator(`#${firstColumnId} .editor-empty`).count(), 1, 'undo restores the empty target');
|
||||
await page.locator('#redo').click(); await previewReady();
|
||||
await frame.locator(`#${firstColumnId} .button`).waitFor();
|
||||
await page.locator('#undo').click(); await previewReady();
|
||||
await page.locator('#undo').click(); await previewReady();
|
||||
await page.screenshot({ path: 'reports/editor/empty-layout-desktop.png', fullPage: true });
|
||||
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
|
||||
await frame.getByRole('button', { name: 'Spalte 2 von 2: Inhalt hinzufügen', exact: true }).scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: 'reports/editor/empty-layout-mobile.png', fullPage: true });
|
||||
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
|
||||
await page.locator('#save').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await page.reload(); await previewReady();
|
||||
assert.equal(await frame.locator(`#${newSectionId} .editor-empty`).count(), 2, 'saved empty columns survive reload');
|
||||
for (const device of ['Desktop', 'Mobil']) {
|
||||
await page.getByRole('button', { name: device, exact: true }).click();
|
||||
const destination = frame.locator(`#${secondColumnId}`);
|
||||
await destination.scrollIntoViewIfNeeded();
|
||||
const box = await destination.boundingBox();
|
||||
await page.locator(`#structure [data-block-id="${firstColumnId}"]`).dragTo(destination, {
|
||||
targetPosition: device === 'Desktop' ? { x: box.width - 8, y: 8 } : { x: 8, y: box.height - 8 },
|
||||
});
|
||||
await previewReady();
|
||||
assert.equal(await frame.locator(`#${newSectionId} .block-column`).first().getAttribute('id'), secondColumnId, `column order follows the ${device} layout`);
|
||||
await page.locator('#undo').click(); await previewReady();
|
||||
assert.equal(await frame.locator(`#${newSectionId} .block-column`).first().getAttribute('id'), firstColumnId);
|
||||
}
|
||||
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
|
||||
const savedEmptyDraft = await page.evaluate(async () => (await (await fetch(document.querySelector('#editor').dataset.api)).json()).document);
|
||||
assert.equal(JSON.stringify(savedEmptyDraft).includes('editor-empty'), false, 'editing aids never enter content');
|
||||
const publicBefore = await (await fetch(origin)).text();
|
||||
assert.equal(publicBefore.includes(newSectionId), false, 'draft remains private');
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
const publishedEmpty = await (await fetch(origin)).text();
|
||||
assert.ok(publishedEmpty.includes(newSectionId));
|
||||
assert.equal(/editor-empty|data-editor-target|preview-containers|preview\.css|Inhalt hinzufügen/.test(publishedEmpty), false, 'public output has no editor aids');
|
||||
// Restore the fixture through the same editor actions; subsequent tests retain their baseline.
|
||||
await page.locator(`#structure [data-block-id="${newSectionId}"]`).click();
|
||||
await page.locator('#delete').click();
|
||||
await page.locator('#save').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
await page.reload(); await previewReady();
|
||||
checks.push({ name: 'empty sections and columns: keyboard insertion, exact drop target, undo, responsive preview, reload and public isolation', passed: true });
|
||||
await frame.locator('h1').click();
|
||||
assert.equal(await page.locator('#properties-title').innerText(), 'Überschrift');
|
||||
await page.getByLabel('Text', { exact: true }).fill('Meine bearbeitete Website');
|
||||
@@ -75,15 +286,158 @@ try {
|
||||
await page.getByRole('button', { name: 'Wiederholen', exact: true }).click();
|
||||
await frame.getByRole('heading', { name: 'Meine bearbeitete Website', exact: true }).waitFor();
|
||||
checks.push({ name: 'preview selection, property edit, undo and redo', passed: true });
|
||||
|
||||
// --- Die Vorschau als Arbeitsflaeche -------------------------------------------------------
|
||||
// Rahmen, Werkzeugleiste am Element, Einfuegepunkte und Ziehen aus dem Werkzeugkasten.
|
||||
const inPreview = selector => page.evaluate(
|
||||
sel => document.querySelector('#preview').contentDocument?.querySelector(sel)?.id ?? null, selector);
|
||||
await frame.locator('h1').click();
|
||||
await page.locator('.ov-select').waitFor({ state: 'visible' });
|
||||
assert.equal(await page.locator('.ov-toolbar-name').innerText(), 'Überschrift');
|
||||
await page.getByRole('toolbar', { name: 'Aktionen für das gewählte Element' }).waitFor();
|
||||
// Die Leiste benennt das Element, auf das sie wirkt - sonst haette sie denselben Namen wie
|
||||
// die gleichnamige Schaltflaeche in der Seitenleiste.
|
||||
await page.getByRole('button', { name: 'Überschrift duplizieren', exact: true }).waitFor();
|
||||
await page.getByRole('button', { name: 'Element über Überschrift auswählen', exact: true }).click();
|
||||
assert.notEqual(await page.locator('#properties-title').innerText(), 'Überschrift');
|
||||
|
||||
// Einfuegepunkt: das naechste Element landet genau dort und nicht am Ende.
|
||||
await frame.locator('h1').click();
|
||||
const headingId = await inPreview('h1');
|
||||
await page.getByRole('button', { name: 'Element dahinter einfügen', exact: true }).click();
|
||||
await page.locator('#insert-hint').waitFor({ state: 'visible' });
|
||||
await page.locator('#library button[data-add="text"]').click();
|
||||
assert.equal(await page.locator('#properties-title').innerText(), 'Text');
|
||||
assert.equal(await page.locator('#insert-hint').isVisible(), false);
|
||||
await page.waitForFunction(id => {
|
||||
const next = document.querySelector('#preview').contentDocument?.querySelector(`#${id}`)?.nextElementSibling;
|
||||
return Boolean(next?.classList.contains('block-text'));
|
||||
}, headingId);
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
|
||||
// Ziehen aus dem Werkzeugkasten auf die Seite.
|
||||
await frame.locator('h1').waitFor();
|
||||
const beforeButtons = await page.locator('#structure [data-block-id]').count();
|
||||
await page.locator('#library button[data-add="button"]').dragTo(frame.locator('h1'), { targetPosition: { x: 12, y: 4 } });
|
||||
await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count + 1, beforeButtons);
|
||||
assert.equal(await page.locator('#properties-title').innerText(), 'Button');
|
||||
// Oben abgelegt heisst davor, nicht dahinter.
|
||||
await page.waitForFunction(id => {
|
||||
const previous = document.querySelector('#preview').contentDocument?.querySelector(`#${id}`)?.previousElementSibling;
|
||||
return Boolean(previous?.classList.contains('button'));
|
||||
}, headingId);
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count, beforeButtons);
|
||||
checks.push({ name: 'canvas toolbar, insert points and drag from the toolbox', passed: true });
|
||||
await page.screenshot({ path: 'reports/editor/canvas-overlay.png' });
|
||||
// --- Der Werkzeugkasten kennt den Zusammenhang ---------------------------------------------
|
||||
// Eine Zeile kommt nicht aus dem Nichts, und eine Spalte gehoert in eine Zeile.
|
||||
const toolboxState = type => page.evaluate(selector => {
|
||||
const button = document.querySelector(selector);
|
||||
return button ? { disabled: button.disabled, title: button.title } : null;
|
||||
}, `#library button[data-add="${type}"]:not([data-row])`);
|
||||
const rowState = () => page.evaluate(() => {
|
||||
const button = document.querySelector('#library button[data-row="2"]');
|
||||
return { disabled: button.disabled, title: button.title };
|
||||
});
|
||||
|
||||
await page.locator('#select-page').click();
|
||||
assert.equal((await toolboxState('column')).disabled, true, 'Spalte ohne Zeile gesperrt');
|
||||
assert.match((await toolboxState('column')).title, /gehört in Zeile/);
|
||||
assert.equal((await rowState()).disabled, true, 'Zeile ohne Sektion gesperrt');
|
||||
assert.match((await rowState()).title, /gehört in eine Sektion/);
|
||||
assert.equal((await toolboxState('section')).disabled, false, 'Sektion geht immer');
|
||||
|
||||
// In einer Sektion wird die Zeile moeglich, die Spalte noch nicht. Die Sektion kommt aus dem
|
||||
// Strukturbaum, nicht aus der Vorschau: dort steht auch der Kopfbereich, und der gehoert
|
||||
// einem anderen Dokument.
|
||||
await page.locator('#structure .tree-item').filter({ hasText: 'Sektion' }).first().click();
|
||||
assert.equal(await page.locator('#properties-title').innerText(), 'Sektion');
|
||||
assert.equal((await rowState()).disabled, false, 'Zeile in der Sektion moeglich');
|
||||
assert.equal((await toolboxState('column')).disabled, true, 'Spalte in der Sektion weiterhin gesperrt');
|
||||
|
||||
// Und in der Zeile wird die Spalte moeglich.
|
||||
const columnsBefore = await page.locator('#structure .tree-item').filter({ hasText: 'Spalte' }).count();
|
||||
await page.locator('#library button[data-row="3"]').click();
|
||||
assert.equal(await page.locator('#properties-title').innerText(), 'Zeile');
|
||||
assert.equal((await toolboxState('column')).disabled, false, 'Spalte in der Zeile moeglich');
|
||||
assert.match((await toolboxState('column')).title, /Wird in Zeile eingefügt/);
|
||||
// Die Zeile bringt ihre drei Spalten mit. Die Testseite hat schon welche, also zaehlt die
|
||||
// Differenz.
|
||||
assert.equal(await page.locator('#structure .tree-item').filter({ hasText: 'Spalte' }).count(), columnsBefore + 3);
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
// Jeder Eintrag traegt eine Zeichnung statt eines Schriftzeichens.
|
||||
const mitBild = await page.locator('#library button .library-preview svg').count();
|
||||
const knoepfe = await page.locator('#library button').count();
|
||||
assert.equal(mitBild, knoepfe, `alle ${knoepfe} Eintraege haben ein Vorschaubild`);
|
||||
|
||||
// Die Suche greift ueber alle Gruppen und blendet leere Gruppen aus.
|
||||
await page.locator('#library-search').fill('galerie');
|
||||
await page.locator('#library-found').filter({ hasText: 'von' }).waitFor();
|
||||
assert.equal(await page.locator('#library button:visible').count(), 1, 'genau die Galerie bleibt');
|
||||
assert.equal(await page.locator('#library .library-group:visible').count(), 1, 'nur ihre Gruppe bleibt');
|
||||
// Auch der Gruppenname findet: "Aufbau" gehoert zu Sektion, Zeile und Spalte.
|
||||
await page.locator('#library-search').fill('aufbau');
|
||||
assert.equal(await page.locator('#library button:visible').count(), 5, 'Sektion, drei Zeilen, Spalte');
|
||||
await page.locator('#library-search').fill('gibtesnicht');
|
||||
await page.locator('#library-found').filter({ hasText: 'Kein Element' }).waitFor();
|
||||
await page.locator('#library-search').fill('');
|
||||
assert.equal(await page.locator('#library button:visible').count(), knoepfe, 'leere Suche zeigt wieder alles');
|
||||
|
||||
checks.push({ name: 'the toolbox follows the structure', passed: true });
|
||||
await page.screenshot({ path: 'reports/editor/toolbox.png' });
|
||||
console.log('Context aware toolbox passed.');
|
||||
|
||||
// Zurueck in den Zustand, den dieser Abschnitt vorgefunden hat: die folgenden Pruefungen
|
||||
// setzen eine bestehende Auswahl voraus.
|
||||
await frame.getByRole('heading', { name: 'Meine bearbeitete Website', exact: true }).click();
|
||||
await page.locator('#duplicate:not([disabled])').waitFor();
|
||||
console.log('Canvas overlay, insert points and drag and drop passed.');
|
||||
|
||||
// Die Vorschau darf nie sichtbar sein, solange ihre Gestaltung fehlt. Vorher hing das Anwenden
|
||||
// am Ladeereignis des Rahmens - und das feuert erst, wenn das letzte Bild geladen ist. Auf der
|
||||
// nachgebauten Startseite von kant-it.de stand die Vorschau dadurch 1440 ms lang mit falschen
|
||||
// Farben da: schwarze Knöpfe statt roter.
|
||||
const beobachtung = page.evaluate(async () => {
|
||||
const rahmen = document.getElementById('preview');
|
||||
const verlauf = [];
|
||||
let ungestylt = 0;
|
||||
// Zweierlei wird festgehalten: dass der Rahmen ueberhaupt verdeckt wird, waehrend das neue
|
||||
// Dokument entsteht - und ob er je sichtbar war, ohne seine Gestaltung zu haben.
|
||||
new MutationObserver(() => verlauf.push(rahmen.style.visibility || 'sichtbar'))
|
||||
.observe(rahmen, { attributes: true, attributeFilter: ['style'] });
|
||||
const bis = performance.now() + 5000;
|
||||
while (performance.now() < bis) {
|
||||
const doc = rahmen.contentDocument;
|
||||
if ('visible' === getComputedStyle(rahmen).visibility
|
||||
&& doc?.getElementById('kant-styles')
|
||||
&& 0 === (doc.adoptedStyleSheets ?? []).length) {
|
||||
ungestylt++;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 30));
|
||||
}
|
||||
return { verlauf, ungestylt };
|
||||
});
|
||||
await page.click('#duplicate');
|
||||
const { verlauf, ungestylt } = await beobachtung;
|
||||
assert.equal(ungestylt, 0, 'Die Vorschau war sichtbar, bevor ihre Gestaltung angewandt war.');
|
||||
// Der Rahmen muss verdeckt worden und danach wieder sichtbar geworden sein. Ohne diese Prüfung
|
||||
// hinge der Nachweis am Zufall: auf einer kleinen Seite ist das Fenster zu kurz, um es zu
|
||||
// treffen - auf der nachgebauten Startseite von kant-it.de war es 1440 ms lang.
|
||||
assert.ok(verlauf.includes('hidden'), 'Die Vorschau wird beim Aufbau nicht verdeckt.');
|
||||
assert.equal(verlauf[verlauf.length - 1], 'sichtbar', 'Die Vorschau bleibt verdeckt.');
|
||||
await page.click('#undo');
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
checks.push({ name: 'preview never shows unstyled content', passed: true });
|
||||
console.log('Preview edits and history passed.');
|
||||
|
||||
await page.getByRole('button', { name: 'Einklappen', exact: true }).click();
|
||||
assert.equal(await page.locator('#structure .tree-item').count(), 3);
|
||||
await page.getByLabel('Element suchen', { exact: true }).fill('Meine bearbeitete');
|
||||
await page.getByLabel('Element auf der Seite suchen', { exact: true }).fill('Meine bearbeitete');
|
||||
assert.equal(await page.locator('#structure .tree-item').count(), 2);
|
||||
await page.locator('#structure [data-block-id="headline"]').focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByLabel('Element suchen', { exact: true }).fill('');
|
||||
await page.getByLabel('Element auf der Seite suchen', { exact: true }).fill('');
|
||||
assert.equal(await page.locator('#structure [data-block-id="headline"]').isVisible(), true);
|
||||
await page.getByRole('button', { name: 'Alle öffnen', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
||||
@@ -148,6 +502,73 @@ try {
|
||||
await setLength('style-padding-top', 32);
|
||||
await frameValue('#hero', 'paddingTop', '32px');
|
||||
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
|
||||
|
||||
// --- Die Optionen selbst ------------------------------------------------------------------
|
||||
// Geraetechips je Option, Abstandsrahmen mit Kette, Ziehfeld.
|
||||
|
||||
// Der Chip zeigt, wo ein abweichender Wert liegt: Innenabstand hat oben gerade Desktop und
|
||||
// Mobil bekommen, Tablet nicht.
|
||||
const chip = device => page.locator(`#style-padding-unit`).locator('xpath=ancestor::div[contains(@class,"side-wrap")]/preceding-sibling::div[contains(@class,"design-side-head")][1]')
|
||||
.locator(`.device-chip[data-device="${device}"]`);
|
||||
await chip('desktop').waitFor();
|
||||
assert.equal(await chip('desktop').evaluate(el => el.classList.contains('device-chip-own')), true, 'Desktop hat einen eigenen Innenabstand');
|
||||
assert.equal(await chip('mobile').evaluate(el => el.classList.contains('device-chip-own')), true, 'Mobil hat einen eigenen Innenabstand');
|
||||
assert.equal(await chip('tablet').evaluate(el => el.classList.contains('device-chip-own')), false, 'Tablet erbt');
|
||||
assert.equal(await chip('desktop').getAttribute('aria-pressed'), 'true', 'Desktop ist die bearbeitete Groesse');
|
||||
|
||||
// Ein Klick auf den Chip wechselt Bedienfeld und Vorschau zusammen.
|
||||
await chip('mobile').click();
|
||||
assert.equal(await page.locator('[data-viewport="mobile"]').getAttribute('aria-pressed'), 'true');
|
||||
await chip('desktop').click();
|
||||
assert.equal(await page.locator('[data-viewport="desktop"]').getAttribute('aria-pressed'), 'true');
|
||||
|
||||
// Der Rahmen aendert ohne Kette nur die eine Seite.
|
||||
await setLength('style-padding-left', 24);
|
||||
await frameValue('#hero', 'paddingLeft', '24px');
|
||||
await frameValue('#hero', 'paddingRight', '0px');
|
||||
// Mit Kette werden alle vier gleichgezogen.
|
||||
await page.locator('.side-link').first().click();
|
||||
assert.equal(await page.locator('.side-link').first().getAttribute('aria-pressed'), 'true');
|
||||
await setLength('style-padding-top', 40);
|
||||
for (const seite of ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft']) await frameValue('#hero', seite, '40px');
|
||||
await page.locator('.side-link').first().click();
|
||||
|
||||
// Die gemeinsame Einheit schreibt die gesetzten Seiten um, ohne Zahlen zu verlieren.
|
||||
await page.locator('#style-padding-unit').selectOption('rem');
|
||||
await frameValue('#hero', 'paddingTop', '640px');
|
||||
|
||||
// Das Ziehfeld: seitwaerts ziehen aendert den Wert, und der ganze Zug ist ein Undo-Schritt.
|
||||
await page.locator('#structure [data-block-id="headline"]').click();
|
||||
await openGroup('Typografie');
|
||||
const scrub = page.locator('#style-fontSize');
|
||||
await scrub.scrollIntoViewIfNeeded();
|
||||
const box = await scrub.boundingBox();
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + 20, box.y + box.height / 2, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
const gezogen = Number(await scrub.inputValue());
|
||||
assert.equal(gezogen, 92, `Ziehen um 20 px erhoeht 72 auf 92, gemessen: ${gezogen}`);
|
||||
await frameValue('#headline', 'fontSize', '92px');
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
await frameValue('#headline', 'fontSize', '72px');
|
||||
checks.push({ name: 'device chips, spacing box and scrub fields', passed: true });
|
||||
|
||||
// Aufraeumen: dieser Abschnitt hat den Innenabstand des Hero auf 40rem an allen Seiten
|
||||
// gebracht. Das drueckt die Spaltenbreite auf null, und alles Folgende haette unsichtbare
|
||||
// Inhalte gepruef. Zurueck auf den Zustand von vorher: 96 px oben, sonst nichts.
|
||||
await page.locator('#structure [data-block-id="hero"]').click();
|
||||
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
||||
await page.locator('#style-padding-unit').selectOption('px');
|
||||
for (const seite of ['right', 'bottom', 'left']) await setLength(`style-padding-${seite}`, '');
|
||||
await setLength('style-padding-top', 96);
|
||||
await frameValue('#hero', 'paddingTop', '96px');
|
||||
await frameValue('#hero', 'paddingLeft', '0px');
|
||||
await openGroup('Abstand');
|
||||
await page.locator('#style-padding-top').scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: 'reports/editor/options.png' });
|
||||
console.log('Per option device chips, spacing box and scrub fields passed.');
|
||||
|
||||
// The background group is collapsed by default, and the readable field next to the swatch is
|
||||
// what a person types into.
|
||||
await openGroup('Hintergrund');
|
||||
@@ -437,7 +858,7 @@ try {
|
||||
await page.locator('#structure [data-block-id="headline"]').waitFor();
|
||||
await page.locator('#structure [data-block-id="headline"]').click();
|
||||
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Zum Auswählen' }).waitFor();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await axe('desktop editor');
|
||||
await page.screenshot({ path: 'reports/editor/editor-desktop.png', fullPage: true });
|
||||
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* KantSites in einer Datei installieren.
|
||||
*
|
||||
* Diese Datei per FTP in das Verzeichnis legen, in dem KantSites liegen soll, und im Browser
|
||||
* aufrufen. Sie lädt das Releasearchiv, prüft dessen Prüfsumme, entpackt es und löscht sich
|
||||
* danach selbst. Anschließend übernimmt der Installer unter /install.
|
||||
*
|
||||
* Die Regeln, nach denen sie gebaut ist - und warum:
|
||||
*
|
||||
* - Die Adresse und die Prüfsumme stehen HIER, nicht in einem Formular. Ein Installer, der eine
|
||||
* URL entgegennimmt und entpackt, ist eine Einladung, fremden Code auf den Server zu legen.
|
||||
* - Ohne passende Prüfsumme wird nicht entpackt. Es gibt kein Häkchen, das daran vorbeiführt.
|
||||
* - HTTPS mit Zertifikatsprüfung, kein Rückfall auf HTTP.
|
||||
* - Jeder Archiveintrag wird gegen das Zielverzeichnis geprüft, bevor er geschrieben wird. Ein
|
||||
* Eintrag wie ../../.env landet nirgends.
|
||||
* - Sie arbeitet nur in ein leeres Verzeichnis. Eine bestehende Installation wird nicht
|
||||
* überschrieben, auch nicht teilweise.
|
||||
*/
|
||||
|
||||
// Vom Buildskript gesetzt: `php scripts/build-release.php` schreibt Adresse und Prüfsumme des
|
||||
// gebauten Archivs hier hinein. Ohne beides verweigert die Datei die Arbeit - eine unfertige
|
||||
// install.php soll nicht irgendetwas herunterladen.
|
||||
const KANT_ARCHIVE_URL = '';
|
||||
const KANT_ARCHIVE_SHA256 = '';
|
||||
const KANT_VERSION = '';
|
||||
|
||||
/**
|
||||
* Wohin ein Archiveintrag gehört - oder null, wenn er nirgendwohin gehört.
|
||||
*
|
||||
* Der Namensbestandteil der obersten Ebene (kantsites-1.2.0/) fällt weg, damit die Dateien direkt
|
||||
* im Zielverzeichnis landen. Danach wird der Pfad geprüft, und zwar nicht durch Suchen nach „..“,
|
||||
* sondern indem der aufgelöste Zielpfad innerhalb des Zielverzeichnisses liegen muss.
|
||||
*/
|
||||
function kant_target_path(string $entry, string $target): ?string
|
||||
{
|
||||
$entry = str_replace('\\', '/', $entry);
|
||||
if ('' === $entry || str_ends_with($entry, '/')) {
|
||||
return null;
|
||||
}
|
||||
$parts = explode('/', $entry);
|
||||
if (count($parts) > 1 && str_starts_with($parts[0], 'kantsites-')) {
|
||||
array_shift($parts);
|
||||
}
|
||||
foreach ($parts as $part) {
|
||||
if ('' === $part || '.' === $part || '..' === $part || str_contains($part, "\0") || str_contains($part, ':')) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if ([] === $parts) {
|
||||
return null;
|
||||
}
|
||||
$base = rtrim(str_replace('\\', '/', $target), '/');
|
||||
$path = $base.'/'.implode('/', $parts);
|
||||
// Doppelt geprüft: der zusammengesetzte Pfad muss unterhalb des Ziels beginnen.
|
||||
return str_starts_with($path, $base.'/') ? $path : null;
|
||||
}
|
||||
|
||||
/** Lädt eine Adresse über HTTPS mit Zertifikatsprüfung in eine Datei. Gibt eine Meldung oder null. */
|
||||
function kant_download(string $url, string $file): ?string
|
||||
{
|
||||
if (!str_starts_with($url, 'https://')) {
|
||||
return 'Die Adresse des Archivs ist keine HTTPS-Adresse.';
|
||||
}
|
||||
$handle = fopen($file, 'wb');
|
||||
if (false === $handle) {
|
||||
return 'Die heruntergeladene Datei ließ sich nicht anlegen: '.$file;
|
||||
}
|
||||
try {
|
||||
if (function_exists('curl_init')) {
|
||||
$curl = curl_init($url);
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_FILE => $handle,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_CONNECTTIMEOUT => 20,
|
||||
CURLOPT_TIMEOUT => 900,
|
||||
CURLOPT_USERAGENT => 'KantSites-Installer',
|
||||
]);
|
||||
$ok = curl_exec($curl);
|
||||
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
$error = curl_error($curl);
|
||||
curl_close($curl);
|
||||
if (!$ok) {
|
||||
return 'Der Download ist gescheitert: '.$error;
|
||||
}
|
||||
if (200 !== $status) {
|
||||
return 'Der Server hat mit Status '.$status.' geantwortet statt mit 200.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
if (!filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL)) {
|
||||
return 'Weder curl noch allow_url_fopen ist verfügbar. Ohne eines von beidem kann diese Datei nichts herunterladen; dann bleibt der Weg über FTP mit dem entpackten Archiv.';
|
||||
}
|
||||
$context = stream_context_create(['http' => ['timeout' => 900, 'follow_location' => 1, 'max_redirects' => 3], 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]);
|
||||
$source = @fopen($url, 'rb', false, $context);
|
||||
if (false === $source) {
|
||||
return 'Das Archiv ließ sich nicht öffnen. Prüfe, ob dieser Server nach außen verbinden darf.';
|
||||
}
|
||||
stream_copy_to_stream($source, $handle);
|
||||
fclose($source);
|
||||
|
||||
return null;
|
||||
} finally {
|
||||
if (is_resource($handle)) {
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Entpackt das Archiv; gibt die Anzahl der Dateien zurück oder wirft mit einer lesbaren Meldung. */
|
||||
function kant_unpack(string $archive, string $target): int
|
||||
{
|
||||
if (!class_exists('ZipArchive')) {
|
||||
throw new RuntimeException('Die PHP-Erweiterung zip fehlt. Ohne sie lässt sich das Archiv nicht entpacken.');
|
||||
}
|
||||
$zip = new ZipArchive();
|
||||
if (true !== $zip->open($archive)) {
|
||||
throw new RuntimeException('Das Archiv ließ sich nicht öffnen. Der Download ist wahrscheinlich unvollständig.');
|
||||
}
|
||||
$written = 0;
|
||||
try {
|
||||
for ($index = 0; $index < $zip->numFiles; ++$index) {
|
||||
$name = (string) $zip->getNameIndex($index);
|
||||
$path = kant_target_path($name, $target);
|
||||
if (null === $path) {
|
||||
if (str_ends_with($name, '/')) {
|
||||
continue;
|
||||
}
|
||||
throw new RuntimeException('Das Archiv enthält einen Eintrag, der außerhalb des Zielverzeichnisses landen würde: '.$name);
|
||||
}
|
||||
if (!is_dir(dirname($path)) && !mkdir(dirname($path), 0o775, true) && !is_dir(dirname($path))) {
|
||||
throw new RuntimeException('Verzeichnis ließ sich nicht anlegen: '.dirname($path));
|
||||
}
|
||||
$stream = $zip->getStream($name);
|
||||
if (false === $stream) {
|
||||
throw new RuntimeException('Eintrag ließ sich nicht lesen: '.$name);
|
||||
}
|
||||
$out = fopen($path, 'wb');
|
||||
if (false === $out) {
|
||||
fclose($stream);
|
||||
throw new RuntimeException('Datei ließ sich nicht schreiben: '.$path);
|
||||
}
|
||||
stream_copy_to_stream($stream, $out);
|
||||
fclose($stream);
|
||||
fclose($out);
|
||||
++$written;
|
||||
}
|
||||
} finally {
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
return $written;
|
||||
}
|
||||
|
||||
/** Die Vorprüfung: alles, was fehlschlagen kann, bevor irgendetwas geschrieben wird. */
|
||||
function kant_checks(string $target): array
|
||||
{
|
||||
$checks = [];
|
||||
$add = static function (string $label, bool $ok, string $hint) use (&$checks): void {
|
||||
$checks[] = ['label' => $label, 'ok' => $ok, 'hint' => $hint];
|
||||
};
|
||||
$add('PHP-Fassung', PHP_VERSION_ID >= 80200, 'Gefunden: '.PHP_VERSION.'. Gebraucht wird mindestens 8.2 – beim Hoster umstellen.');
|
||||
$add('Schreibrecht im Zielverzeichnis', is_writable($target), $target.' ist nicht beschreibbar. Im FTP-Programm die Rechte auf 0775 setzen.');
|
||||
$add('ZIP-Erweiterung', class_exists('ZipArchive'), 'Ohne die Erweiterung zip lässt sich das Archiv nicht entpacken.');
|
||||
$add('Verbindung nach außen', function_exists('curl_init') || filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL), 'Weder curl noch allow_url_fopen ist verfügbar; dann bleibt nur der Weg über FTP.');
|
||||
$add('Adresse und Prüfsumme eingetragen', '' !== KANT_ARCHIVE_URL && 64 === strlen(KANT_ARCHIVE_SHA256), 'Diese Datei ist unfertig: sie stammt nicht aus einem Release. Lade install.php von der Releaseseite herunter.');
|
||||
$free = @disk_free_space($target);
|
||||
$add('Freier Platz', false === $free || $free > 80 * 1024 * 1024, 'Es sind weniger als 80 MB frei. Das Archiv braucht Platz zum Herunterladen und zum Entpacken.');
|
||||
$add('Verzeichnis ist leer genug', !is_file($target.'/public/index.php') && !is_file($target.'/.env.local'), 'Hier liegt schon eine Installation. Diese Datei überschreibt nichts – bei einem Update ist der Weg das Releasearchiv von Hand oder der Updater in der Verwaltung.');
|
||||
|
||||
return $checks;
|
||||
}
|
||||
|
||||
/** Der Ablauf, wenn der Knopf gedrückt wurde. Gibt [Erfolg, Meldung] zurück. */
|
||||
function kant_run(string $target): array
|
||||
{
|
||||
$archive = $target.'/kantsites-download-'.bin2hex(random_bytes(4)).'.zip';
|
||||
try {
|
||||
$error = kant_download(KANT_ARCHIVE_URL, $archive);
|
||||
if (null !== $error) {
|
||||
return [false, $error];
|
||||
}
|
||||
$hash = hash_file('sha256', $archive);
|
||||
if (!hash_equals(KANT_ARCHIVE_SHA256, (string) $hash)) {
|
||||
// Nicht entpacken, nicht behalten: eine Datei, die nicht das ist, was sie sein soll,
|
||||
// hat auf dem Server nichts verloren.
|
||||
return [false, 'Die Prüfsumme des heruntergeladenen Archivs stimmt nicht. Erwartet: '.KANT_ARCHIVE_SHA256.', erhalten: '.$hash.'. Es wurde nichts entpackt.'];
|
||||
}
|
||||
$files = kant_unpack($archive, $target);
|
||||
|
||||
return [true, 'Entpackt: '.$files.' Dateien.'];
|
||||
} catch (Throwable $e) {
|
||||
return [false, $e->getMessage()];
|
||||
} finally {
|
||||
@unlink($archive);
|
||||
}
|
||||
}
|
||||
|
||||
// Im Test wird diese Datei eingebunden, um die Pfadprüfung und das Entpacken zu prüfen, ohne dass
|
||||
// etwas heruntergeladen wird.
|
||||
if ('cli' === PHP_SAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
$target = __DIR__;
|
||||
$checks = kant_checks($target);
|
||||
$ready = [] === array_filter($checks, static fn (array $check): bool => !$check['ok']);
|
||||
$done = false;
|
||||
$message = null;
|
||||
if ('POST' === ($_SERVER['REQUEST_METHOD'] ?? 'GET') && $ready) {
|
||||
@set_time_limit(0);
|
||||
[$done, $message] = kant_run($target);
|
||||
if ($done) {
|
||||
// Erst jetzt: die Datei hat ihre Arbeit getan und ist ab hier nur noch ein offener Weg,
|
||||
// Code auf den Server zu legen.
|
||||
@unlink(__FILE__);
|
||||
}
|
||||
}
|
||||
|
||||
$e = static fn (?string $value): string => htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
||||
header('Content-Type: text/html; charset=UTF-8');
|
||||
header('X-Robots-Tag: noindex, nofollow');
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>KantSites installieren</title>
|
||||
<style>
|
||||
:root { color-scheme: light }
|
||||
body { margin: 0; padding: 40px 20px; background: #f6f7f4; color: #1d2320; font: 15px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif }
|
||||
main { max-width: 620px; margin: 0 auto; background: #fff; border: 1px solid #e2e7de; border-radius: 14px; padding: 32px }
|
||||
h1 { font-size: 23px; margin: 0 0 6px }
|
||||
p.lead { color: #5d6b62; margin-top: 0 }
|
||||
ul { list-style: none; margin: 22px 0; padding: 0 }
|
||||
li { display: flex; gap: 10px; align-items: flex-start; margin-bottom: 10px; font-size: 14px }
|
||||
.mark { width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; font-size: 12px; font-weight: 700; flex-shrink: 0 }
|
||||
.ok .mark { background: #e7f0d6; color: #2c5340 }
|
||||
.bad .mark { background: #fff0eb; color: #a3341f }
|
||||
.bad { color: #a3341f }
|
||||
button { font: inherit; font-weight: 600; padding: 11px 20px; border-radius: 8px; border: 1px solid #2c5340; background: #2c5340; color: #fff; cursor: pointer }
|
||||
button[disabled] { opacity: .5; cursor: not-allowed }
|
||||
.notice { padding: 14px 16px; border-radius: 10px; background: #fff0eb; color: #a3341f; font-size: 14px }
|
||||
.done { background: #e7f0d6; color: #2c5340 }
|
||||
code { font-size: 13px; background: #f1f4ee; padding: 1px 5px; border-radius: 4px }
|
||||
a { color: #2c5340 }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<?php if ($done) { ?>
|
||||
<h1>Die Dateien liegen.</h1>
|
||||
<p class="lead">Diese Datei hat sich selbst gelöscht. Weiter geht es mit der Einrichtung: Datenbank, Konto, Website.</p>
|
||||
<p class="notice done"><?= $e($message) ?></p>
|
||||
<p><a href="install">Zur Einrichtung →</a></p>
|
||||
<p style="font-size:13px;color:#5d6b62">Führt der Verweis ins Leere, zeigt die Domain nicht auf <code>public/</code>. Das muss beim Hoster eingestellt werden – ohne das wären auch <code>.env</code> und <code>var/</code> über das Netz erreichbar.</p>
|
||||
<?php } else { ?>
|
||||
<h1>KantSites installieren</h1>
|
||||
<p class="lead">Diese Datei lädt Fassung <?= $e(KANT_VERSION ?: 'unbekannt') ?> herunter, prüft die Prüfsumme und entpackt sie hierher: <code><?= $e($target) ?></code></p>
|
||||
<?php if (null !== $message) { ?><p class="notice"><?= $e($message) ?></p><?php } ?>
|
||||
<ul>
|
||||
<?php foreach ($checks as $check) { ?>
|
||||
<li class="<?= $check['ok'] ? 'ok' : 'bad' ?>">
|
||||
<span class="mark" aria-hidden="true"><?= $check['ok'] ? '✓' : '!' ?></span>
|
||||
<span><strong><?= $e($check['label']) ?></strong><?= $check['ok'] ? '' : ' – '.$e($check['hint']) ?></span>
|
||||
</li>
|
||||
<?php } ?>
|
||||
</ul>
|
||||
<form method="post">
|
||||
<button type="submit" <?= $ready ? '' : 'disabled' ?>>Herunterladen und entpacken</button>
|
||||
</form>
|
||||
<p style="font-size:13px;color:#5d6b62">Das dauert je nach Anbindung eine halbe bis mehrere Minuten. Adresse und Prüfsumme stehen fest in dieser Datei; ein Archiv mit abweichender Prüfsumme wird nicht entpackt.</p>
|
||||
<?php } ?>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Prüft jeden internen Verweis einer Website auf seine Antwort.
|
||||
*
|
||||
* Ein Link, der ins Leere zeigt, ist der Fehler, den niemand bemerkt: Tests prüfen, was der Code
|
||||
* erzeugt, nicht ob die Adresse dahinter existiert. Im Nachbau von kant-it.de zeigten vier
|
||||
* Verweise im Hero-Slider auf Adressen, die es nie gab - gefunden hat das kein Test, sondern ein
|
||||
* Durchlauf wie dieser.
|
||||
*
|
||||
* Ausgangspunkt ist die Sitemap, danach wird jeder Seite gefolgt, die von dort erreichbar ist.
|
||||
* Geprüft werden Seiten, Bilder, Stylesheets und Skripte; externe Adressen werden gezählt, aber
|
||||
* nicht abgerufen - ob fremde Server antworten, ist nicht die Frage dieser Prüfung.
|
||||
*
|
||||
* CMS_URL=http://127.0.0.1:8080 npm run test:links
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
|
||||
const origin = (process.env.CMS_URL || 'http://127.0.0.1:8080').replace(/\/$/, '');
|
||||
const browser = await chromium.launch();
|
||||
const geprueft = new Map();
|
||||
const extern = new Set();
|
||||
const kaputt = [];
|
||||
const quellen = new Map();
|
||||
|
||||
try {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
const antwort = async (url) => {
|
||||
if (!geprueft.has(url)) {
|
||||
const response = await context.request.get(url, { maxRedirects: 5 });
|
||||
geprueft.set(url, response.status());
|
||||
}
|
||||
|
||||
return geprueft.get(url);
|
||||
};
|
||||
|
||||
const sitemap = await context.request.get(origin + '/sitemap.xml');
|
||||
assert.equal(sitemap.status(), 200, 'Ohne Sitemap fehlt der Ausgangspunkt.');
|
||||
const seiten = [...(await sitemap.text()).matchAll(/<loc>([^<]+)<\/loc>/g)]
|
||||
.map(treffer => new URL(treffer[1]).pathname);
|
||||
console.log(`Sitemap: ${seiten.length} Adresse(n).`);
|
||||
|
||||
const besucht = new Set();
|
||||
const warteschlange = ['/', ...seiten];
|
||||
while (warteschlange.length > 0) {
|
||||
const pfad = warteschlange.shift();
|
||||
if (besucht.has(pfad)) continue;
|
||||
besucht.add(pfad);
|
||||
|
||||
const response = await page.goto(origin + pfad, { waitUntil: 'domcontentloaded' });
|
||||
if (200 !== response.status()) {
|
||||
kaputt.push({ status: response.status(), url: pfad, von: 'Sitemap' });
|
||||
continue;
|
||||
}
|
||||
// Verweise und eingebundene Dateien derselben Website.
|
||||
const verweise = await page.evaluate(() => ({
|
||||
links: [...document.querySelectorAll('a[href]')].map(a => a.getAttribute('href')),
|
||||
dateien: [
|
||||
...[...document.querySelectorAll('img[src]')].map(i => i.getAttribute('src')),
|
||||
...[...document.querySelectorAll('img[srcset]')].flatMap(i => i.getAttribute('srcset').split(',').map(teil => teil.trim().split(/\s+/)[0])),
|
||||
...[...document.querySelectorAll('link[rel="stylesheet"], link[rel="preload"]')].map(l => l.getAttribute('href')),
|
||||
...[...document.querySelectorAll('script[src]')].map(s => s.getAttribute('src')),
|
||||
...[...document.querySelectorAll('[style*="url("]')].flatMap(e => [...e.getAttribute('style').matchAll(/url\(["']?([^"')]+)/g)].map(m => m[1])),
|
||||
],
|
||||
stile: [...document.querySelectorAll('style')].flatMap(s => [...s.textContent.matchAll(/url\(["']?([^"')]+)/g)].map(m => m[1])),
|
||||
}));
|
||||
|
||||
for (const roh of [...verweise.links, ...verweise.dateien, ...verweise.stile]) {
|
||||
if (!roh || roh.startsWith('data:') || roh.startsWith('#') || roh.startsWith('mailto:') || roh.startsWith('tel:')) continue;
|
||||
const ziel = new URL(roh, origin + pfad);
|
||||
if (ziel.origin !== origin) {
|
||||
extern.add(ziel.origin);
|
||||
continue;
|
||||
}
|
||||
const adresse = ziel.pathname + ziel.search;
|
||||
const status = await antwort(origin + adresse);
|
||||
if (status >= 400) {
|
||||
kaputt.push({ status, url: adresse, von: pfad });
|
||||
}
|
||||
// Eigenen Seiten folgen wir weiter; Dateien nicht.
|
||||
if (verweise.links.includes(roh) && !/\.[a-z0-9]{2,5}(\?|$)/i.test(ziel.pathname) && !besucht.has(ziel.pathname)) {
|
||||
warteschlange.push(ziel.pathname);
|
||||
quellen.set(ziel.pathname, pfad);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await mkdir('reports', { recursive: true });
|
||||
await writeFile('reports/links.json', JSON.stringify({ origin, geprueft: geprueft.size, seiten: besucht.size, extern: [...extern], kaputt }, null, 2));
|
||||
console.log(`${besucht.size} Seite(n), ${geprueft.size} Adresse(n) geprüft, ${extern.size} fremde Quelle(n): ${[...extern].join(', ') || 'keine'}`);
|
||||
if (kaputt.length > 0) {
|
||||
for (const eintrag of kaputt) {
|
||||
console.error(` ${eintrag.status} ${eintrag.url} (verlinkt auf ${eintrag.von})`);
|
||||
}
|
||||
throw new Error(`${kaputt.length} Verweis(e) zeigen ins Leere.`);
|
||||
}
|
||||
console.log('Kein toter Verweis.');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Ändert die Adresse der Verwaltung.
|
||||
*
|
||||
* `/admin` ist die erste Adresse, die ein Scanner probiert, und jeder Anmeldeversuch, der dort
|
||||
* ankommt, kostet Rechenzeit und Logzeilen. Heißt die Verwaltung anders, findet sie niemand, der
|
||||
* sie nicht kennt. Das ersetzt kein gutes Passwort - es nimmt der Maschinerie nur das Ziel.
|
||||
*
|
||||
* Der Wert steht in `.env.local` und nicht in der Datenbank: die Routen stehen fest, bevor die
|
||||
* erste Datenbankabfrage läuft. Deshalb dieser Befehl - er schreibt die Datei, prüft vorher, dass
|
||||
* die Adresse benutzbar ist, und räumt den Zwischenspeicher auf, damit die neue Adresse sofort
|
||||
* gilt.
|
||||
*/
|
||||
#[AsCommand(name: 'cms:admin:path', description: 'Zeigt die Adresse der Verwaltung oder ändert sie.')]
|
||||
final class AdminPathCommand extends Command
|
||||
{
|
||||
/** Adressen, die schon vergeben sind oder es werden könnten. */
|
||||
private const TABU = ['assets', 'media', 'install', 'einwilligung', 'formular', 'kategorie', 'schlagwort', 'beitraege', 'feed', 'sitemap', 'robots', 'llms', 'favicon'];
|
||||
|
||||
public function __construct(private Connection $db, private string $projectDir, private string $adminPath, private string $environment)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addArgument('pfad', InputArgument::OPTIONAL, 'Die neue Adresse, ohne Schrägstrich. Ohne Angabe wird die aktuelle gezeigt.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$neu = trim((string) $input->getArgument('pfad'), " \t/");
|
||||
if ('' === $neu) {
|
||||
$io->writeln(sprintf('Die Verwaltung liegt unter <info>/%s</info>.', $this->adminPath));
|
||||
$io->writeln('Ändern: <comment>php bin/console cms:admin:path meine-verwaltung</comment>');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if (1 !== preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $neu) || strlen($neu) > 40) {
|
||||
$io->error('Erlaubt sind Kleinbuchstaben, Ziffern und Bindestriche, höchstens 40 Zeichen.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
if (in_array($neu, self::TABU, true)) {
|
||||
$io->error(sprintf('„%s" ist für die Website selbst reserviert.', $neu));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
// Eine Seite mit dieser Adresse wäre von der Verwaltung verdeckt - unauffindbar, ohne dass
|
||||
// jemand merkt, warum.
|
||||
$seite = $this->db->fetchOne('SELECT path FROM page WHERE website_id = ? AND path = ? AND deleted_at IS NULL', ['default', $neu]);
|
||||
if (is_string($seite)) {
|
||||
$io->error(sprintf('Es gibt bereits eine Seite unter /%s. Die Verwaltung würde sie verdecken.', $neu));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$datei = $this->projectDir.'/.env.local';
|
||||
$inhalt = is_file($datei) ? (string) file_get_contents($datei) : '';
|
||||
$zeile = sprintf('ADMIN_PATH=%s', $neu);
|
||||
$inhalt = 1 === preg_match('/^ADMIN_PATH=.*$/m', $inhalt)
|
||||
? (string) preg_replace('/^ADMIN_PATH=.*$/m', $zeile, $inhalt)
|
||||
: rtrim($inhalt)."\n".$zeile."\n";
|
||||
if (false === @file_put_contents($datei, ltrim($inhalt, "\n"))) {
|
||||
$io->error('.env.local ist nicht beschreibbar. Trage dort von Hand ein: '.$zeile);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Ohne das Aufräumen gälte die alte Adresse weiter - und die neue wäre nicht erreichbar.
|
||||
// Die Umgebung, in der dieser Befehl gerade läuft, bleibt dabei unangetastet: Symfony lädt
|
||||
// Dienste nach, und wer sich den Boden unter den Füßen wegräumt, endet mit einem Fehler,
|
||||
// obwohl alles geklappt hat. Der nächste Aufruf baut sie ohnehin neu.
|
||||
$offen = [];
|
||||
foreach (glob($this->projectDir.'/var/cache/*') ?: [] as $pfad) {
|
||||
if (!is_dir($pfad)) {
|
||||
continue;
|
||||
}
|
||||
if (basename($pfad) === $this->environment) {
|
||||
$offen[] = basename($pfad);
|
||||
|
||||
continue;
|
||||
}
|
||||
$this->removeDirectory($pfad);
|
||||
}
|
||||
|
||||
$io->success(sprintf('Die Verwaltung liegt jetzt unter /%s.', $neu));
|
||||
if ([] !== $offen) {
|
||||
$io->writeln(sprintf('Falls die Website noch die alte Adresse kennt: <comment>php bin/console cache:clear --env=%s</comment>', implode(' --env=', $offen)));
|
||||
}
|
||||
if ($this->adminPath !== $neu) {
|
||||
$io->writeln('Merk dir die Adresse: <comment>/'.$this->adminPath.'</comment> antwortet ab sofort mit 404, wie jede andere unbekannte Adresse auch.');
|
||||
}
|
||||
$io->writeln('Zurück geht es mit: <comment>php bin/console cms:admin:path admin</comment>');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function removeDirectory(string $pfad): void
|
||||
{
|
||||
if (!is_dir($pfad)) {
|
||||
return;
|
||||
}
|
||||
foreach (scandir($pfad) ?: [] as $eintrag) {
|
||||
if ('.' === $eintrag || '..' === $eintrag) {
|
||||
continue;
|
||||
}
|
||||
$voll = $pfad.'/'.$eintrag;
|
||||
is_dir($voll) ? $this->removeDirectory($voll) : @unlink($voll);
|
||||
}
|
||||
@rmdir($pfad);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\Theme;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Holt die Schriften des Designs auf den eigenen Server.
|
||||
*
|
||||
* Dasselbe wie der Knopf im Designbereich, für Installationen mit Konsolenzugang - und für den
|
||||
* Fall, dass eine frisch aufgesetzte Website die Dateien noch nicht hat.
|
||||
*/
|
||||
#[AsCommand(name: 'cms:fonts:fetch', description: 'Lädt die Google-Schriften des Designs nach public/assets/fonts.')]
|
||||
final class FetchFontsCommand extends Command
|
||||
{
|
||||
public function __construct(private Theme $theme, private FontLibrary $fonts)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
try {
|
||||
$result = $this->fonts->fetch($this->theme->load());
|
||||
} catch (\RuntimeException $error) {
|
||||
$io->error($error->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$io->success(sprintf(
|
||||
'%d Datei(en) geladen, %d KB insgesamt: %s. Die Seiten laden jetzt nichts mehr von Google.',
|
||||
$result['files'],
|
||||
(int) round($result['bytes'] / 1024),
|
||||
implode(', ', $result['families']),
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Ops\Scheduler;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Der dritte Weg: ein echter Cronjob oder ein Dauerläufer.
|
||||
*
|
||||
* Für Installationen, die eine Konsole haben. Er tut dasselbe wie der Besuchs-Auslöser, nur ohne
|
||||
* dessen enge Grenzen – und er läuft auch nachts um drei, wenn die Website leer ist.
|
||||
*
|
||||
* * * * * * cd /pfad && php bin/console cms:tasks:run (jede Minute, Standardweg)
|
||||
* php bin/console cms:tasks:run --loop=60 --max-time=3600 (Dauerläufer, z. B. unter Supervisor)
|
||||
* php bin/console cms:tasks:run --task=backup (eine bestimmte, sofort)
|
||||
*/
|
||||
#[AsCommand(name: 'cms:tasks:run', description: 'Führt fällige Aufgaben aus. Für einen echten Cronjob oder als Dauerläufer.')]
|
||||
final class RunTasksCommand extends Command
|
||||
{
|
||||
public function __construct(private Scheduler $scheduler)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('task', null, InputOption::VALUE_REQUIRED, 'Nur diese Aufgabe, unabhängig vom Zeitplan.')
|
||||
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Höchstens so viele Aufgaben je Durchlauf.', '10')
|
||||
->addOption('budget', null, InputOption::VALUE_REQUIRED, 'Höchstens so viele Sekunden je Durchlauf.', '300')
|
||||
->addOption('loop', null, InputOption::VALUE_REQUIRED, 'Als Dauerläufer: Sekunden zwischen zwei Durchläufen.')
|
||||
->addOption('max-time', null, InputOption::VALUE_REQUIRED, 'Der Dauerläufer endet nach so vielen Sekunden.', '3600');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
if (null !== ($name = $input->getOption('task'))) {
|
||||
$result = $this->scheduler->runNow((string) $name, 'konsole');
|
||||
if (null === $result) {
|
||||
$io->error('Diese Aufgabe gibt es nicht oder sie läuft gerade schon. `cms:tasks` zeigt, welche es gibt.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$this->report($io, [$result]);
|
||||
|
||||
return 'ok' === $result['status'] ? Command::SUCCESS : Command::FAILURE;
|
||||
}
|
||||
$limit = max(1, (int) $input->getOption('limit'));
|
||||
$budget = max(1, (int) $input->getOption('budget'));
|
||||
$loop = null !== $input->getOption('loop') ? max(5, (int) $input->getOption('loop')) : null;
|
||||
$deadline = microtime(true) + max(0, (int) $input->getOption('max-time'));
|
||||
|
||||
$failed = false;
|
||||
while (true) {
|
||||
$results = $this->scheduler->runDue('konsole', $limit, $budget);
|
||||
$failed = $failed || [] !== array_filter($results, static fn (array $r): bool => 'ok' !== $r['status']);
|
||||
$this->report($io, $results);
|
||||
if (null === $loop) {
|
||||
break;
|
||||
}
|
||||
// Ein Dauerläufer, der nie endet, sammelt Speicher und hält eine Datenbankverbindung
|
||||
// über Tage. Er endet nach `--max-time`; der Prozessverwalter startet ihn neu.
|
||||
if (microtime(true) + $loop > $deadline) {
|
||||
$io->writeln(sprintf('<comment>Ende nach %d Sekunden – neu starten lassen.</comment>', (int) $input->getOption('max-time')));
|
||||
break;
|
||||
}
|
||||
sleep($loop);
|
||||
}
|
||||
|
||||
return $failed ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** @param list<array{name: string, label: string, status: string, message: string, ms: int}> $results */
|
||||
private function report(SymfonyStyle $io, array $results): void
|
||||
{
|
||||
if ([] === $results) {
|
||||
$io->writeln(sprintf('<info>%s</info> nichts fällig.', gmdate('H:i:s')));
|
||||
|
||||
return;
|
||||
}
|
||||
foreach ($results as $result) {
|
||||
$io->writeln(sprintf(
|
||||
'<info>%s</info> %s %s (%d ms): %s',
|
||||
gmdate('H:i:s'),
|
||||
'ok' === $result['status'] ? '<info>ok</info> ' : '<error>fehler</error>',
|
||||
$result['name'],
|
||||
$result['ms'],
|
||||
$result['message'],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Ops\Scheduler;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Was der Plan vorhat und was zuletzt passiert ist.
|
||||
*
|
||||
* Dieselbe Auskunft wie der Bildschirm „Aufgaben“, nur für den, der ohnehin auf der Konsole
|
||||
* steht – und für den Fall, dass die Verwaltung gerade nicht erreichbar ist.
|
||||
*/
|
||||
#[AsCommand(name: 'cms:tasks', description: 'Zeigt den Aufgabenplan: Abstand, Fälligkeit und das letzte Ergebnis.')]
|
||||
final class TasksCommand extends Command
|
||||
{
|
||||
public function __construct(private Scheduler $scheduler)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('log', null, InputOption::VALUE_NONE, 'Zusätzlich die letzten Protokollzeilen.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
// Erst der Abgleich (er legt fehlende Zeilen an und schreibt die Zustandsdatei neu), dann
|
||||
// die Auskunft daraus - sonst zeigt der erste Aufruf nach einem Update einen Zeitpunkt,
|
||||
// der eine Sekunde spaeter schon nicht mehr stimmt.
|
||||
$tasks = $this->scheduler->overview();
|
||||
$state = $this->scheduler->fastState();
|
||||
$io->writeln(sprintf(
|
||||
'Auslöser: Besuch <info>%s</info>, Verwaltung <info>%s</info>. Konsole und URL-Cron laufen immer.',
|
||||
$state['traffic'] ? 'an' : 'aus',
|
||||
$state['admin'] ? 'an' : 'aus',
|
||||
));
|
||||
$io->writeln(sprintf(
|
||||
'Jetzt ist %s UTC; das nächste Mal steht %s an.',
|
||||
gmdate('Y-m-d H:i:s'),
|
||||
null === $state['next'] ? 'unbekannt' : gmdate('Y-m-d H:i:s', $state['next']),
|
||||
));
|
||||
|
||||
$rows = [];
|
||||
foreach ($tasks as $task) {
|
||||
$rows[] = [
|
||||
$task['name'],
|
||||
$task['enabled'] ? ($task['due'] ? 'fällig' : 'an') : 'aus',
|
||||
$this->minutes($task['interval']),
|
||||
$task['next'],
|
||||
$task['running'] ? 'läuft' : ($task['status'] ?? '–'),
|
||||
mb_substr($task['message'], 0, 60),
|
||||
];
|
||||
}
|
||||
if ([] === $rows) {
|
||||
$io->warning('Keine Aufgaben. Fehlt die Migration? `php vendor/bin/doctrine-migrations migrate`');
|
||||
} else {
|
||||
$io->table(['Name', 'Zustand', 'Abstand', 'Nächster Lauf (UTC)', 'Zuletzt', 'Meldung'], $rows);
|
||||
}
|
||||
|
||||
if ($input->getOption('log')) {
|
||||
$io->section('Protokoll');
|
||||
$lines = $this->scheduler->protocol(30);
|
||||
$io->writeln([] === $lines ? 'Noch nichts protokolliert.' : $lines);
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function minutes(int $seconds): string
|
||||
{
|
||||
return $seconds >= 86400 && 0 === $seconds % 86400
|
||||
? sprintf('%d Tag(e)', intdiv($seconds, 86400))
|
||||
: sprintf('%d Min.', max(1, intdiv($seconds, 60)));
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,41 @@ final class DemoContent
|
||||
*
|
||||
* @return array<string, array{name: string, document: array}>
|
||||
*/
|
||||
/**
|
||||
* The start page of a fresh installation - not demo content.
|
||||
*
|
||||
* Short on purpose. Somebody who has just installed should find a page they can edit, not a
|
||||
* brochure about the CMS they have to delete first. Exactly one H1, as every page requires.
|
||||
*/
|
||||
public static function starterPage(string $siteName): array
|
||||
{
|
||||
return [
|
||||
'schemaVersion' => 16,
|
||||
'title' => $siteName,
|
||||
'description' => 'NOCH AUSZUFÜLLEN: ein Satz darüber, was es hier gibt. Er erscheint in Suchergebnissen.',
|
||||
'blocks' => [
|
||||
self::section('start-hero', [
|
||||
self::heading('start-heading', $siteName, 1),
|
||||
self::text('start-intro', 'Diese Seite ist der Anfang. Melde dich an, öffne sie im Editor und ersetze diesen Text durch deinen eigenen.'),
|
||||
], 'mint'),
|
||||
self::section('start-body', [
|
||||
self::heading('start-next-heading', 'Die nächsten Schritte', 2),
|
||||
self::text('start-next', "Impressum und Datenschutzerklärung sind bereits angelegt und müssen noch ausgefüllt werden – die Pflichtangaben sind darin markiert.\n\nUnter „Design“ legst du Farben, Schriften und die Breite fest, unter „Menüs“ die Navigation, unter „Einstellungen“ den Namen und die Angaben zum Unternehmen."),
|
||||
], 'paper', 'narrow'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** Header and footer of a fresh installation, with the site name instead of a wordmark. */
|
||||
public static function starterAreas(string $siteName): array
|
||||
{
|
||||
$areas = self::areas();
|
||||
$areas['header']['document']['blocks'][0]['children'][0]['children'][0]['children'][0]['props']['text'] = $siteName;
|
||||
$areas['footer']['document']['blocks'][0]['children'][0]['children'][0]['children'][0]['props']['text'] = '© '.date('Y').' '.$siteName;
|
||||
|
||||
return $areas;
|
||||
}
|
||||
|
||||
public static function areas(): array
|
||||
{
|
||||
$row = static fn (string $id, array $children): array => [
|
||||
|
||||
@@ -27,6 +27,10 @@ final class DocumentValidator
|
||||
public const V14_TYPES = ['embed'];
|
||||
/** The sizes an icon can have; each one has a class in site.css. */
|
||||
public const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240];
|
||||
/** Schema v18: die Navigation zwischen Beiträgen, vom Renderer gefüllt. */
|
||||
public const V18_TYPES = ['postnav'];
|
||||
/** Die Rechtszeile als Baustein: gestaltbar und dort, wo sie hingehoert. */
|
||||
public const V19_TYPES = ['legal'];
|
||||
/** Schema v15: a single icon, a progress bar and a slider that works without a script. */
|
||||
public const V15_TYPES = ['icon', 'progress', 'slider'];
|
||||
|
||||
@@ -38,7 +42,7 @@ final class DocumentValidator
|
||||
public function validate(array $document, bool $requireHeading = true): void
|
||||
{
|
||||
$schemaVersion = $document['schemaVersion'] ?? null;
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], true), 'Unsupported document schema.');
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19], true), 'Unsupported document schema.');
|
||||
$this->text($document['title'] ?? null, 160);
|
||||
$this->text($document['description'] ?? null, 320);
|
||||
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.');
|
||||
@@ -66,7 +70,7 @@ final class DocumentValidator
|
||||
// page: a deactivated plugin must never make stored content unreadable. Nothing from
|
||||
// props is rendered for such a block, so unvalidated values never reach the output.
|
||||
$plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type);
|
||||
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
|
||||
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
|
||||
// An accordion entry only ever renders inside an accordion; at the top level or anywhere
|
||||
// else it would produce a details element without its group.
|
||||
$this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.');
|
||||
@@ -116,13 +120,20 @@ final class DocumentValidator
|
||||
}
|
||||
if (in_array($type, ['heading', 'text', 'button'], true)) {
|
||||
// From v12 a text block may carry the markup RichText allows, which needs more room.
|
||||
$limit = 'text' === $type ? (RichText::supports($schemaVersion) ? RichText::MAX_LENGTH : 10000) : 300;
|
||||
// From v17 a heading may too - one highlighted word is a design need, not a luxury.
|
||||
$limit = 'text' === $type ? (RichText::supports($schemaVersion) ? RichText::MAX_LENGTH : 10000) : ('heading' === $type && RichText::headings($schemaVersion) ? 2000 : 300);
|
||||
$this->text($props['text'] ?? null, $limit);
|
||||
}
|
||||
if ('heading' === $type) {
|
||||
$this->require(is_int($props['level'] ?? null) && $props['level'] >= 1 && $props['level'] <= 6, 'Invalid heading level.');
|
||||
$headings += 1 === $props['level'] ? 1 : 0;
|
||||
}
|
||||
if ('postnav' === $type) {
|
||||
// Nur die Beschriftungen sind Inhalt. Welche Beiträge es sind, entscheidet der
|
||||
// Renderer beim Ausliefern - gespeicherter Inhalt kann keinen Beitrag behaupten.
|
||||
$this->text($props['previousLabel'] ?? null, 60);
|
||||
$this->text($props['nextLabel'] ?? null, 60);
|
||||
}
|
||||
if ('postlist' === $type) {
|
||||
// The list is filled by the renderer, never by stored content: the block only says
|
||||
// how many posts to show and which term to limit them to.
|
||||
@@ -132,6 +143,30 @@ final class DocumentValidator
|
||||
$this->require(is_string($props[$kind]) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props[$kind]) && strlen($props[$kind]) <= 80, 'Ungültiger Filter für die Beitragsliste.');
|
||||
}
|
||||
}
|
||||
// Was eine Karte zeigt. Ein Projektraster besteht aus Bild und Titel; Datum, Auszug
|
||||
// und Kategorie gehören in ein Blogarchiv und lassen eine Projektseite falsch
|
||||
// aussehen. Voreinstellung ist der alte Zustand, damit bestehende Seiten sich nicht
|
||||
// ändern, wenn niemand etwas einstellt.
|
||||
if (isset($props['layout'])) {
|
||||
// Drei Darstellungen für dieselben Daten: Raster aus Karten, Liste mit Bild
|
||||
// daneben, und eine reine Titelliste. Welche passt, hängt vom Ort ab - ein
|
||||
// Projektraster auf der Startseite, eine Liste im Archiv, eine Titelliste in
|
||||
// einer Randspalte.
|
||||
$this->require($schemaVersion >= 18, 'Die Darstellung der Beitragsliste braucht Schema v18.');
|
||||
$this->require(in_array($props['layout'], ['karten', 'liste', 'kompakt', 'wechsel'], true), 'Unbekannte Darstellung der Beitragsliste.');
|
||||
}
|
||||
if (isset($props['paginate'])) {
|
||||
// Eine Auswahl zeigt die ersten n und hört auf. Eine Übersicht blättert - sonst
|
||||
// verschweigt sie, dass es mehr gibt. Das ist die eigene Regel aus Arbeitspaket M.
|
||||
$this->require($schemaVersion >= 17, 'Das Blättern einer Beitragsliste braucht Schema v17.');
|
||||
$this->require(is_bool($props['paginate']), 'Das Blättern ist ja oder nein.');
|
||||
}
|
||||
foreach (['showMeta', 'showExcerpt', 'showTerms'] as $switch) {
|
||||
if (isset($props[$switch])) {
|
||||
$this->require($schemaVersion >= 17, 'Die Anzeigeoptionen der Beitragsliste brauchen Schema v17.');
|
||||
$this->require(is_bool($props[$switch]), sprintf('"%s" ist ja oder nein.', $switch));
|
||||
}
|
||||
}
|
||||
}
|
||||
if ('button' === $type) {
|
||||
$this->url($props['href'] ?? null);
|
||||
@@ -224,6 +259,18 @@ final class DocumentValidator
|
||||
$this->require(is_int($props[$key] ?? null) && $props[$key] >= 1 && $props[$key] <= 6, sprintf('"%s" liegt zwischen 1 und 6.', $key));
|
||||
}
|
||||
$this->require(is_bool($props['snap'] ?? null), 'Das Einrasten ist anzugeben.');
|
||||
if (isset($props['autoplay'])) {
|
||||
// Sekunden bis zur nächsten Folie. 0 heißt aus, und unter drei Sekunden wäre es
|
||||
// keine Hilfe mehr, sondern Hektik.
|
||||
$this->require($schemaVersion >= 17, 'Das automatische Weiterblättern braucht Schema v17.');
|
||||
$this->require(is_int($props['autoplay']) && (0 === $props['autoplay'] || ($props['autoplay'] >= 3 && $props['autoplay'] <= 30)), 'Das Weiterblättern liegt bei 0 (aus) oder 3 bis 30 Sekunden.');
|
||||
}
|
||||
if (isset($props['dots'])) {
|
||||
// Punkte und Pfeile zeichnet der Browser aus dem Scrollbereich selbst; das
|
||||
// geht erst ab v17, weil es vorher keine Eigenschaft dafür gab.
|
||||
$this->require($schemaVersion >= 17, 'Punkte am Slider brauchen Schema v17.');
|
||||
$this->require(is_bool($props['dots']), 'Die Punkte sind ja oder nein.');
|
||||
}
|
||||
}
|
||||
if ('embed' === $type) {
|
||||
// Everything about the embed is validated here, because the address ends up in an
|
||||
@@ -247,8 +294,24 @@ final class DocumentValidator
|
||||
// so a stored document can never redirect a submission or change what is asked.
|
||||
$this->require(is_string($props['form'] ?? null) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props['form']) && strlen($props['form']) <= 80, 'Invalid form address.');
|
||||
}
|
||||
if ('gallery' === $type && isset($props['lightbox'])) {
|
||||
$this->require($schemaVersion >= 19, 'Der Bildbetrachter braucht Schema v19.');
|
||||
$this->require(is_bool($props['lightbox']), 'Der Bildbetrachter ist an oder aus.');
|
||||
}
|
||||
if ('image' === $type) {
|
||||
$this->url($props['src'] ?? null);
|
||||
// Ein Bild darf verlinken. Klingt selbstverstaendlich, fehlte aber: ein Logo im
|
||||
// Kopfbereich ohne Verweis auf die Startseite ist der erste Klick, den jeder sucht.
|
||||
if ('' !== ($props['href'] ?? '')) {
|
||||
$this->require($schemaVersion >= 17, 'Ein verlinktes Bild braucht Schema v17.');
|
||||
$this->url($props['href']);
|
||||
}
|
||||
// Was ein Klick auf das Bild tut. Ohne Angabe: nichts - wie bisher.
|
||||
if (isset($props['click'])) {
|
||||
$this->require($schemaVersion >= 19, 'Die Klickwirkung eines Bildes braucht Schema v19.');
|
||||
$this->require(in_array($props['click'], ['keine', 'vergroessern', 'link'], true), 'Für den Klick sind „keine", „vergroessern" und „link" vorgesehen.');
|
||||
$this->require('link' !== $props['click'] || '' !== ($props['href'] ?? ''), 'Ein Bild, das auf eine Adresse zeigen soll, braucht eine.');
|
||||
}
|
||||
$this->require(is_bool($props['decorative'] ?? null), 'Image purpose is required.');
|
||||
$this->text($props['alt'] ?? null, 300, $props['decorative']);
|
||||
$this->require(!$props['decorative'] || '' === $props['alt'], 'Decorative images need empty alternative text.');
|
||||
@@ -267,7 +330,10 @@ final class DocumentValidator
|
||||
$this->require(is_string($props['mediaId']) && 1 === preg_match('/^[a-f0-9]{32}$/D', $props['mediaId']), 'Invalid media ID.');
|
||||
}
|
||||
if (isset($props['sources'])) {
|
||||
$this->require(is_array($props['sources']) && array_is_list($props['sources']) && count($props['sources']) <= 3, 'Invalid image variants.');
|
||||
// As many entries as the media library produces widths, not one more: the list ends
|
||||
// up in a srcset, and the number has to move with the pipeline instead of standing
|
||||
// here as a number nobody connects to it.
|
||||
$this->require(is_array($props['sources']) && array_is_list($props['sources']) && count($props['sources']) <= count(MediaLibrary::WIDTHS), 'Invalid image variants.');
|
||||
$widths = [];
|
||||
foreach ($props['sources'] as $source) {
|
||||
$this->require(is_array($source) && is_int($source['width'] ?? null) && $source['width'] > 0 && $source['width'] <= 8000 && !in_array($source['width'], $widths, true), 'Invalid image variant width.');
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
/**
|
||||
* Schriften, die die Website selbst ausliefert.
|
||||
*
|
||||
* Eine Schrift von fonts.googleapis.com kostet zweierlei. Technisch: der Browser muss erst das
|
||||
* Stylesheet holen, darin die Adresse der Schriftdatei finden und dann diese holen - zwei fremde
|
||||
* Verbindungen hintereinander, bevor die erste Zeile in der richtigen Schrift steht. Rechtlich:
|
||||
* dabei erfährt ein Dritter die IP-Adresse jedes Besuchers, und dafür gibt es in Deutschland
|
||||
* Abmahnungen (LG München I, 3 O 17493/20).
|
||||
*
|
||||
* Beides verschwindet, wenn die Dateien auf dem eigenen Server liegen. Die Lizenz erlaubt das:
|
||||
* die Schriften bei Google Fonts stehen unter der SIL Open Font License oder Apache 2.0.
|
||||
*
|
||||
* Heruntergeladen wird einmal auf Zuruf, nicht bei einem Seitenaufruf - ein Besucher soll nie auf
|
||||
* einen fremden Server warten, auch nicht mittelbar.
|
||||
*/
|
||||
final class FontLibrary
|
||||
{
|
||||
/**
|
||||
* Der Browser, als den wir uns ausgeben.
|
||||
*
|
||||
* Google liefert je nach Browser ein anderes Stylesheet: alten Browsern TTF, neuen WOFF2. Ohne
|
||||
* diese Kennung bekämen wir Dateien, die viermal so groß sind.
|
||||
*/
|
||||
private const AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
|
||||
|
||||
/** Zeichenbereiche, die eine deutschsprachige Website braucht. Kyrillisch und Vietnamesisch nicht. */
|
||||
private const SUBSETS = ['latin', 'latin-ext'];
|
||||
|
||||
public function __construct(private string $directory)
|
||||
{
|
||||
}
|
||||
|
||||
/** Liegen für dieses Theme fertige Dateien bereit? */
|
||||
public function hasLocal(array $theme): bool
|
||||
{
|
||||
return [] === $this->missing($theme) && is_file($this->directory.'/fonts.css');
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Schriften des Themes, für die noch keine lokale Datei existiert.
|
||||
*
|
||||
* @return list<string> Beschriftungen wie „Lato 700“
|
||||
*/
|
||||
public function missing(array $theme): array
|
||||
{
|
||||
$missing = [];
|
||||
foreach ($this->wanted($theme) as $family => $weights) {
|
||||
foreach ($weights as $weight) {
|
||||
if ([] === glob($this->directory.'/'.$this->slug($family).'-'.$weight.'-*.woff2')) {
|
||||
$missing[] = $family.' '.$weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt alle Schriften des Themes und schreibt fonts.css daneben.
|
||||
*
|
||||
* @return array{files: int, bytes: int, families: list<string>}
|
||||
*/
|
||||
public function fetch(array $theme): array
|
||||
{
|
||||
$wanted = $this->wanted($theme);
|
||||
if ([] === $wanted) {
|
||||
throw new \RuntimeException('Dieses Design nutzt keine Schrift von Google Fonts.');
|
||||
}
|
||||
if (!is_dir($this->directory) && !mkdir($this->directory, 0775, true) && !is_dir($this->directory)) {
|
||||
throw new \RuntimeException('Der Schriftenordner ist nicht beschreibbar: '.$this->directory);
|
||||
}
|
||||
$rules = [];
|
||||
$files = 0;
|
||||
$bytes = 0;
|
||||
foreach ($wanted as $family => $weights) {
|
||||
$css = $this->stylesheet($family, $weights);
|
||||
foreach ($this->parse($css, $family) as $face) {
|
||||
$name = $this->slug($family).'-'.$face['weight'].'-'.$face['subset'].'.woff2';
|
||||
$path = $this->directory.'/'.$name;
|
||||
if (!is_file($path)) {
|
||||
file_put_contents($path, $this->download($face['url']));
|
||||
++$files;
|
||||
}
|
||||
$bytes += (int) filesize($path);
|
||||
$rules[] = sprintf(
|
||||
"/* %s */\n@font-face{font-family:'%s';font-style:%s;font-weight:%d;font-display:swap;src:url(/assets/fonts/%s) format('woff2');unicode-range:%s}",
|
||||
$face['subset'],
|
||||
$family,
|
||||
$face['style'],
|
||||
$face['weight'],
|
||||
$name,
|
||||
$face['range'],
|
||||
);
|
||||
}
|
||||
}
|
||||
if ([] === $rules) {
|
||||
throw new \RuntimeException('Google Fonts hat keine passende Schriftdatei geliefert.');
|
||||
}
|
||||
file_put_contents($this->directory.'/fonts.css', implode("\n", $rules)."\n");
|
||||
|
||||
return ['files' => $files, 'bytes' => $bytes, 'families' => array_keys($wanted)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Dateien, die für den ersten Eindruck vorab geholt werden sollen.
|
||||
*
|
||||
* Nur der lateinische Grundbereich und nur die Schnitte, die im Fließtext und in
|
||||
* Überschriften stehen - ein Vorabholen, das alles holt, verzögert genau das, was es
|
||||
* beschleunigen soll.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function preload(array $theme): array
|
||||
{
|
||||
$preload = [];
|
||||
foreach ($this->wanted($theme) as $family => $weights) {
|
||||
foreach (array_slice($weights, 0, 2) as $weight) {
|
||||
$name = $this->slug($family).'-'.$weight.'-latin.woff2';
|
||||
if (is_file($this->directory.'/'.$name)) {
|
||||
$preload[] = '/assets/fonts/'.$name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $preload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Welche Familien und Schnitte das Theme von Google bezieht.
|
||||
*
|
||||
* @return array<string, list<int>>
|
||||
*/
|
||||
private function wanted(array $theme): array
|
||||
{
|
||||
$wanted = [];
|
||||
foreach ($theme['fonts'] ?? [] as $font) {
|
||||
if ('google' !== ($font['source'] ?? '') || '' === ($font['family'] ?? '')) {
|
||||
continue;
|
||||
}
|
||||
$weights = array_map(intval(...), $font['weights'] ?? [400]);
|
||||
sort($weights);
|
||||
$wanted[$font['family']] = array_values(array_unique([...($wanted[$font['family']] ?? []), ...$weights]));
|
||||
sort($wanted[$font['family']]);
|
||||
}
|
||||
|
||||
return $wanted;
|
||||
}
|
||||
|
||||
private function stylesheet(string $family, array $weights): string
|
||||
{
|
||||
$url = sprintf(
|
||||
'https://fonts.googleapis.com/css2?family=%s:wght@%s&display=swap',
|
||||
rawurlencode($family),
|
||||
implode(';', $weights),
|
||||
);
|
||||
|
||||
$css = $this->get($url);
|
||||
if (!str_contains($css, '@font-face')) {
|
||||
throw new \RuntimeException(sprintf('Google Fonts liefert für „%s“ keine Schrift. Schreibt sich die Familie genau so?', $family));
|
||||
}
|
||||
|
||||
return $css;
|
||||
}
|
||||
|
||||
private function download(string $url): string
|
||||
{
|
||||
if (!str_starts_with($url, 'https://fonts.gstatic.com/')) {
|
||||
throw new \RuntimeException('Die Schriftdatei käme von einer fremden Adresse: '.$url);
|
||||
}
|
||||
|
||||
$body = $this->get($url);
|
||||
// Eine WOFF2-Datei beginnt mit "wOF2". Was anderes ist, wird nicht gespeichert.
|
||||
if (!str_starts_with($body, 'wOF2')) {
|
||||
throw new \RuntimeException('Die geladene Datei ist keine WOFF2-Schrift.');
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die @font-face-Blöcke eines Google-Stylesheets.
|
||||
*
|
||||
* Der Zeichenbereich steht in einem Kommentar vor jedem Block („/* latin *\/“); danach wird
|
||||
* ausgewählt, was eine deutschsprachige Website braucht.
|
||||
*
|
||||
* @return list<array{subset: string, weight: int, style: string, url: string, range: string}>
|
||||
*/
|
||||
public function parse(string $css, string $family): array
|
||||
{
|
||||
$faces = [];
|
||||
preg_match_all('#/\*\s*([a-z0-9-]+)\s*\*/\s*@font-face\s*\{(.+?)\}#s', $css, $matches, PREG_SET_ORDER);
|
||||
foreach ($matches as [, $subset, $block]) {
|
||||
if (!in_array($subset, self::SUBSETS, true)) {
|
||||
continue;
|
||||
}
|
||||
if (1 !== preg_match('#src:\s*url\(([^)]+)\)#', $block, $src)) {
|
||||
continue;
|
||||
}
|
||||
preg_match('#font-weight:\s*(\d+)#', $block, $weight);
|
||||
preg_match('#font-style:\s*([a-z]+)#', $block, $style);
|
||||
preg_match('#unicode-range:\s*([^;}]+)#', $block, $range);
|
||||
$faces[] = [
|
||||
'subset' => $subset,
|
||||
'weight' => (int) ($weight[1] ?? 400),
|
||||
'style' => $style[1] ?? 'normal',
|
||||
'url' => trim($src[1], '\'" '),
|
||||
'range' => trim($range[1] ?? 'U+0000-00FF'),
|
||||
'family' => $family,
|
||||
];
|
||||
}
|
||||
|
||||
return $faces;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt eine HTTPS-Adresse - erst über curl, sonst über den Dateizeiger.
|
||||
*
|
||||
* Dieselben zwei Wege wie im Installer, aus demselben Grund: auf einem Webhosting ist mal das
|
||||
* eine da und mal das andere. Eine weitere Abhängigkeit dafür ins Projekt zu holen, würde
|
||||
* jedes Update auf einem Hosting ohne Composer scheitern lassen.
|
||||
*/
|
||||
private function get(string $url): string
|
||||
{
|
||||
if (!str_starts_with($url, 'https://')) {
|
||||
throw new \RuntimeException('Geladen wird nur über HTTPS.');
|
||||
}
|
||||
if (function_exists('curl_init')) {
|
||||
$curl = curl_init($url);
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_CONNECTTIMEOUT => 15,
|
||||
CURLOPT_TIMEOUT => 60,
|
||||
CURLOPT_USERAGENT => self::AGENT,
|
||||
]);
|
||||
$body = curl_exec($curl);
|
||||
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
$error = curl_error($curl);
|
||||
curl_close($curl);
|
||||
if (!is_string($body) || 200 !== $status) {
|
||||
throw new \RuntimeException(sprintf('Google Fonts antwortet mit %d%s.', $status, '' === $error ? '' : ' ('.$error.')'));
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
if (!filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL)) {
|
||||
throw new \RuntimeException('Dieser Server darf nicht nach außen verbinden (weder curl noch allow_url_fopen). Die Schriftdateien müssen dann von Hand nach public/assets/fonts/ gelegt werden.');
|
||||
}
|
||||
$context = stream_context_create([
|
||||
'http' => ['timeout' => 60, 'follow_location' => 1, 'max_redirects' => 3, 'header' => 'User-Agent: '.self::AGENT],
|
||||
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
|
||||
]);
|
||||
$body = @file_get_contents($url, false, $context);
|
||||
if (!is_string($body)) {
|
||||
throw new \RuntimeException('Google Fonts ist von diesem Server aus nicht erreichbar.');
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
private function slug(string $family): string
|
||||
{
|
||||
return strtolower(preg_replace('/[^a-z0-9]+/i', '-', $family) ?? 'schrift');
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,16 @@ final class MediaLibrary
|
||||
/** Enough to fill a screen, few enough that the browser does not decode a hundred images. */
|
||||
public const PER_PAGE = 24;
|
||||
|
||||
/** The widths every upload is rendered to, in WebP. A source narrower than a step is skipped. */
|
||||
public const WIDTHS = [480, 960, 1600, 2400];
|
||||
|
||||
/**
|
||||
* Die Bilder, die in dieser Anfrage schon gesucht wurden.
|
||||
*
|
||||
* @var array<string, ?array>
|
||||
*/
|
||||
private array $gemerkt = [];
|
||||
|
||||
public function __construct(private Connection $db, private string $directory)
|
||||
{
|
||||
}
|
||||
@@ -50,11 +60,49 @@ final class MediaLibrary
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Bild, gemerkt fuer die Dauer dieser Anfrage.
|
||||
*
|
||||
* Dasselbe Bild wird waehrend einer Seitenausgabe mehrfach gesucht: Ein Hintergrundbild
|
||||
* bekommt je Geraet eine Regel, also dreimal dieselbe Abfrage, und der Kopf fragt dasselbe
|
||||
* Bild noch einmal, um es vorab zu holen. Gemerkt wird nur innerhalb einer Anfrage - der
|
||||
* Dienst lebt nicht laenger, also kann der Merkzettel nicht veralten.
|
||||
*/
|
||||
public function find(string $id): ?array
|
||||
{
|
||||
if (array_key_exists($id, $this->gemerkt)) {
|
||||
return $this->gemerkt[$id];
|
||||
}
|
||||
$row = $this->db->fetchAssociative('SELECT * FROM media WHERE id = ?', [$id]);
|
||||
|
||||
return $row ? $this->present($row) : null;
|
||||
return $this->gemerkt[$id] = $row ? $this->present($row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mehrere Bilder auf einmal, nach Kennung sortiert.
|
||||
*
|
||||
* Eine Liste mit zwölf Beiträgen soll nicht zwölfmal nach je einem Bild fragen. Fehlende
|
||||
* Kennungen fehlen auch im Ergebnis - der Aufrufer prüft ohnehin auf null.
|
||||
*
|
||||
* @param list<string> $ids
|
||||
*
|
||||
* @return array<string, array>
|
||||
*/
|
||||
public function findMany(array $ids): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter($ids)));
|
||||
if ([] === $ids) {
|
||||
return [];
|
||||
}
|
||||
$found = [];
|
||||
foreach ($this->db->fetchAllAssociative(
|
||||
'SELECT * FROM media WHERE id IN ('.implode(',', array_fill(0, count($ids), '?')).')',
|
||||
$ids,
|
||||
) as $row) {
|
||||
$found[$row['id']] = $this->present($row);
|
||||
}
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
private function present(array $row): array
|
||||
@@ -109,7 +157,10 @@ final class MediaLibrary
|
||||
$variants = [];
|
||||
$width = imagesx($source);
|
||||
$height = imagesy($source);
|
||||
foreach (array_unique([min(480, $width), min(960, $width), min(1600, $width)]) as $targetWidth) {
|
||||
// Four widths, and never larger than the original: 480 for a phone, 960 for a tablet
|
||||
// or a column, 1600 for a full width section, 2400 for a full bleed image on a large
|
||||
// display. A smaller source simply produces fewer variants.
|
||||
foreach (array_unique(array_map(static fn (int $step): int => min($step, $width), self::WIDTHS)) as $targetWidth) {
|
||||
$targetHeight = max(1, (int) round($height * $targetWidth / $width));
|
||||
$target = imagecreatetruecolor($targetWidth, $targetHeight);
|
||||
try {
|
||||
@@ -130,6 +181,7 @@ final class MediaLibrary
|
||||
$title = mb_strcut(preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?: 'Bild', 0, 160, 'UTF-8');
|
||||
$row = ['id' => $id, 'title' => $title, 'alt' => '', 'variants' => json_encode($variants, JSON_THROW_ON_ERROR), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')];
|
||||
$this->db->insert('media', $row);
|
||||
$this->gemerkt = [];
|
||||
|
||||
return $this->present($row);
|
||||
} catch (\Throwable $error) {
|
||||
@@ -150,6 +202,7 @@ final class MediaLibrary
|
||||
throw new \InvalidArgumentException('Titel (max. 160 Bytes), Alternativtext (max. 300 Bytes) und Version prüfen.');
|
||||
}
|
||||
$count = $this->db->executeStatement('UPDATE media SET title = ?, alt = ?, archived = ?, version = version + 1 WHERE id = ? AND version = ?', [$data['title'], $data['alt'], (int) $data['archived'], $id, $data['version']]);
|
||||
unset($this->gemerkt[$id]);
|
||||
if (1 !== $count) {
|
||||
throw new StaleWrite();
|
||||
}
|
||||
@@ -157,6 +210,77 @@ final class MediaLibrary
|
||||
return [...$data, 'id' => $id, 'version' => $data['version'] + 1];
|
||||
}
|
||||
|
||||
/** Die Größen, die ein Favicon braucht: der Reiter im Browser und das Symbol auf dem Startbildschirm. */
|
||||
public const FAVICON_SIZES = [32, 180];
|
||||
|
||||
/**
|
||||
* Erzeugt aus einem Bild der Mediathek die Symboldateien der Website.
|
||||
*
|
||||
* Ein Favicon ist quadratisch, winzig und wird von den Browsern bis heute am zuverlässigsten
|
||||
* als PNG gelesen - deshalb hier PNG statt der WebP-Varianten und ein mittiger quadratischer
|
||||
* Ausschnitt statt eines gestauchten Bildes. Erzeugt wird einmal beim Speichern der
|
||||
* Einstellung, nicht bei jedem Aufruf.
|
||||
*/
|
||||
public function renderFavicon(string $mediaId): void
|
||||
{
|
||||
// Ueber die Varianten, die dieses Bild tatsaechlich hat, nicht ueber die vier Regelbreiten:
|
||||
// ein 192 px breites Zeichen hat genau eine Variante mit 192 - und die Suche nach 480
|
||||
// ging ins Leere. Genau so ein quadratisches Zeichen ist der haeufigste Fall.
|
||||
$item = $this->find($mediaId);
|
||||
$source = null;
|
||||
foreach (array_reverse($item['variants'] ?? []) as $variant) {
|
||||
$path = $this->file($mediaId, $variant['width']);
|
||||
if (null !== $path) {
|
||||
$source = @imagecreatefromwebp($path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$source) {
|
||||
throw new \RuntimeException('Das Bild für das Symbol konnte nicht gelesen werden.');
|
||||
}
|
||||
try {
|
||||
// Mittiger quadratischer Ausschnitt: ein breites Logo würde sonst zur Briefmarke.
|
||||
$kante = min(imagesx($source), imagesy($source));
|
||||
$links = (int) ((imagesx($source) - $kante) / 2);
|
||||
$oben = (int) ((imagesy($source) - $kante) / 2);
|
||||
foreach (self::FAVICON_SIZES as $size) {
|
||||
$target = imagecreatetruecolor($size, $size);
|
||||
try {
|
||||
imagealphablending($target, false);
|
||||
imagesavealpha($target, true);
|
||||
imagecopyresampled($target, $source, 0, 0, $links, $oben, $size, $size, $kante, $kante);
|
||||
if (!imagepng($target, $this->faviconPath($size), 9)) {
|
||||
throw new \RuntimeException('Das Symbol konnte nicht gespeichert werden.');
|
||||
}
|
||||
} finally {
|
||||
imagedestroy($target);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
imagedestroy($source);
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Pfad einer Symboldatei, oder null wenn sie (noch) nicht erzeugt wurde. */
|
||||
public function faviconFile(int $size): ?string
|
||||
{
|
||||
if (!in_array($size, self::FAVICON_SIZES, true)) {
|
||||
return null;
|
||||
}
|
||||
$path = $this->faviconPath($size);
|
||||
|
||||
return is_file($path) ? $path : null;
|
||||
}
|
||||
|
||||
private function faviconPath(int $size): string
|
||||
{
|
||||
if (!is_dir($this->directory) && !mkdir($this->directory, 0775, true) && !is_dir($this->directory)) {
|
||||
throw new \RuntimeException('Medienordner ist nicht beschreibbar.');
|
||||
}
|
||||
|
||||
return $this->directory.'/favicon-'.$size.'.png';
|
||||
}
|
||||
|
||||
public function file(string $id, int $width): ?string
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{32}$/D', $id)) {
|
||||
|
||||
@@ -85,14 +85,26 @@ final class MenuRepository
|
||||
*
|
||||
* @return list<array{label: string, href: string, current: bool, children: list<array>}>
|
||||
*/
|
||||
/**
|
||||
* Die Seitenliste, die jedes Menue zum Aufloesen braucht - einmal je Anfrage.
|
||||
*
|
||||
* Vorher holte jedes Menue sie selbst. Mit zwei Menuen fiel das nicht auf, mit vieren - Kopf,
|
||||
* zwei Listen im Fuss und die Rechtszeile - waren es vier gleiche Abfragen je Seitenaufruf.
|
||||
*
|
||||
* @var array<string, array<string, mixed>>|null
|
||||
*/
|
||||
private ?array $pages = null;
|
||||
|
||||
public function resolve(array $items, string $currentPath, string $locale = 'de'): array
|
||||
{
|
||||
$pages = [];
|
||||
foreach ($this->db->fetchAllAssociative('SELECT id, path, published_revision_id, draft_revision_id FROM page WHERE website_id = ? AND locale = ? AND deleted_at IS NULL', ['default', $locale]) as $page) {
|
||||
$pages[$page['id']] = $page;
|
||||
if (null === $this->pages) {
|
||||
$this->pages = [];
|
||||
foreach ($this->db->fetchAllAssociative('SELECT id, path, published_revision_id, draft_revision_id FROM page WHERE website_id = ? AND locale = ? AND deleted_at IS NULL', ['default', $locale]) as $page) {
|
||||
$this->pages[$page['id']] = $page;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->resolveLevel($items, $pages, $currentPath, $this->settings->homePath());
|
||||
return $this->resolveLevel($items, $this->pages, $currentPath, $this->settings->homePath());
|
||||
}
|
||||
|
||||
private function resolveLevel(array $items, array $pages, string $currentPath, string $homePath): array
|
||||
|
||||
@@ -368,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, 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',
|
||||
'SELECT p.id, p.slug, p.path, p.type, p.locale, p.author, p.excerpt, p.published_at, p.robots, p.seo_title, p.og_media_id, p.featured_media_id, p.header_area, p.footer_area, r.document, r.id AS revision_id, w.name AS website_name FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id JOIN website w ON w.id = p.website_id WHERE p.path = ? AND p.locale = ? AND p.website_id = ? AND p.deleted_at IS NULL',
|
||||
[$path, $locale, 'default'],
|
||||
);
|
||||
if (false === $page) {
|
||||
@@ -417,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, 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']);
|
||||
return $this->db->fetchAllAssociative('SELECT p.id, p.slug, p.path, p.parent_id, p.type, p.locale, p.version, p.published_revision_id, p.draft_revision_id, p.excerpt, p.author, p.published_at, p.featured_media_id, p.robots, p.seo_title, p.og_media_id, p.publish_at, p.header_area, p.footer_area, r.document FROM page p LEFT JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.deleted_at IS NULL ORDER BY p.path', ['default']);
|
||||
}
|
||||
|
||||
public function findForEditor(string $id): ?array
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
/**
|
||||
* Fertige Aufbauten für einen neuen Beitrag.
|
||||
*
|
||||
* Ein leeres Dokument ist für eine Seite richtig - dort weiß niemand vorher, was daraus wird. Ein
|
||||
* Beitrag dagegen hat fast immer dieselbe Form: ein Kopf, ein Text, Bilder, und am Ende ein Weg
|
||||
* weiter. Wer jedes Mal von vorn anfängt, baut jedes Mal etwas leicht anderes, und nach zwanzig
|
||||
* Beiträgen sieht ein Archiv aus wie zwanzig Websites.
|
||||
*
|
||||
* Die drei Aufbauten hier sind Startpunkte, keine Schablonen: jeder Block lässt sich danach
|
||||
* verschieben, ändern und löschen wie jeder andere Block auch. Was sie festlegen, ist die erste
|
||||
* Entscheidung - und die ist die, die am schwersten nachzuholen ist.
|
||||
*/
|
||||
final class PostLayouts
|
||||
{
|
||||
public const DEFAULT = 'bildkopf';
|
||||
|
||||
/** @return array<string, array{name: string, description: string}> */
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
'bildkopf' => [
|
||||
'name' => 'Bildkopf',
|
||||
'description' => 'Großes Beitragsbild über die volle Breite, Titel darauf. Für Projekte und alles, was von einem Bild lebt.',
|
||||
],
|
||||
'klassisch' => [
|
||||
'name' => 'Klassisch',
|
||||
'description' => 'Titel und Vorspann in einer schmalen Spalte, Bild darunter. Ruhig zu lesen, gut für lange Texte.',
|
||||
],
|
||||
'magazin' => [
|
||||
'name' => 'Magazin',
|
||||
'description' => 'Titel neben den Eckdaten, danach Text und Galerie. Für Beiträge, bei denen Kunde, Leistung und Jahr zählen.',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public static function exists(string $key): bool
|
||||
{
|
||||
return isset(self::all()[$key]);
|
||||
}
|
||||
|
||||
/** Das vollständige Dokument eines neuen Beitrags in der gewählten Form. */
|
||||
public static function document(string $key, string $title): array
|
||||
{
|
||||
$document = [
|
||||
'schemaVersion' => 18,
|
||||
'title' => $title,
|
||||
'description' => 'Ein neuer Beitrag. Ersetze diese Beschreibung durch eine kurze Zusammenfassung – sie erscheint in Suchergebnissen und auf der Beitragskarte.',
|
||||
'blocks' => match ($key) {
|
||||
'klassisch' => self::klassisch($title),
|
||||
'magazin' => self::magazin($title),
|
||||
default => self::bildkopf($title),
|
||||
},
|
||||
];
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bildkopf: das Beitragsbild füllt den Kopf, der Titel liegt darauf.
|
||||
*
|
||||
* Das Bild ist beim Anlegen noch keines - es kommt ein Platzhalter, und im Designbereich wird
|
||||
* daraus mit zwei Klicks das Beitragsbild. Ein dunkler Kopf ohne Bild sieht dabei nicht kaputt
|
||||
* aus, sondern schlicht, und das ist der Punkt: kein Aufbau darf auf ein Bild angewiesen sein,
|
||||
* das noch niemand hochgeladen hat.
|
||||
*/
|
||||
private static function bildkopf(string $title): array
|
||||
{
|
||||
return [
|
||||
self::section('kopf', [
|
||||
self::badge('kopf-label', 'Beitrag'),
|
||||
self::heading('kopf-titel', $title, 1, ['fontSize' => '46px', 'fontWeight' => '800', 'lineHeight' => 1.12, 'letterSpacing' => '-1px', 'maxWidth' => '900px', 'margin' => self::spacing('0px', '18px', 'auto', 'auto')]),
|
||||
self::line('kopf-linie'),
|
||||
], [
|
||||
'backgroundColor' => '@ink', 'color' => '#ffffff', 'textAlign' => 'center',
|
||||
'display' => 'flex', 'alignItems' => 'flex-end', 'justifyContent' => 'center',
|
||||
'minHeight' => '58vh', 'padding' => self::spacing('110px', '52px', '0px', '0px'),
|
||||
], [], ['entrance' => 'fade']),
|
||||
self::narrow('text', [
|
||||
self::heading('text-h', 'Über den Beitrag', 2, ['fontSize' => '28px', 'fontWeight' => '800', 'color' => '@ink']),
|
||||
self::text('text-1', 'Schreibe hier den ersten Absatz. Er beantwortet am besten die Frage, warum jemand weiterlesen sollte.'),
|
||||
self::text('text-2', 'Der zweite Absatz darf ins Detail gehen: was die Aufgabe war, wie der Weg aussah, was dabei herauskam.'),
|
||||
]),
|
||||
self::section('galerie', [
|
||||
self::badge('galerie-label', 'Bildergalerie', true),
|
||||
self::heading('galerie-h', 'Eindrücke', 2, ['fontSize' => '30px', 'fontWeight' => '800', 'color' => '@ink']),
|
||||
self::gallery('galerie-bilder', 2),
|
||||
], ['backgroundColor' => '@paper', 'padding' => self::spacing('60px', '60px', '0px', '0px')], [], ['entrance' => 'rise']),
|
||||
self::section('weiter', [self::nav('weiter-nav')], ['padding' => self::spacing('50px', '50px', '0px', '0px')], [], ['entrance' => 'fade']),
|
||||
self::section('aufruf', [self::cta('aufruf-band')], ['backgroundColor' => '@ink', 'color' => '#ffffff', 'padding' => self::spacing('62px', '62px', '0px', '0px')], [], ['entrance' => 'fade']),
|
||||
];
|
||||
}
|
||||
|
||||
/** Klassisch: Titel und Vorspann schmal, Bild darunter, dann der Text. */
|
||||
private static function klassisch(string $title): array
|
||||
{
|
||||
return [
|
||||
self::narrow('kopf', [
|
||||
self::badge('kopf-label', 'Beitrag', true),
|
||||
self::heading('kopf-titel', $title, 1, ['fontSize' => '42px', 'fontWeight' => '800', 'lineHeight' => 1.15, 'letterSpacing' => '-0.8px']),
|
||||
self::text('kopf-vorspann', 'Ein Vorspann in zwei, drei Sätzen. Er steht für sich und sagt, worum es geht – wer nur ihn liest, weiß Bescheid.', ['fontSize' => '20px', 'lineHeight' => 1.6]),
|
||||
], '70px', '30px'),
|
||||
self::section('bild', [self::image('bild-1')], ['padding' => self::spacing('0px', '10px', '0px', '0px')], [], ['entrance' => 'zoom']),
|
||||
self::narrow('text', [
|
||||
self::text('text-1', 'Der erste Absatz des Haupttextes. Ab hier darf es ausführlich werden.'),
|
||||
self::heading('text-h', 'Ein Zwischentitel', 2, ['fontSize' => '26px', 'fontWeight' => '800', 'color' => '@ink', 'margin' => self::spacing('34px', '12px', '0px', '0px')]),
|
||||
self::text('text-2', 'Zwischentitel machen einen langen Text überhaupt erst lesbar. Setze alle drei bis fünf Absätze einen.'),
|
||||
]),
|
||||
self::section('weiter', [self::nav('weiter-nav')], ['backgroundColor' => '@paper', 'padding' => self::spacing('50px', '50px', '0px', '0px')], [], ['entrance' => 'fade']),
|
||||
];
|
||||
}
|
||||
|
||||
/** Magazin: Titel links, Eckdaten rechts, danach Text und Galerie. */
|
||||
private static function magazin(string $title): array
|
||||
{
|
||||
return [
|
||||
self::section('kopf', [
|
||||
['id' => 'kopf-zeile', 'type' => 'row', 'style' => ['desktop' => ['gridColumns' => 2, 'gap' => '40px', 'alignItems' => 'flex-end'], 'mobile' => ['gridColumns' => 1, 'gap' => '24px']], 'children' => [
|
||||
['id' => 'kopf-links', 'type' => 'column', 'children' => [
|
||||
self::badge('kopf-label', 'Beitrag', true),
|
||||
self::heading('kopf-titel', $title, 1, ['fontSize' => '44px', 'fontWeight' => '800', 'lineHeight' => 1.12, 'letterSpacing' => '-1px']),
|
||||
]],
|
||||
['id' => 'kopf-rechts', 'type' => 'column', 'style' => ['desktop' => [
|
||||
'backgroundColor' => '#ffffff', 'borderRadius' => self::corners('16px'),
|
||||
'borderWidth' => self::spacing('1px', '1px', '1px', '1px'), 'borderStyle' => 'solid', 'borderColor' => '@line',
|
||||
'padding' => self::spacing('22px', '22px', '24px', '24px'),
|
||||
]], 'children' => [
|
||||
self::badge('eck-label', 'Eckdaten', true),
|
||||
self::text('eck-daten', "Kunde: noch einzutragen\nLeistung: noch einzutragen\nJahr: noch einzutragen", ['fontSize' => '15px']),
|
||||
]],
|
||||
]],
|
||||
], ['backgroundColor' => '@paper', 'padding' => self::spacing('72px', '48px', '0px', '0px')], [], ['entrance' => 'fade']),
|
||||
self::narrow('text', [
|
||||
self::text('text-1', 'Der Einstieg in den Beitrag. Was war die Aufgabe, und warum war sie nicht trivial?'),
|
||||
self::heading('text-h', 'Der Weg', 2, ['fontSize' => '26px', 'fontWeight' => '800', 'color' => '@ink', 'margin' => self::spacing('30px', '12px', '0px', '0px')]),
|
||||
self::text('text-2', 'Was getan wurde, in der Reihenfolge, in der es getan wurde.'),
|
||||
self::heading('text-h2', 'Das Ergebnis', 2, ['fontSize' => '26px', 'fontWeight' => '800', 'color' => '@ink', 'margin' => self::spacing('30px', '12px', '0px', '0px')]),
|
||||
self::text('text-3', 'Was sich seitdem geändert hat – am besten ohne Superlative.'),
|
||||
]),
|
||||
self::section('galerie', [self::gallery('galerie-bilder', 3)], ['backgroundColor' => '@paper', 'padding' => self::spacing('56px', '56px', '0px', '0px')], [], ['entrance' => 'rise']),
|
||||
self::section('weiter', [self::nav('weiter-nav')], ['padding' => self::spacing('50px', '50px', '0px', '0px')], [], ['entrance' => 'fade']),
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Bausteine
|
||||
|
||||
/**
|
||||
* Ein Abschnitt, wahlweise mit Einblendung.
|
||||
*
|
||||
* Die Einblendung läuft beim Hineinscrollen, wo der Browser eine Scroll-Zeitachse kennt, und
|
||||
* sonst beim Laden - beides ohne Skript, beides hinter der Bewegungsschranke. Deshalb darf
|
||||
* sie auch weiter unten stehen, ohne ins Leere zu laufen.
|
||||
*/
|
||||
private static function section(string $id, array $children, array $desktop = [], array $mobile = [], array $effects = []): array
|
||||
{
|
||||
$block = ['id' => $id, 'type' => 'section', 'children' => $children];
|
||||
if ([] !== $effects) {
|
||||
$block['layout'] = ['effects' => $effects];
|
||||
}
|
||||
if ([] !== $desktop || [] !== $mobile) {
|
||||
$block['style'] = array_filter(['desktop' => $desktop, 'mobile' => $mobile]);
|
||||
}
|
||||
|
||||
return $block;
|
||||
}
|
||||
|
||||
/** Ein schmaler Abschnitt steht in der Mitte: maximale Breite ohne automatischen Rand ist eine halbe Einstellung. */
|
||||
private static function narrow(string $id, array $children, string $top = '54px', string $bottom = '54px'): array
|
||||
{
|
||||
return self::section($id, $children, [
|
||||
'maxWidth' => '820px',
|
||||
'margin' => self::spacing('0px', '0px', 'auto', 'auto'),
|
||||
'padding' => self::spacing($top, $bottom, '0px', '0px'),
|
||||
], [], ['entrance' => 'rise']);
|
||||
}
|
||||
|
||||
private static function heading(string $id, string $text, int $level, array $style = []): array
|
||||
{
|
||||
return array_filter([
|
||||
'id' => $id, 'type' => 'heading', 'props' => ['text' => $text, 'level' => $level],
|
||||
'style' => [] === $style ? null : ['desktop' => $style],
|
||||
], static fn ($value) => null !== $value);
|
||||
}
|
||||
|
||||
private static function text(string $id, string $text, array $style = []): array
|
||||
{
|
||||
return array_filter([
|
||||
'id' => $id, 'type' => 'text', 'props' => ['text' => $text],
|
||||
'style' => [] === $style ? null : ['desktop' => $style],
|
||||
], static fn ($value) => null !== $value);
|
||||
}
|
||||
|
||||
/** Die kleine Beschriftung über einer Überschrift - hell auf dunklem Kopf, sonst in der Akzentfarbe. */
|
||||
private static function badge(string $id, string $label, bool $quiet = false): array
|
||||
{
|
||||
return $quiet
|
||||
? self::text($id, $label, ['fontSize' => '12px', 'fontWeight' => '700', 'letterSpacing' => '1.6px', 'textTransform' => 'uppercase', 'color' => '@link', 'margin' => self::spacing('0px', '10px', '0px', '0px')])
|
||||
: self::text($id, $label, [
|
||||
'display' => 'inline-block', 'backgroundColor' => '@link', 'color' => '#ffffff',
|
||||
'borderRadius' => self::corners('999px'), 'padding' => self::spacing('7px', '7px', '16px', '16px'),
|
||||
'fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '1px', 'textTransform' => 'uppercase',
|
||||
'margin' => self::spacing('0px', '18px', 'auto', 'auto'),
|
||||
]);
|
||||
}
|
||||
|
||||
private static function line(string $id): array
|
||||
{
|
||||
return ['id' => $id, 'type' => 'divider', 'style' => ['desktop' => [
|
||||
'width' => '60px', 'height' => '4px', 'backgroundColor' => '@link',
|
||||
'borderRadius' => self::corners('2px'), 'borderWidth' => self::spacing('0px', '0px', '0px', '0px'),
|
||||
'margin' => self::spacing('0px', '0px', 'auto', 'auto'),
|
||||
]]];
|
||||
}
|
||||
|
||||
private static function image(string $id): array
|
||||
{
|
||||
return ['id' => $id, 'type' => 'image', 'props' => [
|
||||
'src' => '/assets/image-placeholder.svg', 'alt' => '', 'decorative' => true,
|
||||
'width' => 1200, 'height' => 675, 'caption' => '',
|
||||
], 'style' => ['desktop' => ['imageRadius' => self::corners('18px')]]];
|
||||
}
|
||||
|
||||
private static function gallery(string $id, int $count): array
|
||||
{
|
||||
$children = [];
|
||||
for ($index = 1; $index <= $count; ++$index) {
|
||||
$children[] = ['id' => $id.'-'.$index, 'type' => 'image', 'props' => [
|
||||
'src' => '/assets/image-placeholder.svg', 'alt' => '', 'decorative' => true,
|
||||
'width' => 1200, 'height' => 900,
|
||||
]];
|
||||
}
|
||||
|
||||
return ['id' => $id, 'type' => 'gallery', 'children' => $children, 'style' => ['desktop' => [
|
||||
'gridColumns' => $count, 'gap' => '18px', 'margin' => self::spacing('24px', '0px', '0px', '0px'),
|
||||
], 'mobile' => ['gridColumns' => 1]]];
|
||||
}
|
||||
|
||||
private static function nav(string $id): array
|
||||
{
|
||||
return ['id' => $id, 'type' => 'postnav', 'props' => ['previousLabel' => 'Vorheriger Beitrag', 'nextLabel' => 'Nächster Beitrag']];
|
||||
}
|
||||
|
||||
private static function cta(string $id): array
|
||||
{
|
||||
return ['id' => $id, 'type' => 'cta', 'props' => [
|
||||
'title' => 'Noch Fragen?',
|
||||
'text' => 'Schreib uns – wir antworten in der Regel am selben Werktag.',
|
||||
'buttonText' => 'Kontakt aufnehmen',
|
||||
'href' => '/#kontakt',
|
||||
]];
|
||||
}
|
||||
|
||||
private static function spacing(string $top, string $bottom, string $left, string $right): array
|
||||
{
|
||||
return ['top' => $top, 'bottom' => $bottom, 'left' => $left, 'right' => $right];
|
||||
}
|
||||
|
||||
private static function corners(string $value): array
|
||||
{
|
||||
return ['topLeft' => $value, 'topRight' => $value, 'bottomRight' => $value, 'bottomLeft' => $value];
|
||||
}
|
||||
}
|
||||
+107
-11
@@ -46,20 +46,27 @@ final class PostRepository
|
||||
throw new InvalidPage(sprintf('Das gewählte %s gibt es nicht.', $label));
|
||||
}
|
||||
}
|
||||
// Der Titel fuer die Suche. Leer heisst: der Titel der Seite gilt. 160 Zeichen sind die
|
||||
// Grenze des Feldes; Google zeigt etwa 60 - der Hinweis dazu steht im Formular.
|
||||
$seoTitle = trim((string) ($values['seoTitle'] ?? ''));
|
||||
if (mb_strlen($seoTitle) > 160) {
|
||||
throw new InvalidPage('Der Titel für Suchergebnisse darf höchstens 160 Zeichen lang sein.');
|
||||
}
|
||||
$robots = (string) ($values['robots'] ?? 'index');
|
||||
if (!in_array($robots, ['index', 'noindex'], true)) {
|
||||
throw new InvalidPage('Für die Indexierung sind nur „index“ und „noindex“ vorgesehen.');
|
||||
}
|
||||
|
||||
$this->db->transactional(function () use ($pageId, $excerpt, $author, $date, $mediaId, $ogMediaId, $robots, $categories, $tags, $expectedVersion): void {
|
||||
$this->db->transactional(function () use ($pageId, $excerpt, $author, $date, $mediaId, $ogMediaId, $robots, $seoTitle, $categories, $tags, $expectedVersion): void {
|
||||
$changed = $this->db->executeStatement(
|
||||
'UPDATE page SET excerpt = ?, author = ?, featured_media_id = ?, published_at = ?, robots = ?, og_media_id = ?, version = version + 1 WHERE id = ? AND version = ?',
|
||||
'UPDATE page SET excerpt = ?, author = ?, featured_media_id = ?, published_at = ?, robots = ?, seo_title = ?, og_media_id = ?, version = version + 1 WHERE id = ? AND version = ?',
|
||||
[
|
||||
'' === $excerpt ? null : $excerpt,
|
||||
'' === $author ? null : $author,
|
||||
'' === $mediaId ? null : $mediaId,
|
||||
'' === $date ? null : str_replace('T', ' ', 10 === strlen($date) ? $date.' 00:00' : $date).':00',
|
||||
$robots,
|
||||
'' === $seoTitle ? null : $seoTitle,
|
||||
'' === $ogMediaId ? null : $ogMediaId,
|
||||
$pageId,
|
||||
$expectedVersion,
|
||||
@@ -127,17 +134,24 @@ final class PostRepository
|
||||
$params,
|
||||
);
|
||||
|
||||
return ['posts' => array_map($this->present(...), $rows), 'total' => $total, 'pages' => $pages, 'page' => $page];
|
||||
// Bilder und Begriffe für die ganze Liste in je einer Abfrage: vorher fragte jeder Beitrag
|
||||
// einzeln nach seinem Bild und seinen Begriffen, eine Liste mit zwölf Einträgen kostete
|
||||
// also 24 Abfragen statt zwei. Bei neighbours(), das 500 Beiträge lädt, war das absurd.
|
||||
$images = $this->media->findMany(array_map(static fn (array $row) => (string) $row['featured_media_id'], $rows));
|
||||
$terms = $this->termsForMany(array_column($rows, 'id'));
|
||||
|
||||
return [
|
||||
'posts' => array_map(fn (array $row) => $this->present($row, $images, $terms[$row['id']] ?? ['category' => [], 'tag' => []]), $rows),
|
||||
'total' => $total,
|
||||
'pages' => $pages,
|
||||
'page' => $page,
|
||||
];
|
||||
}
|
||||
|
||||
private function present(array $row): array
|
||||
private function present(array $row, array $images, array $terms): array
|
||||
{
|
||||
$document = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$image = null;
|
||||
if (null !== $row['featured_media_id']) {
|
||||
$item = $this->media->find($row['featured_media_id']);
|
||||
$image = null === $item ? null : ['alt' => $item['alt'] ?? '', 'variants' => $item['variants']];
|
||||
}
|
||||
$item = $images[$row['featured_media_id']] ?? null;
|
||||
|
||||
return [
|
||||
'title' => $document['title'] ?? $row['path'],
|
||||
@@ -145,14 +159,96 @@ final class PostRepository
|
||||
'excerpt' => $row['excerpt'] ?? $document['description'] ?? '',
|
||||
'author' => $row['author'],
|
||||
'publishedAt' => $row['published_at'],
|
||||
'image' => $image,
|
||||
'terms' => $this->termsFor($row['id']),
|
||||
'image' => null === $item ? null : ['alt' => $item['alt'] ?? '', 'variants' => $item['variants']],
|
||||
'terms' => $terms,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Begriffe mehrerer Seiten auf einmal.
|
||||
*
|
||||
* @param list<string> $pageIds
|
||||
*
|
||||
* @return array<string, array<string, list<array{slug: string, name: string, href: string}>>>
|
||||
*/
|
||||
private function termsForMany(array $pageIds): array
|
||||
{
|
||||
$pageIds = array_values(array_unique(array_filter($pageIds)));
|
||||
if ([] === $pageIds) {
|
||||
return [];
|
||||
}
|
||||
$terms = [];
|
||||
foreach ($this->db->fetchAllAssociative(
|
||||
'SELECT pt.page_id, t.kind, t.slug, t.name FROM term t JOIN page_term pt ON pt.term_id = t.id WHERE pt.page_id IN ('
|
||||
.implode(',', array_fill(0, count($pageIds), '?')).') ORDER BY t.name',
|
||||
$pageIds,
|
||||
) as $row) {
|
||||
$terms[$row['page_id']] ??= ['category' => [], 'tag' => []];
|
||||
$terms[$row['page_id']][$row['kind']][] = ['slug' => $row['slug'], 'name' => $row['name'], 'href' => '/'.self::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug']];
|
||||
}
|
||||
|
||||
return $terms;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<array{slug: string, name: string, href: string}>>
|
||||
*/
|
||||
/**
|
||||
* Der vorherige und der nächste Beitrag, in der Reihenfolge des Archivs.
|
||||
*
|
||||
* Gesucht wird innerhalb derselben Kategorie, wenn der Beitrag eine hat: von einem Projekt
|
||||
* führt man zum nächsten Projekt und nicht zur nächsten Pressemitteilung. Hat er keine, gilt
|
||||
* die ganze Liste. Am Rand wird nicht umgebrochen - der erste Beitrag hat keinen Vorgänger,
|
||||
* und ein Ring aus zwei Beiträgen wäre eine Schleife, keine Navigation.
|
||||
*
|
||||
* @return array{previous: ?array, next: ?array}
|
||||
*/
|
||||
public function neighbours(string $pageId, string $locale = 'de'): array
|
||||
{
|
||||
$termId = (string) $this->db->fetchOne(
|
||||
'SELECT t.id FROM term t JOIN page_term pt ON pt.term_id = t.id WHERE pt.page_id = ? AND t.kind = ? ORDER BY t.name LIMIT 1',
|
||||
[$pageId, 'category'],
|
||||
);
|
||||
// Dieselbe Sortierung wie im Archiv, damit „vorheriges" dort dasselbe heißt wie hier.
|
||||
$all = $this->published(1, '' === $termId ? null : $termId, 500, $locale)['posts'];
|
||||
// present() gibt bewusst keine Kennung heraus - in eine Liste gehören Titel und Adresse.
|
||||
// Verglichen wird deshalb über die Adresse, die innerhalb einer Website eindeutig ist.
|
||||
$own = $this->settings->publicPath((string) $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$pageId]));
|
||||
$index = null;
|
||||
foreach ($all as $position => $post) {
|
||||
if ($post['href'] === $own) {
|
||||
$index = $position;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (null === $index) {
|
||||
return ['previous' => null, 'next' => null];
|
||||
}
|
||||
|
||||
return [
|
||||
// „Vorheriges" ist im Archiv der neuere Beitrag, weil dort das Neueste oben steht.
|
||||
'previous' => $all[$index - 1] ?? null,
|
||||
'next' => $all[$index + 1] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt es überhaupt einen veröffentlichten Beitrag?
|
||||
*
|
||||
* Eine Website ohne Blog soll nirgends von Beiträgen reden: kein leeres Archiv, kein Feed mit
|
||||
* null Einträgen, kein Kasten „hier erscheinen Beiträge“ auf einer Seite. Die Antwort wird für
|
||||
* die Dauer einer Anfrage gemerkt, weil mehrere Stellen sie brauchen.
|
||||
*/
|
||||
public function anyPublished(string $locale = 'de'): bool
|
||||
{
|
||||
return $this->anyPublished ??= (bool) $this->db->fetchOne(
|
||||
"SELECT 1 FROM page WHERE website_id = ? AND locale = ? AND type = 'post' AND published_revision_id IS NOT NULL AND deleted_at IS NULL LIMIT 1",
|
||||
['default', $locale],
|
||||
);
|
||||
}
|
||||
|
||||
private ?bool $anyPublished = null;
|
||||
|
||||
public function termsFor(string $pageId): array
|
||||
{
|
||||
$terms = ['category' => [], 'tag' => []];
|
||||
|
||||
@@ -33,6 +33,12 @@ final class RichText
|
||||
return is_int($schemaVersion) && $schemaVersion >= 12;
|
||||
}
|
||||
|
||||
/** Ueberschriften duerfen erst ab v17 Auszeichnungen tragen. */
|
||||
public static function headings(mixed $schemaVersion): bool
|
||||
{
|
||||
return is_int($schemaVersion) && $schemaVersion >= 17;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the text with only allowed markup left. The result is safe to print unescaped.
|
||||
*/
|
||||
@@ -184,11 +190,21 @@ final class RichText
|
||||
if (!self::supports($document['schemaVersion'] ?? null)) {
|
||||
return $document;
|
||||
}
|
||||
$walk = function (array $blocks) use (&$walk): array {
|
||||
// Die Fassung wird in die Schliessung gebunden. Ohne das war sie darin schlicht nicht
|
||||
// vorhanden, der Filter fuer Ueberschriften lief nie - und ihr Inhalt wird unescaped
|
||||
// ausgegeben. Der Test, der ein <script> in eine Ueberschrift schreibt, hat es gezeigt.
|
||||
$headings = self::headings($document['schemaVersion'] ?? null);
|
||||
$walk = function (array $blocks) use (&$walk, $headings): array {
|
||||
foreach ($blocks as $index => $block) {
|
||||
if ('text' === ($block['type'] ?? null) && is_string($block['props']['text'] ?? null)) {
|
||||
$blocks[$index]['props']['text'] = $this->sanitise($block['props']['text']);
|
||||
}
|
||||
// Ab v17 darf auch eine Ueberschrift Auszeichnungen tragen. Gebraucht wird das
|
||||
// fuer ein hervorgehobenes Wort - im Original von kant-it.de ist die zweite
|
||||
// Zeile der Hauptueberschrift rot. Durch denselben Filter wie jeder Text.
|
||||
if ($headings && 'heading' === ($block['type'] ?? null) && is_string($block['props']['text'] ?? null)) {
|
||||
$blocks[$index]['props']['text'] = $this->sanitise($block['props']['text']);
|
||||
}
|
||||
if (isset($block['children']) && is_array($block['children'])) {
|
||||
$blocks[$index]['children'] = $walk($block['children']);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Content;
|
||||
|
||||
use App\Domain\InvalidPage;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Exception\TableNotFoundException;
|
||||
|
||||
/**
|
||||
* Company data and site wide switches. The structured data the pages emit is built from exactly
|
||||
@@ -24,6 +25,31 @@ final class SiteSettings
|
||||
'sameAs' => [],
|
||||
'localBusiness' => false,
|
||||
'llmsTxt' => false,
|
||||
// Der Kopfbereich bleibt beim Scrollen oben stehen. Eine Eigenschaft der Website und
|
||||
// nicht des Dokuments: kleben kann nur das Element, das den Bereich umschliesst.
|
||||
'stickyHeader' => false,
|
||||
// Datum, Autor und Kategorie über dem Beitrag. Aus, wenn der Beitrag seinen eigenen
|
||||
// Kopf mitbringt - sonst steht die Zeile über einem Bild, das den Titel schon trägt.
|
||||
'postMetaHeader' => true,
|
||||
// Das Symbol der Website im Browser-Reiter: ein Bild aus der Mediathek, aus dem beim
|
||||
// Speichern quadratische PNG-Dateien entstehen. Leer heißt: das mitgelieferte Zeichen.
|
||||
'faviconMediaId' => '',
|
||||
// Der Knopf „nach oben". Aus, weil eine kurze Seite ihn nicht braucht; alles daran ist
|
||||
// einstellbar, weil er auf jeder Seite in der Ecke klebt und deshalb zur Gestaltung
|
||||
// passen muss. Eingeblendet wird er beim Scrollen - ohne eine Zeile JavaScript.
|
||||
'scrollTop' => false,
|
||||
'scrollTopPosition' => 'rechts',
|
||||
'scrollTopShape' => 'rund',
|
||||
'scrollTopSize' => 48,
|
||||
'scrollTopColor' => '',
|
||||
'scrollTopInk' => '',
|
||||
'scrollTopLabel' => 'Nach oben',
|
||||
'scrollTopAfter' => 400,
|
||||
// Cloudflare Turnstile fuer Formulare. Aus, weil eine Website ohne Spamwelle keinen
|
||||
// Dritten einbinden soll - und weil die Pruefung JavaScript verlangt.
|
||||
'turnstile' => false,
|
||||
'turnstileSiteKey' => '',
|
||||
'turnstileSecret' => '',
|
||||
'robotsExtra' => '',
|
||||
// Which stored page answers at the site root. Everything else derives from this.
|
||||
'homePath' => 'home',
|
||||
@@ -36,7 +62,7 @@ final class SiteSettings
|
||||
'imprintPath' => '',
|
||||
];
|
||||
|
||||
public function __construct(private Connection $db)
|
||||
public function __construct(private Connection $db, private MediaLibrary $media)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -54,7 +80,14 @@ final class SiteSettings
|
||||
if (null !== $this->loaded) {
|
||||
return $this->loaded;
|
||||
}
|
||||
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']);
|
||||
try {
|
||||
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']);
|
||||
} catch (TableNotFoundException) {
|
||||
// Vor der Installation gibt es die Tabelle noch nicht. Fragen tun trotzdem schon
|
||||
// Seiten, die es in dem Moment geben kann - der Installer selbst und die
|
||||
// Nicht-gefunden-Seite. Die Standardwerte sind dort die richtige Antwort.
|
||||
return $this->loaded = self::DEFAULTS;
|
||||
}
|
||||
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
|
||||
|
||||
return $this->loaded = [...self::DEFAULTS, ...(is_array($stored) ? $stored : [])];
|
||||
@@ -93,15 +126,68 @@ final class SiteSettings
|
||||
throw new InvalidPage('Ein lokales Unternehmen braucht mindestens Straße und Ort.');
|
||||
}
|
||||
$values['llmsTxt'] = (bool) ($input['llmsTxt'] ?? false);
|
||||
$values['stickyHeader'] = (bool) ($input['stickyHeader'] ?? false);
|
||||
$favicon = trim((string) ($input['faviconMediaId'] ?? ''));
|
||||
if ('' !== $favicon) {
|
||||
if (1 !== preg_match('/^[a-f0-9]{32}$/D', $favicon) || null === $this->media->find($favicon)) {
|
||||
throw new InvalidPage('Das gewählte Symbolbild gibt es nicht (mehr).');
|
||||
}
|
||||
// Die Dateien entstehen hier und nicht bei jedem Seitenaufruf. Schlägt das fehl, wird
|
||||
// die Einstellung gar nicht erst gespeichert - ein Verweis auf ein fehlendes Symbol
|
||||
// wäre schlimmer als das alte Symbol.
|
||||
try {
|
||||
$this->media->renderFavicon($favicon);
|
||||
} catch (\RuntimeException $error) {
|
||||
throw new InvalidPage('Aus diesem Bild konnte kein Symbol erzeugt werden: '.$error->getMessage());
|
||||
}
|
||||
}
|
||||
$values['faviconMediaId'] = $favicon;
|
||||
foreach (['turnstileSiteKey' => 100, 'turnstileSecret' => 100] as $key => $max) {
|
||||
$wert = trim((string) ($input[$key] ?? ''));
|
||||
if ('' !== $wert && 1 !== preg_match('/^[A-Za-z0-9_:.-]{8,'.$max.'}$/D', $wert)) {
|
||||
throw new InvalidPage('Die Turnstile-Schlüssel bestehen aus Buchstaben, Ziffern und den Zeichen _ : . - und sind mindestens acht Zeichen lang.');
|
||||
}
|
||||
$values[$key] = $wert;
|
||||
}
|
||||
$values['scrollTop'] = (bool) ($input['scrollTop'] ?? false);
|
||||
$values['scrollTopPosition'] = in_array($input['scrollTopPosition'] ?? '', ['rechts', 'links'], true) ? $input['scrollTopPosition'] : 'rechts';
|
||||
$values['scrollTopShape'] = in_array($input['scrollTopShape'] ?? '', ['rund', 'weich', 'eckig'], true) ? $input['scrollTopShape'] : 'rund';
|
||||
$values['scrollTopSize'] = max(36, min(72, (int) ($input['scrollTopSize'] ?? 48)));
|
||||
// Ab wie vielen Pixeln er erscheint. Null heisst: sofort.
|
||||
$values['scrollTopAfter'] = max(0, min(4000, (int) ($input['scrollTopAfter'] ?? 400)));
|
||||
$values['scrollTopLabel'] = mb_substr(trim((string) ($input['scrollTopLabel'] ?? '')), 0, 60) ?: 'Nach oben';
|
||||
foreach (['scrollTopColor', 'scrollTopInk'] as $feld) {
|
||||
$farbe = trim((string) ($input[$feld] ?? ''));
|
||||
if ('' !== $farbe && 1 !== preg_match(StyleSchema::COLOR, $farbe)) {
|
||||
throw new InvalidPage('Die Farben des Knopfes „nach oben" werden als #1a2b3c oder als @schlüssel aus der Palette angegeben.');
|
||||
}
|
||||
$values[$feld] = $farbe;
|
||||
}
|
||||
$values['turnstile'] = (bool) ($input['turnstile'] ?? false);
|
||||
if ($values['turnstile'] && ('' === $values['turnstileSiteKey'] || '' === $values['turnstileSecret'])) {
|
||||
// Eingeschaltet ohne Schluessel hiesse: jedes Formular lehnt jede Absendung ab.
|
||||
throw new InvalidPage('Für die Turnstile-Prüfung werden beide Schlüssel gebraucht – der öffentliche und der geheime.');
|
||||
}
|
||||
$values['postMetaHeader'] = (bool) ($input['postMetaHeader'] ?? false);
|
||||
// Only comment and simple directive lines; nothing that could smuggle in a second policy.
|
||||
// Leerzeilen bleiben erhalten (hoechstens eine am Stueck): eine robots.txt mit eigenen
|
||||
// Regeln je Bot besteht aus Bloecken, und ohne Trennung liest die niemand mehr. Das Limit
|
||||
// liegt bei 200 Zeilen, weil allein die Liste der KI-Crawler laenger ist als dreissig.
|
||||
$extra = [];
|
||||
foreach (preg_split('/\R/', (string) ($input['robotsExtra'] ?? '')) ?: [] as $line) {
|
||||
$line = trim($line);
|
||||
if ('' !== $line && 1 === preg_match('/^(#.{0,180}|(?:Disallow|Allow|Crawl-delay|User-agent):[ \t][^\s][^\r\n]{0,180})$/D', $line)) {
|
||||
if ('' === $line) {
|
||||
if ([] !== $extra && '' !== end($extra)) {
|
||||
$extra[] = '';
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
if (1 === preg_match('/^(#.{0,180}|(?:Disallow|Allow|Crawl-delay|User-agent):[ \t][^\s][^\r\n]{0,180})$/D', $line)) {
|
||||
$extra[] = $line;
|
||||
}
|
||||
}
|
||||
$values['robotsExtra'] = implode("\n", array_slice($extra, 0, 30));
|
||||
$values['robotsExtra'] = trim(implode("\n", array_slice($extra, 0, 200)));
|
||||
|
||||
$home = trim(trim((string) ($input['homePath'] ?? self::DEFAULTS['homePath'])), '/');
|
||||
if (1 !== preg_match('#^[a-z0-9]+(?:[-/][a-z0-9]+)*$#D', $home)) {
|
||||
|
||||
@@ -22,7 +22,7 @@ use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
final class Statistics
|
||||
{
|
||||
/** Everything that is not a page of this site, or not a visit at all. */
|
||||
private const IGNORED = '#^/(admin|media/|assets/|einwilligung$|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$|favicon)#';
|
||||
private const IGNORED = '#^/(media/|assets/|einwilligung$|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$|favicon)#';
|
||||
|
||||
/**
|
||||
* A heuristic, and nothing more: user agents lie. It keeps the obvious crawlers and monitors
|
||||
@@ -40,7 +40,7 @@ final class Statistics
|
||||
/** After two months the hours of a day are merged into one row. */
|
||||
public const COMPACT_AFTER_DAYS = 60;
|
||||
|
||||
public function __construct(private Connection $db)
|
||||
public function __construct(private Connection $db, private string $adminPath = 'admin')
|
||||
{
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ final class Statistics
|
||||
&& in_array($status, [200, 404], true)
|
||||
&& str_contains($contentType, 'text/html')
|
||||
&& 1 !== preg_match(self::IGNORED, $path)
|
||||
// Die Verwaltung ist kein Besuch - unabhaengig davon, wie sie heisst.
|
||||
&& !str_starts_with($path, '/'.$this->adminPath)
|
||||
&& 1 !== preg_match(self::MACHINES, $userAgent);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
/**
|
||||
* Die Stylesheets einer öffentlichen Seite als eine Datei.
|
||||
*
|
||||
* Getrennte Dateien sind beim Schreiben richtig – `site.css` das Grundgerüst, `design.css` die
|
||||
* Gestaltungswerte, `enhancements.css` die Bewegung, `fonts/fonts.css` die Schriften. Beim
|
||||
* Ausliefern sind sie vier Anfragen, die alle vier das Zeichnen der Seite aufhalten, und auf einer
|
||||
* Mobilfunkverbindung ist jede davon eine eigene Wartezeit.
|
||||
*
|
||||
* Zusammengelegt wird deshalb einmal in eine Datei, deren Name die Prüfsumme des Inhalts trägt:
|
||||
* ändert sich eine Quelle, ändert sich der Name, und kein Browser zeigt altes Aussehen. Entfernt
|
||||
* werden dabei die Kommentare – sie erklären den Quelltext, nicht den Browser.
|
||||
*
|
||||
* Schlägt das Schreiben fehl (kein Schreibrecht im Ordner), bleibt es bei den Einzeldateien: eine
|
||||
* Website ohne Bündel ist langsamer, eine Website ohne Stylesheet ist kaputt.
|
||||
*/
|
||||
final class StyleBundle
|
||||
{
|
||||
/** Die Reihenfolge ist die Reihenfolge im Kopf der Seite - später gewinnt. */
|
||||
private const SOURCES = ['fonts/fonts.css', 'site.css', 'design.css', 'enhancements.css'];
|
||||
|
||||
public function __construct(private string $assetDirectory)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Adresse des Bündels, oder null wenn es keines gibt.
|
||||
*
|
||||
* Erzeugt wird beim ersten Aufruf nach einer Änderung. Das kostet einen Aufruf lang ein paar
|
||||
* Millisekunden und spart danach jedem Besucher drei Anfragen.
|
||||
*/
|
||||
public function url(bool $withFonts = true): ?string
|
||||
{
|
||||
$sources = $this->sources($withFonts);
|
||||
if ([] === $sources) {
|
||||
return null;
|
||||
}
|
||||
$name = 'kant-'.substr(hash('xxh128', implode('|', array_map(
|
||||
static fn (string $file): string => $file.':'.filemtime($file).':'.filesize($file),
|
||||
$sources,
|
||||
))), 0, 16).'.css';
|
||||
$path = $this->assetDirectory.'/build/'.$name;
|
||||
if (!is_file($path) && !$this->write($path, $sources)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return '/assets/build/'.$name;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function sources(bool $withFonts): array
|
||||
{
|
||||
$found = [];
|
||||
foreach (self::SOURCES as $source) {
|
||||
if ('fonts/fonts.css' === $source && !$withFonts) {
|
||||
continue;
|
||||
}
|
||||
$path = $this->assetDirectory.'/'.$source;
|
||||
if (is_file($path)) {
|
||||
$found[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
// Ohne das Grundgerüst kein Bündel: dann stimmt etwas anderes nicht.
|
||||
return in_array($this->assetDirectory.'/site.css', $found, true) ? $found : [];
|
||||
}
|
||||
|
||||
/** @param list<string> $sources */
|
||||
private function write(string $path, array $sources): bool
|
||||
{
|
||||
$directory = dirname($path);
|
||||
if (!is_dir($directory) && !@mkdir($directory, 0775, true) && !is_dir($directory)) {
|
||||
return false;
|
||||
}
|
||||
$css = '';
|
||||
foreach ($sources as $source) {
|
||||
$css .= $this->minify((string) file_get_contents($source))."\n";
|
||||
}
|
||||
// Erst daneben schreiben, dann umbenennen: ein zweiter Aufruf im selben Moment soll nie
|
||||
// eine halb geschriebene Datei ausliefern.
|
||||
$temporary = $path.'.'.bin2hex(random_bytes(4));
|
||||
if (false === @file_put_contents($temporary, $css) || !@rename($temporary, $path)) {
|
||||
@unlink($temporary);
|
||||
|
||||
return false;
|
||||
}
|
||||
$this->clean($directory, basename($path));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kommentare raus, Einrückung raus - sonst nichts.
|
||||
*
|
||||
* Kein Umbau von Selektoren, kein Zusammenfassen von Regeln: ein Minifier, der die Bedeutung
|
||||
* ändert, spart ein paar Zeichen und kostet einen Abend Fehlersuche. Leerzeichen innerhalb
|
||||
* einer Zeile bleiben deshalb stehen – in `calc(100% - 20px)` und in `.a :hover` tragen sie
|
||||
* Bedeutung.
|
||||
*/
|
||||
private function minify(string $css): string
|
||||
{
|
||||
$withoutComments = preg_replace('#/\*(?!!).*?\*/#s', '', $css) ?? $css;
|
||||
$lines = [];
|
||||
foreach (preg_split('/\R/', $withoutComments) ?: [] as $line) {
|
||||
$line = trim($line);
|
||||
if ('' !== $line) {
|
||||
$lines[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/** Ältere Bündel wegräumen; sie werden von niemandem mehr angefragt. */
|
||||
private function clean(string $directory, string $keep): void
|
||||
{
|
||||
foreach (glob($directory.'/kant-*.css') ?: [] as $old) {
|
||||
if (basename($old) !== $keep && filemtime($old) < time() - 3600) {
|
||||
@unlink($old);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,18 @@ final class StyleCompiler
|
||||
{
|
||||
private const BREAKPOINTS = ['tablet' => 1024, 'mobile' => 767];
|
||||
|
||||
/**
|
||||
* Dieselben Grenzen, als Medienabfragen fuer einen Verweis im Seitenkopf.
|
||||
*
|
||||
* Genau eine trifft zu, und sie trifft dieselbe Variante wie die Regel weiter unten - sonst
|
||||
* holte ein Vorabverweis ein zweites Bild.
|
||||
*/
|
||||
public const PRELOAD_DEVICES = [
|
||||
'desktop' => '(min-width:1025px)',
|
||||
'tablet' => '(min-width:768px) and (max-width:1024px)',
|
||||
'mobile' => '(max-width:767px)',
|
||||
];
|
||||
|
||||
public function __construct(private ?MediaLibrary $media = null)
|
||||
{
|
||||
}
|
||||
@@ -101,23 +113,24 @@ final class StyleCompiler
|
||||
private function compileBlock(string $id, array $style): string
|
||||
{
|
||||
$css = '';
|
||||
$style = $this->scaleBackground($style);
|
||||
foreach (['desktop', 'tablet', 'mobile'] as $device) {
|
||||
$values = $style[$device] ?? null;
|
||||
if (!is_array($values)) {
|
||||
continue;
|
||||
}
|
||||
$rules = '';
|
||||
$base = $this->declarations($values);
|
||||
$base = $this->declarations($values, 'self', $device);
|
||||
if ('' !== $base) {
|
||||
$rules .= sprintf('#%s{%s}', $id, $base);
|
||||
}
|
||||
// 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');
|
||||
$images = $this->declarations($values, 'img', $device);
|
||||
if ('' !== $images) {
|
||||
$rules .= sprintf('#%s img{%s}', $id, $images);
|
||||
}
|
||||
$hover = is_array($values['hover'] ?? null) ? $this->declarations($values['hover']) : '';
|
||||
$hover = is_array($values['hover'] ?? null) ? $this->declarations($values['hover'], 'self', $device) : '';
|
||||
if ('' !== $hover) {
|
||||
$rules .= sprintf('#%s:hover,#%s:focus-within{%s}', $id, $id, $hover);
|
||||
}
|
||||
@@ -138,7 +151,7 @@ final class StyleCompiler
|
||||
*
|
||||
* @param string $target 'self' for the block, 'img' for the images inside it
|
||||
*/
|
||||
private function declarations(array $values, string $target = 'self'): string
|
||||
private function declarations(array $values, string $target = 'self', string $device = 'desktop'): string
|
||||
{
|
||||
$properties = StyleSchema::properties();
|
||||
$out = [];
|
||||
@@ -173,7 +186,7 @@ final class StyleCompiler
|
||||
// The composed declarations describe the block itself; none of them has an image variant.
|
||||
if ('self' === $target) {
|
||||
foreach ([
|
||||
$this->background($values),
|
||||
$this->background($values, $device),
|
||||
$this->shadow($values),
|
||||
$this->textShadow($values),
|
||||
$this->filter($values),
|
||||
@@ -203,7 +216,7 @@ final class StyleCompiler
|
||||
}
|
||||
|
||||
/** Overlay, gradient and image share one background-image declaration, layered in that order. */
|
||||
private function background(array $values): string
|
||||
private function background(array $values, string $device = 'desktop'): string
|
||||
{
|
||||
$layers = [];
|
||||
$overlay = $values['overlayColor'] ?? null;
|
||||
@@ -223,7 +236,9 @@ final class StyleCompiler
|
||||
if (isset($values['backgroundImage']) && null !== $this->media) {
|
||||
$item = $this->media->find((string) $values['backgroundImage']);
|
||||
if (null !== $item && [] !== $item['variants']) {
|
||||
$largest = end($item['variants']);
|
||||
// A phone should not download the 2400 pixel variant of a background. CSS has no
|
||||
// srcset, but the rules are already written per device, so the device decides.
|
||||
$largest = self::variantFor($item['variants'], $device);
|
||||
// The URL comes from the media library, never from the document.
|
||||
$layers[] = sprintf('url("%s")', $largest['src']);
|
||||
}
|
||||
@@ -314,6 +329,68 @@ final class StyleCompiler
|
||||
return [] === $parts ? '' : 'transform:'.implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* The variant a device should load for a background.
|
||||
*
|
||||
* Mobile takes the smallest that is at least 960 wide, tablet the one around 1600, desktop the
|
||||
* largest. A picture that only has small variants simply gives what it has.
|
||||
*/
|
||||
/**
|
||||
* Ein Hintergrundbild gilt auch dann in Gerätegrößen, wenn es nur einmal gesetzt wurde.
|
||||
*
|
||||
* Wer im Editor ein Hintergrundbild wählt, setzt es üblicherweise am Schreibtisch - also unter
|
||||
* „Desktop“. Ohne eigene Regel für die kleineren Geräte erbt ein Telefon diese Regel und lädt
|
||||
* die größte Variante: gemessen 189 KB statt 40 KB für ein Bild, das nie in dieser Auflösung
|
||||
* zu sehen ist. CSS kennt kein srcset, aber die Regeln stehen ohnehin je Gerät da - also
|
||||
* bekommt jedes Gerät dieselbe Bildwahl, die es bekäme, wenn jemand sie einzeln gesetzt hätte.
|
||||
*
|
||||
* Ein ausdrücklich gesetztes Bild wird nie ersetzt; ergänzt wird nur, wo nichts steht.
|
||||
*/
|
||||
private function scaleBackground(array $style): array
|
||||
{
|
||||
$image = $style['desktop']['backgroundImage'] ?? null;
|
||||
if (null === $image) {
|
||||
return $style;
|
||||
}
|
||||
foreach (['tablet', 'mobile'] as $device) {
|
||||
if (!isset($style[$device]['backgroundImage'])) {
|
||||
$style[$device]['backgroundImage'] = $image;
|
||||
// Verlauf und Überlagerung gehören zur selben Regel: ohne sie stünde auf dem
|
||||
// Telefon plötzlich ein unabgedunkeltes Bild hinter hellem Text.
|
||||
foreach (['gradientFrom', 'gradientTo', 'gradientAngle', 'overlayColor', 'overlayOpacity', 'backgroundSize', 'backgroundPosition', 'backgroundRepeat', 'backgroundAttachment'] as $begleiter) {
|
||||
if (isset($style['desktop'][$begleiter]) && !isset($style[$device][$begleiter])) {
|
||||
$style[$device][$begleiter] = $style['desktop'][$begleiter];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $style;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Variante, die ein Hintergrundbild auf diesem Geraet bekommt.
|
||||
*
|
||||
* Oeffentlich und statisch, weil der Seitenkopf dieselbe Wahl treffen muss, wenn er das Bild
|
||||
* vorab holt. Zwei Stellen mit derselben Regel waeren zwei Stellen, die auseinanderlaufen.
|
||||
*
|
||||
* @param list<array{src: string, width: int}> $variants
|
||||
*
|
||||
* @return array{src: string, width: int}
|
||||
*/
|
||||
public static function variantFor(array $variants, string $device): array
|
||||
{
|
||||
$wanted = ['mobile' => 960, 'tablet' => 1600, 'desktop' => 4000][$device] ?? 4000;
|
||||
$chosen = $variants[0];
|
||||
foreach ($variants as $variant) {
|
||||
if ($variant['width'] <= $wanted || $chosen['width'] < $wanted) {
|
||||
$chosen = $variant;
|
||||
}
|
||||
}
|
||||
|
||||
return $chosen;
|
||||
}
|
||||
|
||||
private function grid(array $values): string
|
||||
{
|
||||
return isset($values['gridColumns'])
|
||||
|
||||
+29
-1
@@ -49,6 +49,10 @@ final class Theme
|
||||
'linkColor' => '@link',
|
||||
'containerWidth' => '1240px',
|
||||
'headingScale' => 1.25,
|
||||
// Unterstrichene Links im Fließtext. An, weil ein Link, den nur die Farbe verrät,
|
||||
// für Menschen mit Farbsehschwäche kein Link ist. Wer eine ruhigere Seite will,
|
||||
// schaltet es hier ab - dann bleibt der Unterstrich beim Überfahren.
|
||||
'linkUnderline' => true,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -207,6 +211,7 @@ final class Theme
|
||||
}
|
||||
$base[$field] = $value;
|
||||
}
|
||||
$base['linkUnderline'] = (bool) ($input['linkUnderline'] ?? false);
|
||||
foreach (['lineHeight' => [0.8, 3.0], 'headingScale' => [1.0, 2.0]] as $field => [$min, $max]) {
|
||||
$value = (float) ($input[$field] ?? $base[$field]);
|
||||
if ($value < $min || $value > $max) {
|
||||
@@ -269,6 +274,26 @@ final class Theme
|
||||
$variables[] = sprintf('--k-container:%s', $base['containerWidth']);
|
||||
|
||||
$colour = static fn (string $v): string => str_starts_with($v, '@') ? sprintf('var(--k-%s)', substr($v, 1)) : $v;
|
||||
|
||||
// Die Bausteine selbst - Knopf, Symbol, Rahmen, ruhiger Text - wurden aus festen Werten in
|
||||
// site.css gefaerbt. Eine Website mit eigener Palette behielt damit das Gruen der Vorlage:
|
||||
// die Farben liessen sich einstellen und taten nichts. Hier bekommen diese Token ihre
|
||||
// Werte aus dem Theme. Als Akzent dient die Linkfarbe und nicht ein Palettenname, weil
|
||||
// dieses Token auch kleine Beschriftungen faerbt - eine helle Akzentfarbe waere dort
|
||||
// nicht mehr lesbar.
|
||||
$ink = $colour($base['color']);
|
||||
$paper = $colour($base['backgroundColor']);
|
||||
$variables[] = sprintf('--ink:%s', $ink);
|
||||
$variables[] = sprintf('--paper:%s', $paper);
|
||||
$variables[] = sprintf('--accent:%s', $colour($base['linkColor']));
|
||||
// Abgeleitet statt erfunden: ruhiger Text und Linien sind der Textton, in den Hintergrund
|
||||
// gemischt. Ein Palettenwert gleichen Namens hat Vorrang, damit sich beides setzen laesst.
|
||||
// 80 Prozent, nicht weniger: bei 72 kam fuer eine Palette mit hellem Textton ein Grau
|
||||
// heraus, das die 4,5:1 knapp verfehlte - gefunden beim Nachbau von kant-it.de.
|
||||
$variables[] = sprintf('--muted:var(--k-muted,color-mix(in srgb,%s 80%%,%s))', $ink, $paper);
|
||||
$variables[] = sprintf('--line:var(--k-line,color-mix(in srgb,%s 20%%,%s))', $ink, $paper);
|
||||
$variables[] = sprintf('--mint:var(--k-mint,color-mix(in srgb,%s 14%%,%s))', $colour($base['linkColor']), $paper);
|
||||
|
||||
$scale = (float) $base['headingScale'];
|
||||
$headings = '';
|
||||
// A modular scale from the base size, so one number changes the whole hierarchy.
|
||||
@@ -279,7 +304,9 @@ final class Theme
|
||||
return sprintf(
|
||||
':root{%s;--k-size:%s}.kant{font-family:var(--kf-%s);font-size:var(--k-size);line-height:%s;color:%s;background:%s}'
|
||||
.'.kant h1,.kant h2,.kant h3,.kant h4,.kant h5,.kant h6{font-family:var(--kf-%s)}'
|
||||
.'.kant .block-text a,.kant .post-excerpt a{color:%s}.kant .shell{max-width:var(--k-container)}%s',
|
||||
.'.kant .block-text a,.kant .block-list a,.kant .post-excerpt a{color:%s;text-decoration:%s}'
|
||||
.'.kant .block-text a:hover,.kant .block-list a:hover,.kant .post-excerpt a:hover{text-decoration:underline}'
|
||||
.'.kant .shell{max-width:var(--k-container)}%s',
|
||||
implode(';', $variables),
|
||||
$base['fontSize'],
|
||||
$base['fontFamily'],
|
||||
@@ -288,6 +315,7 @@ final class Theme
|
||||
$colour($base['backgroundColor']),
|
||||
$base['headingFontFamily'],
|
||||
$colour($base['linkColor']),
|
||||
($base['linkUnderline'] ?? true) ? 'underline' : 'none',
|
||||
$headings,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
/**
|
||||
* Cloudflare Turnstile: die Prüfung „bist du ein Mensch“ ohne Bilderrätsel.
|
||||
*
|
||||
* Der eingebaute Spamschutz – Honigtopf, signierter Zeitstempel, fünf Absendungen je Stunde –
|
||||
* hält, was automatisch ausgefüllte Formulare angeht, das meiste ab. Gegen einen Absender, der
|
||||
* einen echten Browser fernsteuert, hilft er nicht. Dafür ist das hier gedacht, und zwar
|
||||
* **optional**: eine Website, die keine Spamwelle hat, soll keinen Dritten einbinden.
|
||||
*
|
||||
* Warum Turnstile und nicht reCAPTCHA: es setzt keine Cookies, verlangt keine Anmeldung bei
|
||||
* Google und wertet kein Nutzungsverhalten über Websites hinweg aus. Ein Dritter bleibt es
|
||||
* trotzdem – die IP-Adresse des Absenders geht an Cloudflare, und das gehört in die
|
||||
* Datenschutzerklärung.
|
||||
*
|
||||
* Der Preis steht in der Verwaltung dabei: die Prüfung braucht JavaScript. Ohne Skript ist das
|
||||
* Formular dann nicht absendbar, und das ist genau die Art von Verschlechterung, die dieses
|
||||
* Projekt sonst vermeidet.
|
||||
*/
|
||||
final class Turnstile
|
||||
{
|
||||
private const VERIFY = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
|
||||
/** Die Adresse, von der das Widget sein Skript lädt - sie muss in die Sicherheitsrichtlinie. */
|
||||
public const HOST = 'https://challenges.cloudflare.com';
|
||||
|
||||
public function __construct(private SiteSettings $settings)
|
||||
{
|
||||
}
|
||||
|
||||
/** Ist die Prüfung eingerichtet und eingeschaltet? */
|
||||
public function isActive(): bool
|
||||
{
|
||||
$settings = $this->settings->load();
|
||||
|
||||
return ($settings['turnstile'] ?? false)
|
||||
&& '' !== ($settings['turnstileSiteKey'] ?? '')
|
||||
&& '' !== ($settings['turnstileSecret'] ?? '');
|
||||
}
|
||||
|
||||
/** Der öffentliche Schlüssel für das Widget im Formular. */
|
||||
public function siteKey(): string
|
||||
{
|
||||
return (string) ($this->settings->load()['turnstileSiteKey'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft die Antwort des Besuchers bei Cloudflare.
|
||||
*
|
||||
* Ist die Prüfung nicht eingeschaltet, gilt jede Absendung als bestanden - der Aufrufer muss
|
||||
* nicht wissen, ob sie eingerichtet ist.
|
||||
*
|
||||
* Antwortet Cloudflare gar nicht, gilt die Absendung ebenfalls als bestanden. Das ist
|
||||
* beabsichtigt: ein Ausfall bei einem Dritten darf nicht dazu führen, dass ein Kunde sein
|
||||
* Angebot nicht anfragen kann. Spam ist ärgerlich, eine verlorene Anfrage ist teurer.
|
||||
*/
|
||||
public function passed(?string $token, ?string $clientIp): bool
|
||||
{
|
||||
if (!$this->isActive()) {
|
||||
return true;
|
||||
}
|
||||
if (null === $token || '' === $token || strlen($token) > 2048) {
|
||||
return false;
|
||||
}
|
||||
$antwort = $this->ask([
|
||||
'secret' => (string) ($this->settings->load()['turnstileSecret'] ?? ''),
|
||||
'response' => $token,
|
||||
...(null === $clientIp ? [] : ['remoteip' => $clientIp]),
|
||||
]);
|
||||
|
||||
return null === $antwort ? true : (bool) ($antwort['success'] ?? false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragt Cloudflare - über curl, ersatzweise über den Dateizeiger.
|
||||
*
|
||||
* Dieselben zwei Wege wie im Installer und beim Laden der Schriften: auf einem Webhosting ist
|
||||
* mal das eine da und mal das andere, und eine weitere Abhängigkeit würde jedes Update ohne
|
||||
* Composer scheitern lassen.
|
||||
*
|
||||
* @param array<string, string> $felder
|
||||
*
|
||||
* @return array<string, mixed>|null null, wenn keine Antwort zustande kam
|
||||
*/
|
||||
private function ask(array $felder): ?array
|
||||
{
|
||||
$body = http_build_query($felder);
|
||||
$rohantwort = null;
|
||||
if (function_exists('curl_init')) {
|
||||
$curl = curl_init(self::VERIFY);
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
// Kurz gehalten: hier wartet ein Mensch vor einem abgeschickten Formular.
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
$ergebnis = curl_exec($curl);
|
||||
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($curl);
|
||||
$rohantwort = is_string($ergebnis) && 200 === $status ? $ergebnis : null;
|
||||
} elseif (filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL)) {
|
||||
$context = stream_context_create([
|
||||
'http' => ['method' => 'POST', 'header' => "Content-Type: application/x-www-form-urlencoded\r\n", 'content' => $body, 'timeout' => 10],
|
||||
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
|
||||
]);
|
||||
$ergebnis = @file_get_contents(self::VERIFY, false, $context);
|
||||
$rohantwort = is_string($ergebnis) ? $ergebnis : null;
|
||||
}
|
||||
if (null === $rohantwort) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$entschluesselt = json_decode($rohantwort, true, 16, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_array($entschluesselt) ? $entschluesselt : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\EventSubscriber\TaskWorkerSubscriber;
|
||||
use App\Ops\Scheduler;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Der Cronjob für Hostings, die nur eine URL aufrufen können.
|
||||
*
|
||||
* Viele Tarife bieten keinen Shell-Cron, aber einen „URL-Cronjob": der Hoster ruft alle paar
|
||||
* Minuten eine Adresse auf. Das genügt – und ist gegenüber dem Besuchs-Auslöser der bessere Weg,
|
||||
* weil er auch dann läuft, wenn niemand auf der Website ist.
|
||||
*
|
||||
* Drei Entscheidungen:
|
||||
*
|
||||
* 1. **Der Schlüssel steht in der Adresse**, weil ein URL-Cronjob nichts anderes mitschicken
|
||||
* kann als eine Adresse. Er ist aus dem Anwendungsgeheimnis abgeleitet, 40 Zeichen lang und
|
||||
* auf jeder Installation ein anderer.
|
||||
* 2. **Ein falscher Schlüssel bekommt 404**, nicht 403. Wer probiert, soll nicht erfahren, dass
|
||||
* es hier überhaupt etwas gibt.
|
||||
* 3. **Die Antwort ist Text**, nicht HTML: sie landet im Protokoll des Hosters, und dort ist
|
||||
* eine Zeile je Aufgabe das Nützlichste, was ankommen kann.
|
||||
*/
|
||||
final class CronController extends AbstractController
|
||||
{
|
||||
public function __construct(private Scheduler $scheduler, private string $secret)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/cms-cron/{token}', name: 'cms_cron', requirements: ['token' => '[a-f0-9]{40}'], methods: ['GET', 'POST'])]
|
||||
public function run(Request $request, string $token): Response
|
||||
{
|
||||
if (!hash_equals(Scheduler::cronToken($this->secret), $token)) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
$request->attributes->set(TaskWorkerSubscriber::HANDLED, true);
|
||||
// Hier wartet kein Mensch: ein Aufruf darf alles abarbeiten, was ansteht.
|
||||
@ignore_user_abort(true);
|
||||
@set_time_limit(0);
|
||||
$began = microtime(true);
|
||||
$results = $this->scheduler->runDue('cron', 20, 240);
|
||||
|
||||
$lines = [sprintf('%s UTC', gmdate('Y-m-d H:i:s'))];
|
||||
foreach ($results as $result) {
|
||||
$lines[] = sprintf('%-14s %-7s %6d ms %s', $result['name'], $result['status'], $result['ms'], $result['message']);
|
||||
}
|
||||
$lines[] = sprintf('%d Aufgabe(n) in %.1f s.', count($results), microtime(true) - $began);
|
||||
|
||||
return new Response(implode("\n", $lines)."\n", Response::HTTP_OK, [
|
||||
'Content-Type' => 'text/plain; charset=UTF-8',
|
||||
'Cache-Control' => 'no-store',
|
||||
'X-Robots-Tag' => 'noindex, nofollow',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Content\DocumentValidator;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\PostLayouts;
|
||||
use App\Content\PostRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\StyleCompiler;
|
||||
@@ -23,7 +24,7 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
|
||||
#[Route('/admin')]
|
||||
#[Route('/%kant.admin_path%')]
|
||||
final class EditorController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private CsrfTokenManagerInterface $csrf)
|
||||
@@ -45,6 +46,7 @@ final class EditorController extends AbstractController
|
||||
'pages' => $pages,
|
||||
'trash' => $this->pages->trash(),
|
||||
'parents' => array_values(array_filter($pages, static fn (array $p): bool => 'page' === $p['type'] && $p['depth'] + 2 <= PageRepository::MAX_DEPTH)),
|
||||
'postLayouts' => PostLayouts::all(),
|
||||
// Which header and footer areas a page can choose from.
|
||||
'areaChoices' => ['header' => $this->partials->choices('header'), 'footer' => $this->partials->choices('footer')],
|
||||
]);
|
||||
@@ -65,7 +67,13 @@ final class EditorController extends AbstractController
|
||||
$parentId = $request->request->getString('parent_id') ?: null;
|
||||
$slug = trim($request->request->getString('slug')) ?: $this->slugify($title);
|
||||
try {
|
||||
$id = $this->pages->createPage($slug, $parentId, $type, DemoContent::starter($title, $type));
|
||||
// Ein Beitrag beginnt mit einem Aufbau, eine Seite leer: bei einer Seite weiß
|
||||
// niemand vorher, was daraus wird, bei einem Beitrag fast immer.
|
||||
$layout = $request->request->getString('layout', PostLayouts::DEFAULT);
|
||||
$document = 'post' === $type && PostLayouts::exists($layout)
|
||||
? PostLayouts::document($layout, $title)
|
||||
: DemoContent::starter($title, $type);
|
||||
$id = $this->pages->createPage($slug, $parentId, $type, $document);
|
||||
} catch (InvalidPage|InvalidDocument $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
@@ -137,6 +145,7 @@ final class EditorController extends AbstractController
|
||||
'publishedAt' => $request->request->getString('publishedAt'),
|
||||
'featuredMediaId' => $request->request->getString('featuredMediaId'),
|
||||
'ogMediaId' => $request->request->getString('ogMediaId'),
|
||||
'seoTitle' => $request->request->getString('seoTitle'),
|
||||
'robots' => $request->request->getString('robots', 'index'),
|
||||
],
|
||||
$terms($request->request->getString('categories')),
|
||||
@@ -213,7 +222,7 @@ final class EditorController extends AbstractController
|
||||
|
||||
return $this->back('' === trim($request->request->getString('publishAt'))
|
||||
? 'Die geplante Veröffentlichung wurde entfernt.'
|
||||
: 'Die Veröffentlichung ist geplant. Sie erfolgt, sobald cms:publish-due läuft.', 'success');
|
||||
: 'Die Veröffentlichung ist geplant. Sie erfolgt beim ersten Aufruf der Website nach diesem Zeitpunkt – siehe „Aufgaben“.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/pages/{id}/restore', name: 'admin_restore_page', methods: ['POST'])]
|
||||
|
||||
@@ -16,7 +16,7 @@ final class FormAdminController extends AbstractController
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/forms', name: 'admin_forms', methods: ['GET'])]
|
||||
#[Route('/%kant.admin_path%/forms', name: 'admin_forms', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
return $this->render('admin/forms.html.twig', [
|
||||
@@ -26,7 +26,7 @@ final class FormAdminController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/forms/{slug}', name: 'admin_form_save', requirements: ['slug' => '[a-z0-9-]{1,80}|neu'], methods: ['POST'])]
|
||||
#[Route('/%kant.admin_path%/forms/{slug}', name: 'admin_form_save', requirements: ['slug' => '[a-z0-9-]{1,80}|neu'], methods: ['POST'])]
|
||||
public function save(Request $request, string $slug): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Content\FormRepository;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Turnstile;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -28,6 +29,7 @@ final class FormController extends AbstractController
|
||||
private MenuRepository $menus,
|
||||
private SiteSettings $settings,
|
||||
private MailerInterface $mailer,
|
||||
private Turnstile $turnstile,
|
||||
private string $secret,
|
||||
private string $siteOrigin,
|
||||
) {
|
||||
@@ -48,12 +50,16 @@ final class FormController extends AbstractController
|
||||
// so it learns nothing, but nothing is stored and nothing is sent.
|
||||
$silentlyDropped = '' !== trim($request->request->getString('website'))
|
||||
|| !$this->timingIsPlausible($request)
|
||||
|| $this->forms->rateLimited($senderHash);
|
||||
|| $this->forms->rateLimited($senderHash)
|
||||
// Die vierte, freiwillige Pruefung. Sie antwortet wie die drei anderen still mit
|
||||
// Erfolg: ein Absender, der erfaehrt, woran er gescheitert ist, versucht es gezielt
|
||||
// noch einmal.
|
||||
|| !$this->turnstile->passed($request->request->getString('cf-turnstile-response'), $request->getClientIp());
|
||||
|
||||
$checked = $this->forms->check($form, $request->request->all());
|
||||
if (!$silentlyDropped && [] !== $checked['errors']) {
|
||||
// Re-render the page the form sits on, with the entered values and the messages.
|
||||
return $this->renderPage($page, $form, $checked, new Response(status: 422));
|
||||
return $this->renderPage($request, $page, $form, $checked);
|
||||
}
|
||||
if (!$silentlyDropped) {
|
||||
$submission = $this->forms->store($form['id'], $checked['values'], $checked['consent'], $senderHash);
|
||||
@@ -104,50 +110,38 @@ final class FormController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
private function renderPage(array $page, array $form, array $checked, Response $response): Response
|
||||
/**
|
||||
* Die Seite mit den Fehlermeldungen - gerendert vom Seitencontroller, nicht hier.
|
||||
*
|
||||
* Vorher baute diese Methode die Seite selbst zusammen und liess dabei alles weg, was sie
|
||||
* nicht kannte: Theme, Blockstile, Schriften, das Stylesheet-Buendel. Wer ein Pflichtfeld
|
||||
* vergass, sah eine Seite ohne Gestaltung. Jetzt geht der Weg durch dieselbe Stelle wie jeder
|
||||
* andere Aufruf, und die Formularzustaende werden als Anfrageattribut mitgereicht.
|
||||
*/
|
||||
private function renderPage(Request $request, array $page, array $form, array $checked): Response
|
||||
{
|
||||
$navigation = [];
|
||||
foreach ($this->menus->all() as $slot => $menu) {
|
||||
$navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path']);
|
||||
}
|
||||
$issued = time();
|
||||
$states = [];
|
||||
$walk = function (array $blocks) use (&$walk, &$states, $page, $form, $checked, $issued): void {
|
||||
$zustaende = [];
|
||||
$walk = function (array $blocks) use (&$walk, &$zustaende, $form, $checked): void {
|
||||
foreach ($blocks as $block) {
|
||||
if ('form' === ($block['type'] ?? null)) {
|
||||
$slug = $block['props']['form'] ?? '';
|
||||
$mine = $slug === $form['slug'];
|
||||
$states[$block['id']] = [
|
||||
'form' => $mine ? $form : $this->forms->findBySlug($slug),
|
||||
'sent' => false,
|
||||
// Only the form that was submitted shows messages and keeps its input.
|
||||
'errors' => $mine ? $checked['errors'] : [],
|
||||
'values' => $mine ? $checked['values'] : [],
|
||||
'blockId' => $block['id'], 'page' => $page,
|
||||
'issued' => $issued,
|
||||
'issuedSignature' => hash_hmac('sha256', (string) $issued, $this->secret),
|
||||
];
|
||||
// Nur das abgeschickte Formular zeigt Meldungen und behaelt seine Eingaben.
|
||||
if ('form' === ($block['type'] ?? null) && ($block['props']['form'] ?? '') === $form['slug']) {
|
||||
$zustaende[$block['id']] = ['errors' => $checked['errors'], 'values' => $checked['values'], 'sent' => false];
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($page['document']['blocks'] ?? []);
|
||||
|
||||
$response = $this->render('page.html.twig', [
|
||||
'page' => $page,
|
||||
'canonical' => rtrim($this->siteOrigin, '/').$this->settings->publicPath($page['path']),
|
||||
'navigation' => $navigation,
|
||||
'postLists' => [],
|
||||
'postMeta' => null,
|
||||
'needsTabs' => false,
|
||||
'pageStyles' => '',
|
||||
'googleFonts' => null,
|
||||
'forms' => $states,
|
||||
], $response);
|
||||
$response->headers->set('Cache-Control', 'no-store');
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$response->headers->set('Content-Security-Policy', "default-src 'self'; script-src 'none'; style-src 'self'; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||
// Die Startseite antwortet unter "/" - mit ihrem eigenen Pfad wuerde sie dorthin umleiten,
|
||||
// und aus der Fehlermeldung wuerde eine Weiterleitung ohne Meldung.
|
||||
$adresse = $page['path'] === $this->settings->homePath() ? '' : (string) $page['path'];
|
||||
$antwort = $this->forward('App\Controller\PageController::page', [
|
||||
'path' => $adresse,
|
||||
'request' => $request->duplicate(null, null, ['kant_form_state' => $zustaende, '_route' => 'page']),
|
||||
]);
|
||||
// Die Eingabe war unvollstaendig - die Seite ist dieselbe, die Antwort ist es nicht.
|
||||
$antwort->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
|
||||
return $response;
|
||||
return $antwort;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Ops\Installation;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Six screens from an empty webspace to a website, without a console.
|
||||
*
|
||||
* Every screen answers only while the installation is unfinished, and every one of them writes its
|
||||
* result straight away, so a reload continues where it stopped instead of starting over. The order
|
||||
* is fixed and enforced here, not only suggested by links: a step whose requirement is missing
|
||||
* sends you back to the step that produces it.
|
||||
*
|
||||
* After the first account exists, every route in this file answers 404 - not because a file was
|
||||
* deleted, but because the database says there is an installation.
|
||||
*/
|
||||
final class InstallController extends AbstractController
|
||||
{
|
||||
public function __construct(private Installation $installation)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/install', name: 'install', methods: ['GET'], priority: 10)]
|
||||
public function check(): Response
|
||||
{
|
||||
$this->guard();
|
||||
$checks = $this->installation->checks();
|
||||
|
||||
return $this->render('install/check.html.twig', [
|
||||
'step' => 1,
|
||||
'checks' => $checks,
|
||||
'ready' => $this->installation->ready($checks),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/install/datenbank', name: 'install_database', methods: ['GET', 'POST'], priority: 10)]
|
||||
public function database(Request $request): Response
|
||||
{
|
||||
$this->guard();
|
||||
$error = null;
|
||||
$driver = $request->request->getString('driver', 'sqlite');
|
||||
if ($request->isMethod('POST') && $this->signed($request, 'datenbank')) {
|
||||
$input = [
|
||||
'host' => $request->request->getString('host', '127.0.0.1'),
|
||||
'port' => $request->request->getString('port', '3306'),
|
||||
'name' => $request->request->getString('name', 'cms'),
|
||||
'user' => $request->request->getString('user'),
|
||||
'password' => $request->request->getString('password'),
|
||||
];
|
||||
$result = $this->installation->testDatabase('mysql' === $driver ? 'mysql' : 'sqlite', $input);
|
||||
$error = $result['error'];
|
||||
if (null === $error) {
|
||||
try {
|
||||
$this->installation->writeEnvironment(['APP_ENV' => 'prod', 'APP_DEBUG' => '0', 'DATABASE_URL' => $result['url']]);
|
||||
|
||||
// The new connection only exists in the next request, because the environment
|
||||
// file is read while booting. That is also why this is a redirect and not a
|
||||
// rendered page: the migration screen has to talk to the new database.
|
||||
return $this->redirectToRoute('install_migration');
|
||||
} catch (\RuntimeException $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('install/database.html.twig', [
|
||||
'step' => 2,
|
||||
'driver' => $driver,
|
||||
'error' => $error,
|
||||
'values' => $request->request->all(),
|
||||
'mysql' => \extension_loaded('pdo_mysql'),
|
||||
// SQLite nur, wenn die Fassung die Tabellenfunktionen kennt, mit denen Doctrine das
|
||||
// Schema liest - sonst scheitert erst der naechste Schritt, und zwar unverstaendlich.
|
||||
'sqlite' => \extension_loaded('pdo_sqlite') && null !== $this->installation->sqliteVersion()
|
||||
&& version_compare((string) $this->installation->sqliteVersion(), Installation::MIN_SQLITE, '>='),
|
||||
'sqliteVersion' => $this->installation->sqliteVersion(),
|
||||
'minSqlite' => Installation::MIN_SQLITE,
|
||||
...$this->token('datenbank'),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/install/migration', name: 'install_migration', methods: ['GET', 'POST'], priority: 10)]
|
||||
public function migration(Request $request): Response
|
||||
{
|
||||
$this->guard();
|
||||
if (!$this->installation->environmentWritten()) {
|
||||
return $this->redirectToRoute('install_database');
|
||||
}
|
||||
$error = null;
|
||||
$applied = null;
|
||||
if ($request->isMethod('POST') && $this->signed($request, 'migration')) {
|
||||
@set_time_limit(0);
|
||||
try {
|
||||
$applied = $this->installation->migrate();
|
||||
|
||||
return $this->redirectToRoute('install_website');
|
||||
} catch (\Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('install/migration.html.twig', [
|
||||
'step' => 3,
|
||||
'pending' => $error ? [] : $this->pending(),
|
||||
'applied' => $applied,
|
||||
'error' => $error,
|
||||
...$this->token('migration'),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/install/website', name: 'install_website', methods: ['GET', 'POST'], priority: 10)]
|
||||
public function website(Request $request): Response
|
||||
{
|
||||
$this->guard();
|
||||
if (!$this->installation->schemaReady()) {
|
||||
return $this->redirectToRoute('install_migration');
|
||||
}
|
||||
$error = null;
|
||||
if ($request->isMethod('POST') && $this->signed($request, 'website')) {
|
||||
try {
|
||||
$this->installation->createSite(
|
||||
$request->request->getString('name'),
|
||||
$request->request->getString('origin'),
|
||||
$request->request->getString('locale', 'de'),
|
||||
);
|
||||
|
||||
return $this->redirectToRoute('install_account');
|
||||
} catch (InvalidPage|\RuntimeException $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('install/website.html.twig', [
|
||||
'step' => 4,
|
||||
'error' => $error,
|
||||
'values' => $request->request->all(),
|
||||
// The address the browser is on is almost always the right one, so it is prefilled.
|
||||
'origin' => $request->getSchemeAndHttpHost(),
|
||||
...$this->token('website'),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/install/konto', name: 'install_account', methods: ['GET', 'POST'], priority: 10)]
|
||||
public function account(Request $request): Response
|
||||
{
|
||||
$this->guard();
|
||||
if (!$this->installation->siteReady()) {
|
||||
return $this->redirectToRoute('install_website');
|
||||
}
|
||||
$error = null;
|
||||
if ($request->isMethod('POST') && $this->signed($request, 'konto')) {
|
||||
try {
|
||||
$this->installation->createAdmin(
|
||||
$request->request->getString('username'),
|
||||
$request->request->getString('password'),
|
||||
$request->request->getString('repeat'),
|
||||
);
|
||||
|
||||
// The last screen is rendered here, not after a redirect: one request later this
|
||||
// route is gone, because the account it just created ends the installation.
|
||||
return $this->render('install/done.html.twig', [
|
||||
'step' => 6,
|
||||
'username' => strtolower(trim($request->request->getString('username'))),
|
||||
]);
|
||||
} catch (InvalidPage $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('install/account.html.twig', [
|
||||
'step' => 5,
|
||||
'error' => $error,
|
||||
'username' => $request->request->getString('username'),
|
||||
...$this->token('konto'),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Reading the pending list must never be the reason the screen cannot be shown. */
|
||||
private function pending(): array
|
||||
{
|
||||
try {
|
||||
return $this->installation->pendingMigrations();
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function guard(): void
|
||||
{
|
||||
if ($this->installation->isInstalled()) {
|
||||
throw new NotFoundHttpException('Diese Installation ist abgeschlossen.');
|
||||
}
|
||||
}
|
||||
|
||||
private function token(string $step): array
|
||||
{
|
||||
$issued = time();
|
||||
|
||||
return ['issued' => $issued, 'signature' => $this->installation->sign($step, $issued)];
|
||||
}
|
||||
|
||||
private function signed(Request $request, string $step): bool
|
||||
{
|
||||
return $this->installation->verify($step, $request->request->getInt('issued'), $request->request->getString('signature'));
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ final class MediaController extends AbstractController
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/media', name: 'admin_media', methods: ['GET'])]
|
||||
#[Route('/%kant.admin_path%/media', name: 'admin_media', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
// The screen renders the images itself, page by page. The dialog stays what it is: the way
|
||||
@@ -31,7 +31,7 @@ final class MediaController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/media/upload', name: 'admin_media_upload', methods: ['POST'])]
|
||||
#[Route('/%kant.admin_path%/media/upload', name: 'admin_media_upload', methods: ['POST'])]
|
||||
public function formUpload(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
@@ -57,7 +57,7 @@ final class MediaController extends AbstractController
|
||||
return $this->back($request, sprintf('%d Bild(er) hochgeladen.', $done), 'success');
|
||||
}
|
||||
|
||||
#[Route('/admin/media/{id}', name: 'admin_media_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
#[Route('/%kant.admin_path%/media/{id}', name: 'admin_media_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function formUpdate(Request $request, string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
@@ -95,7 +95,7 @@ final class MediaController extends AbstractController
|
||||
return $this->redirectToRoute('admin_media', $query);
|
||||
}
|
||||
|
||||
#[Route('/admin/api/media', methods: ['GET'])]
|
||||
#[Route('/%kant.admin_path%/api/media', methods: ['GET'])]
|
||||
public function list(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
@@ -103,8 +103,8 @@ final class MediaController extends AbstractController
|
||||
return $this->json(['items' => $this->media->list(), 'csrfToken' => $this->csrf->getToken('media')->getValue()]);
|
||||
}
|
||||
|
||||
#[Route('/admin/api/media/upload', methods: ['POST'])]
|
||||
#[Route('/admin/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
#[Route('/%kant.admin_path%/api/media/upload', methods: ['POST'])]
|
||||
#[Route('/%kant.admin_path%/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function write(Request $request, ?string $id = null): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
@@ -141,6 +141,22 @@ final class MediaController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Symbol der Website, in den beiden Größen, die Browser abfragen.
|
||||
*
|
||||
* Ohne eingestelltes Bild gibt es die Dateien nicht - dann bleibt es beim mitgelieferten
|
||||
* favicon.svg, auf das die Seiten weiterhin verweisen.
|
||||
*/
|
||||
#[Route('/favicon.png', name: 'favicon_png', methods: ['GET'])]
|
||||
#[Route('/apple-touch-icon.png', name: 'favicon_touch', methods: ['GET'])]
|
||||
public function favicon(Request $request): Response
|
||||
{
|
||||
$size = 'favicon_touch' === $request->attributes->get('_route') ? 180 : 32;
|
||||
$path = $this->media->faviconFile($size) ?? throw $this->createNotFoundException();
|
||||
|
||||
return new BinaryFileResponse($path, headers: ['Content-Type' => 'image/png', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=604800', 'Content-Security-Policy' => "default-src 'none'"]);
|
||||
}
|
||||
|
||||
#[Route('/media/{id}/{width}', requirements: ['id' => '[a-f0-9]{32}', 'width' => '\d{1,4}'], methods: ['GET'])]
|
||||
public function serve(string $id, int $width): Response
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ final class MenuController extends AbstractController
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/menus', name: 'admin_menus', methods: ['GET'])]
|
||||
#[Route('/%kant.admin_path%/menus', name: 'admin_menus', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$menus = $this->menus->all();
|
||||
@@ -39,7 +39,7 @@ final class MenuController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/menus/{slot}', name: 'admin_menu_save', methods: ['POST'])]
|
||||
#[Route('/%kant.admin_path%/menus/{slot}', name: 'admin_menu_save', methods: ['POST'])]
|
||||
public function save(Request $request, string $slot): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
|
||||
@@ -4,15 +4,19 @@ namespace App\Controller;
|
||||
|
||||
use App\Content\Consent;
|
||||
use App\Content\CustomCode;
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\PostRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\StyleBundle;
|
||||
use App\Content\StyleCompiler;
|
||||
use App\Content\Theme;
|
||||
use App\Content\Turnstile;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -20,7 +24,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class PageController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private string $secret, private string $siteOrigin)
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -35,23 +39,32 @@ final class PageController extends AbstractController
|
||||
$home = $this->settings->homePath();
|
||||
$path = '' === $path ? $home : $path;
|
||||
// One canonical address for the root page: its own path redirects to "/".
|
||||
if ($path === $home && '/' !== $request->getPathInfo()) {
|
||||
// Ausgenommen der Weg aus dem Formularversand: der zeigt dieselbe Seite mit Meldungen,
|
||||
// und eine Umleitung wuerde genau die Meldungen verwerfen.
|
||||
if ($path === $home && '/' !== $request->getPathInfo() && null === $request->attributes->get('kant_form_state')) {
|
||||
return $this->redirectToRoute('home', status: 301);
|
||||
}
|
||||
$page = $this->pages->findPublished($path);
|
||||
$styles = '';
|
||||
$googleFonts = null;
|
||||
$localFonts = [];
|
||||
// The page decides which header and footer it gets - including none.
|
||||
$areas = $this->areas($page);
|
||||
if (null !== $page) {
|
||||
$theme = $this->theme->load();
|
||||
// Theme first, block styles after, so a block always wins over the site defaults.
|
||||
$sections = $this->sections($page['document'], $areas);
|
||||
$styles = $this->theme->css($theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
|
||||
$styles = $this->theme->css($theme).$this->scrollTopStyles($this->settings->load(), $theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
|
||||
foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
|
||||
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
|
||||
}
|
||||
$googleFonts = $this->theme->googleFontsUrl($theme);
|
||||
// Liegen die Schriften auf dem eigenen Server, wird nichts von Google geholt - weder
|
||||
// die Datei noch die IP-Adresse des Besuchers.
|
||||
if ($this->fonts->hasLocal($theme)) {
|
||||
$localFonts = $this->fonts->preload($theme);
|
||||
} else {
|
||||
$googleFonts = $this->theme->googleFontsUrl($theme);
|
||||
}
|
||||
}
|
||||
if (null === $page) {
|
||||
// An address that moved or was deliberately removed answers before the 404 does.
|
||||
@@ -65,26 +78,57 @@ final class PageController extends AbstractController
|
||||
'page' => $page,
|
||||
'canonical' => rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $home),
|
||||
'navigation' => $this->navigation($path),
|
||||
'postLists' => $this->postLists($page['document']),
|
||||
'postLists' => $this->postLists($page['document'], max(1, $request->query->getInt('seite', 1))),
|
||||
// Ohne Beiträge gibt es keinen Feed, auf den der Kopf verweisen könnte.
|
||||
'hasPosts' => $this->posts->anyPublished(),
|
||||
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null,
|
||||
// Die Nachbarn kosten eine Abfrage; sie fällt nur an, wenn die Seite den
|
||||
// Baustein wirklich enthält.
|
||||
'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id']) : [],
|
||||
'needsTabs' => $this->hasType($page['document'], 'tabs'),
|
||||
// Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der
|
||||
// dafür ein Skript braucht - nach demselben Muster wie die Tabs.
|
||||
'needsSlider' => $this->hasAutoplaySlider($page['document']),
|
||||
// Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die
|
||||
// grosse Fassung. Das Skript kommt nur dazu, wo es etwas zu tun hat.
|
||||
'needsZoom' => $this->hasZoom($page['document']) || (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasZoom($area)),
|
||||
// Own scripts arrive as one file per page, never inline, so the policy can stay
|
||||
// hash based and no page without a script loses its strict setting.
|
||||
'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js',
|
||||
'forms' => $this->formStates($page, $request->query->getString('gesendet')),
|
||||
'forms' => $this->formStates($page, $request->query->getString('gesendet'), $request),
|
||||
'breadcrumbs' => $this->breadcrumbs($page),
|
||||
'socialImage' => $this->socialImage($page),
|
||||
'structuredData' => $this->structuredData($page),
|
||||
'areas' => $areas,
|
||||
'sections' => $sections,
|
||||
// Rendered before the page content, so the header decides first.
|
||||
'eagerImage' => $this->firstImage($areas['header'] ?? [], $page['document']),
|
||||
// Das grosse Bild ueber der Falz - es soll zuerst geladen werden. Gesucht wird nur
|
||||
// im ersten Abschnitt des Inhalts: ein Bild weiter unten waere die falsche
|
||||
// Eile, und das Logo im Kopfbereich ist klein und laedt ohnehin sofort.
|
||||
'eagerImage' => $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]),
|
||||
// Und das Gegenstueck fuer den haeufigeren Fall: Das grosse Bild ueber der Falz
|
||||
// ist gar kein Bild, sondern der Hintergrund des ersten Abschnitts.
|
||||
'heroBackground' => $this->heroBackground($areas['header'] ?? null, $page['document']),
|
||||
'pageStyles' => $styles,
|
||||
'googleFonts' => $googleFonts,
|
||||
'localFonts' => $localFonts,
|
||||
'styleBundle' => $this->bundle->url([] !== $localFonts),
|
||||
// Steht die Rechtszeile als Baustein im Dokument oder in einem Bereich, entfaellt
|
||||
// die eingebaute Leiste am Seitenende - zweimal dasselbe waere schlechter als gar
|
||||
// nichts, und die Pflicht ist mit dem Baustein erfuellt.
|
||||
'hasLegalBlock' => $this->hasType($page['document'], 'legal')
|
||||
|| (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasType($area, 'legal')),
|
||||
]);
|
||||
}
|
||||
|
||||
$wantsScript = null !== $page && ($this->hasType($page['document'], 'tabs') || [] !== $this->code->scriptsOf($page['document']));
|
||||
// Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur
|
||||
// dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website.
|
||||
$mitTurnstile = null !== $page && $this->turnstile->isActive() && $this->hasType($page['document'], 'form');
|
||||
$wantsScript = null !== $page && ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $this->hasAutoplaySlider($page['document']) || $this->hasZoom($page['document']) || [] !== $this->code->scriptsOf($page['document']));
|
||||
$frames = null === $page ? [] : $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
|
||||
if ($mitTurnstile) {
|
||||
$frames[] = 'challenges.cloudflare.com';
|
||||
}
|
||||
|
||||
return $this->shell($response, $wantsScript, $styles, null !== $googleFonts, $frames);
|
||||
}
|
||||
@@ -117,9 +161,19 @@ final class PageController extends AbstractController
|
||||
$response->headers->set('Cache-Control', 'no-store');
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
// Diese Website braucht weder Kamera noch Mikrofon, Standort oder Bewegungssensoren. Das
|
||||
// hier zu sagen kostet nichts und nimmt einem eingeschleusten Skript - oder einer
|
||||
// eingebetteten fremden Seite - die Möglichkeit, danach zu fragen.
|
||||
$response->headers->set('Permissions-Policy', 'accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()');
|
||||
// Steht ein Rahmen von Cloudflare offen, dann gehoert dorthin auch das Skript, das ihn
|
||||
// oeffnet - sonst laedt die Pruefung nicht und niemand kann das Formular absenden.
|
||||
$scriptSources = $allowScript ? ["'self'"] : ["'none'"];
|
||||
if (in_array('challenges.cloudflare.com', $frameHosts, true)) {
|
||||
$scriptSources = ["'self'", Turnstile::HOST];
|
||||
}
|
||||
$response->headers->set('Content-Security-Policy', sprintf(
|
||||
"default-src 'self'; script-src %s; style-src %s; font-src %s; img-src 'self' https: data:; frame-src %s; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
||||
$allowScript ? "'self'" : "'none'",
|
||||
implode(' ', $scriptSources),
|
||||
implode(' ', $styleSources),
|
||||
implode(' ', $fontSources),
|
||||
[] === $frameHosts ? "'none'" : implode(' ', array_map(static fn (string $host): string => 'https://'.$host, $frameHosts)),
|
||||
@@ -134,10 +188,20 @@ final class PageController extends AbstractController
|
||||
*
|
||||
* @return array<string, array>
|
||||
*/
|
||||
private function formStates(array $page, string $sentSlug): array
|
||||
/**
|
||||
* Der Zustand jedes Formulars auf dieser Seite.
|
||||
*
|
||||
* Kommt die Anfrage aus dem Formularversand zurueck, weil eine Eingabe fehlte, dann stehen die
|
||||
* Meldungen und die eingetippten Werte schon fest und werden hier uebernommen. Genau deshalb
|
||||
* laeuft dieser Fall durch dieselbe Stelle wie jeder andere Seitenaufruf: vorher baute der
|
||||
* Formularcode die Seite selbst noch einmal auf - ohne Theme, ohne Schriften, ohne
|
||||
* Stylesheet-Buendel. Wer ein Pflichtfeld vergass, bekam eine Seite ohne Gestaltung zu sehen.
|
||||
*/
|
||||
private function formStates(array $page, string $sentSlug, ?Request $request = null): array
|
||||
{
|
||||
$issued = time();
|
||||
$states = [];
|
||||
$uebergeben = is_array($request?->attributes->get('kant_form_state')) ? $request->attributes->get('kant_form_state') : [];
|
||||
$walk = function (array $blocks) use (&$walk, &$states, $page, $sentSlug, $issued): void {
|
||||
foreach ($blocks as $block) {
|
||||
if ('form' === ($block['type'] ?? null)) {
|
||||
@@ -149,12 +213,19 @@ final class PageController extends AbstractController
|
||||
'blockId' => $block['id'], 'page' => $page,
|
||||
'issued' => $issued,
|
||||
'issuedSignature' => hash_hmac('sha256', (string) $issued, $this->secret),
|
||||
// Leer, solange die Pruefung aus ist - dann steht auch kein Widget da.
|
||||
'turnstileKey' => $this->turnstile->isActive() ? $this->turnstile->siteKey() : '',
|
||||
];
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($page['document']['blocks'] ?? []);
|
||||
foreach ($uebergeben as $blockId => $zustand) {
|
||||
if (isset($states[$blockId])) {
|
||||
$states[$blockId] = [...$states[$blockId], ...$zustand];
|
||||
}
|
||||
}
|
||||
|
||||
return $states;
|
||||
}
|
||||
@@ -189,6 +260,76 @@ final class PageController extends AbstractController
|
||||
*
|
||||
* @return list<array>
|
||||
*/
|
||||
/**
|
||||
* Die Gestaltungswerte des Knopfes „nach oben", als Variablen für das Stylesheet.
|
||||
*
|
||||
* Sie stehen in den Einstellungen und nicht im Theme, weil sie zur Website gehören und nicht
|
||||
* zum Dokument - aber sie müssen in denselben Stilblock, dessen Prüfsumme in der
|
||||
* Sicherheitsrichtlinie steht. Ein zweiter Block wäre ein zweiter Hash.
|
||||
*/
|
||||
private function scrollTopStyles(array $settings, array $theme): string
|
||||
{
|
||||
if (!($settings['scrollTop'] ?? false)) {
|
||||
return '';
|
||||
}
|
||||
$farbe = static function (string $wert) use ($theme): ?string {
|
||||
if ('' === $wert) {
|
||||
return null;
|
||||
}
|
||||
if (!str_starts_with($wert, '@')) {
|
||||
return $wert;
|
||||
}
|
||||
foreach ($theme['colors'] as $eintrag) {
|
||||
if ($eintrag['key'] === substr($wert, 1)) {
|
||||
return sprintf('var(--k-%s)', $eintrag['key']);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
$werte = [sprintf('--kant-top-size:%dpx', (int) ($settings['scrollTopSize'] ?? 48))];
|
||||
$werte[] = sprintf('--kant-top-radius:%s', match ($settings['scrollTopShape'] ?? 'rund') {
|
||||
'eckig' => '0',
|
||||
'weich' => '12px',
|
||||
default => '999px',
|
||||
});
|
||||
$werte[] = sprintf('--kant-top-after:%dpx', (int) ($settings['scrollTopAfter'] ?? 400));
|
||||
foreach (['scrollTopColor' => '--kant-top-bg', 'scrollTopInk' => '--kant-top-ink'] as $feld => $variable) {
|
||||
$wert = $farbe((string) ($settings[$feld] ?? ''));
|
||||
if (null !== $wert) {
|
||||
$werte[] = $variable.':'.$wert;
|
||||
}
|
||||
}
|
||||
|
||||
return ':root{'.implode(';', $werte).'}';
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Bild, das beim Teilen erscheint - und im strukturierten Datensatz.
|
||||
*
|
||||
* Zuerst das eigens gesetzte Vorschaubild, sonst bei einem Beitrag sein Beitragsbild. Dieses
|
||||
* Feld gab es schon, ausgegeben wurde es nie: eine Einstellung, die speichert und nichts tut.
|
||||
* Die Adresse muss absolut sein, weil sie in fremden Diensten landet, und die Variante nicht
|
||||
* die größte - 1600 px reichen jedem Vorschaubild, 2400 kostet nur Ladezeit beim Teilen.
|
||||
*
|
||||
* @return array{url: string, alt: string}|null
|
||||
*/
|
||||
private function socialImage(array $page): ?array
|
||||
{
|
||||
$id = (string) ($page['og_media_id'] ?? '') ?: (string) ($page['featured_media_id'] ?? '');
|
||||
if ('' === $id) {
|
||||
return null;
|
||||
}
|
||||
$item = $this->media->find($id);
|
||||
if (null === $item || [] === $item['variants']) {
|
||||
return null;
|
||||
}
|
||||
$passend = array_filter($item['variants'], static fn (array $variant): bool => $variant['width'] <= 1600);
|
||||
$variant = end($passend) ?: $item['variants'][0];
|
||||
|
||||
return ['url' => rtrim($this->siteOrigin, '/').$variant['src'], 'alt' => (string) ($item['alt'] ?? '')];
|
||||
}
|
||||
|
||||
private function structuredData(array $page): array
|
||||
{
|
||||
$origin = rtrim($this->siteOrigin, '/');
|
||||
@@ -214,6 +355,10 @@ final class PageController extends AbstractController
|
||||
if (null !== ($page['author'] ?? null) && '' !== $page['author']) {
|
||||
$article['author'] = ['@type' => 'Person', 'name' => $page['author']];
|
||||
}
|
||||
$bild = $this->socialImage($page);
|
||||
if (null !== $bild) {
|
||||
$article['image'] = $bild['url'];
|
||||
}
|
||||
$blocks[] = $article;
|
||||
}
|
||||
$trail = $this->breadcrumbs($page);
|
||||
@@ -233,6 +378,42 @@ final class PageController extends AbstractController
|
||||
}
|
||||
|
||||
/** True when the stored document contains at least one block of that type. */
|
||||
/** Enthaelt das Dokument ein Bild oder eine Galerie, die sich vergroessern laesst? */
|
||||
private function hasZoom(array $document): bool
|
||||
{
|
||||
$gefunden = false;
|
||||
$walk = function (array $blocks) use (&$walk, &$gefunden): void {
|
||||
foreach ($blocks as $block) {
|
||||
if ('vergroessern' === ($block['props']['click'] ?? null) || true === ($block['props']['lightbox'] ?? null)) {
|
||||
$gefunden = true;
|
||||
|
||||
return;
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($document['blocks'] ?? []);
|
||||
|
||||
return $gefunden;
|
||||
}
|
||||
|
||||
/** True when a slider on this page asked to advance by itself. */
|
||||
private function hasAutoplaySlider(array $document): bool
|
||||
{
|
||||
$found = false;
|
||||
$walk = function (array $blocks) use (&$walk, &$found): void {
|
||||
foreach ($blocks as $block) {
|
||||
if ('slider' === ($block['type'] ?? null) && ($block['props']['autoplay'] ?? 0) >= 3) {
|
||||
$found = true;
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($document['blocks'] ?? []);
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
private function hasType(array $document, string $type): bool
|
||||
{
|
||||
$found = false;
|
||||
@@ -262,6 +443,11 @@ final class PageController extends AbstractController
|
||||
}
|
||||
$page = max(1, $request->query->getInt('seite', 1));
|
||||
$result = $this->posts->published($page, $term['id'] ?? null);
|
||||
// Ein Archiv ohne Beitrag ist kein Inhalt, sondern eine Baustelle mit eigener Adresse -
|
||||
// egal ob die Website noch gar keinen Blog hat oder ob diese eine Kategorie leer ist.
|
||||
if (0 === $result['total']) {
|
||||
return $this->shell($this->render('not-found.html.twig', ['navigation' => $this->navigation('')], new Response(status: 404)));
|
||||
}
|
||||
$base = null === $term ? '/beitraege' : '/'.PostRepository::ARCHIVE_PREFIX[$kind].'/'.$term['slug'];
|
||||
|
||||
$frames = $this->frameHosts($request, ...array_values($this->areas()));
|
||||
@@ -281,6 +467,9 @@ final class PageController extends AbstractController
|
||||
#[Route('/feed.xml', name: 'feed', methods: ['GET'])]
|
||||
public function feed(): Response
|
||||
{
|
||||
if (!$this->posts->anyPublished()) {
|
||||
return $this->shell($this->render('not-found.html.twig', ['navigation' => $this->navigation('')], new Response(status: 404)));
|
||||
}
|
||||
$result = $this->posts->published(1, null, 20);
|
||||
$response = $this->shell($this->render('feed.xml.twig', [
|
||||
'posts' => $result['posts'],
|
||||
@@ -297,10 +486,10 @@ final class PageController extends AbstractController
|
||||
*
|
||||
* @return array<string, list<array>>
|
||||
*/
|
||||
private function postLists(array $document): array
|
||||
private function postLists(array $document, int $page = 1): array
|
||||
{
|
||||
$lists = [];
|
||||
$walk = function (array $blocks) use (&$walk, &$lists): void {
|
||||
$walk = function (array $blocks) use (&$walk, &$lists, $page): void {
|
||||
foreach ($blocks as $block) {
|
||||
if ('postlist' === ($block['type'] ?? null)) {
|
||||
$props = $block['props'] ?? [];
|
||||
@@ -310,7 +499,12 @@ final class PageController extends AbstractController
|
||||
$term = $this->posts->findTerm($kind, $props[$kind]);
|
||||
}
|
||||
}
|
||||
$lists[$block['id']] = $this->posts->published(1, $term['id'] ?? null, $props['count'])['posts'];
|
||||
// Eine blätternde Liste nimmt die Seite aus der Anfrage, eine Auswahl
|
||||
// immer die erste - sonst würden zwei Listen auf einer Seite einander
|
||||
// umblättern.
|
||||
$paginate = (bool) ($props['paginate'] ?? false);
|
||||
$result = $this->posts->published($paginate ? $page : 1, $term['id'] ?? null, $props['count']);
|
||||
$lists[$block['id']] = $paginate ? $result : $result['posts'];
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
@@ -348,6 +542,49 @@ final class PageController extends AbstractController
|
||||
* That one must not be lazy: it is usually the largest thing above the fold, and deferring it
|
||||
* delays exactly the pixel a visitor waits for. Everything below it stays lazy.
|
||||
*/
|
||||
/**
|
||||
* Das Hintergrundbild des ersten Abschnitts, um es vorab zu holen.
|
||||
*
|
||||
* Gemessen an der Startseite des Piloten: Das groesste Element ueber der Falz ist dort kein
|
||||
* `<img>`, sondern der Hintergrund des Heros. Ein Hintergrundbild steht im Stylesheet, und
|
||||
* das kommt nach dem HTML - der Browser erfaehrt also erst vom Bild, wenn er das CSS geparst
|
||||
* hat. Lighthouse nennt das „LCP request discovery"; gemessen 1,3 s statt 0,9 s.
|
||||
*
|
||||
* Vorab geholt wird **genau die Variante, die das Stylesheet auch nimmt**: drei Verweise mit
|
||||
* denselben Grenzen wie der Compiler, nicht ein `imagesrcset`. Ein `srcset` waehlt nach
|
||||
* Breite, die Regeln waehlen nach Geraet; wo beide auseinanderlaufen, laedt der Browser zwei
|
||||
* Bilder statt einem - und dann kostet das Vorabholen mehr, als es bringt.
|
||||
*
|
||||
* @return list<array{src: string, media: string}>
|
||||
*/
|
||||
private function heroBackground(?array $header, array $document): array
|
||||
{
|
||||
// Steht ueber dem Inhalt ein Kopfbereich mit eigenem Hintergrundbild, ist dessen Bild das
|
||||
// obere. Sonst der erste Abschnitt des Dokuments.
|
||||
foreach ([$header, $document] as $kandidat) {
|
||||
$block = ($kandidat['blocks'] ?? [])[0] ?? null;
|
||||
$id = $block['style']['desktop']['backgroundImage'] ?? null;
|
||||
if (!is_string($id) || '' === $id) {
|
||||
continue;
|
||||
}
|
||||
$item = $this->media->find($id);
|
||||
if (null === $item || [] === ($item['variants'] ?? [])) {
|
||||
continue;
|
||||
}
|
||||
$verweise = [];
|
||||
foreach (StyleCompiler::PRELOAD_DEVICES as $device => $media) {
|
||||
$verweise[] = [
|
||||
'src' => StyleCompiler::variantFor($item['variants'], $device)['src'],
|
||||
'media' => $media,
|
||||
];
|
||||
}
|
||||
|
||||
return $verweise;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function firstImage(array ...$documents): ?string
|
||||
{
|
||||
foreach ($documents as $document) {
|
||||
|
||||
@@ -25,7 +25,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
* against areas without a second implementation. Areas have no draft and no publication: they are
|
||||
* shared, so a saved change is live. The editor hides the publish button for them.
|
||||
*/
|
||||
#[Route('/admin/partials')]
|
||||
#[Route('/%kant.admin_path%/partials')]
|
||||
final class PartialController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
|
||||
@@ -9,7 +9,7 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
|
||||
|
||||
final class SecurityController extends AbstractController
|
||||
{
|
||||
#[Route('/admin/login', name: 'admin_login', methods: ['GET', 'POST'])]
|
||||
#[Route('/%kant.admin_path%/login', name: 'admin_login', methods: ['GET', 'POST'])]
|
||||
public function login(AuthenticationUtils $auth): Response
|
||||
{
|
||||
if ($this->getUser()) {
|
||||
@@ -19,7 +19,7 @@ final class SecurityController extends AbstractController
|
||||
return $this->render('admin/login.html.twig', ['error' => $auth->getLastAuthenticationError(), 'username' => $auth->getLastUsername()]);
|
||||
}
|
||||
|
||||
#[Route('/admin/logout', name: 'admin_logout', methods: ['POST'])]
|
||||
#[Route('/%kant.admin_path%/logout', name: 'admin_logout', methods: ['POST'])]
|
||||
public function logout(): never
|
||||
{
|
||||
throw new \LogicException('Handled by Symfony Security.');
|
||||
|
||||
@@ -21,6 +21,7 @@ final class SeoController extends AbstractController
|
||||
private SiteSettings $settings,
|
||||
private PostRepository $posts,
|
||||
private string $siteOrigin,
|
||||
private string $adminPath,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -58,7 +59,15 @@ final class SeoController extends AbstractController
|
||||
public function robots(): Response
|
||||
{
|
||||
$settings = $this->settings->load();
|
||||
$lines = ['User-agent: *', 'Disallow: /admin', 'Disallow: /formular/'];
|
||||
// Die Verwaltung wird hier nur genannt, solange sie „admin" heisst - diese Adresse kennt
|
||||
// ohnehin jeder Scanner. Wer sie umbenannt hat, will sie nicht in einer oeffentlichen
|
||||
// Datei wiederfinden: robots.txt ist das erste, was ein Angreifer liest. Vom Indexieren
|
||||
// haelt die Verwaltung ihr eigener Kopf `X-Robots-Tag: noindex` ab.
|
||||
$lines = ['User-agent: *'];
|
||||
if ('admin' === $this->adminPath) {
|
||||
$lines[] = 'Disallow: /admin';
|
||||
}
|
||||
$lines[] = 'Disallow: /formular/';
|
||||
if ('' !== $settings['robotsExtra']) {
|
||||
$lines[] = $settings['robotsExtra'];
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ namespace App\Controller;
|
||||
|
||||
use App\Content\Consent;
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidPage;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -15,11 +17,11 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class SettingsController extends AbstractController
|
||||
{
|
||||
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages)
|
||||
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private MediaLibrary $media, private Theme $theme)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/settings', name: 'admin_settings', methods: ['GET'])]
|
||||
#[Route('/%kant.admin_path%/settings', name: 'admin_settings', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$choosable = [];
|
||||
@@ -38,10 +40,14 @@ final class SettingsController extends AbstractController
|
||||
'consentGroups' => Consent::GROUPS,
|
||||
'missingLegal' => $this->settings->missingLegalPages(),
|
||||
'consentGroupHelp' => Consent::GROUP_HELP,
|
||||
// Für das Symbol der Website: die Mediathek zur Auswahl, die neuesten zuerst.
|
||||
'images' => $this->media->page(1, false, 96)['items'],
|
||||
// Fuer die Farbfelder: dieselbe Palette, die auch der Editor anbietet.
|
||||
'palette' => $this->theme->load()['colors'],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/settings/company', name: 'admin_settings_save', methods: ['POST'])]
|
||||
#[Route('/%kant.admin_path%/settings/company', name: 'admin_settings_save', methods: ['POST'])]
|
||||
public function save(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
@@ -57,7 +63,7 @@ final class SettingsController extends AbstractController
|
||||
return $this->back('Die Unternehmensdaten wurden gespeichert.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/admin/settings/redirects', name: 'admin_redirect_save', methods: ['POST'])]
|
||||
#[Route('/%kant.admin_path%/settings/redirects', name: 'admin_redirect_save', methods: ['POST'])]
|
||||
public function saveRedirect(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
@@ -89,7 +95,7 @@ final class SettingsController extends AbstractController
|
||||
* The pages are mandatory, the text is not something a CMS can write: what gets created is the
|
||||
* structure with every required item named and marked as still to be filled in.
|
||||
*/
|
||||
#[Route('/admin/settings/rechtliches', name: 'admin_legal_pages', methods: ['POST'])]
|
||||
#[Route('/%kant.admin_path%/settings/rechtliches', name: 'admin_legal_pages', methods: ['POST'])]
|
||||
public function legalPages(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
|
||||
@@ -17,7 +17,7 @@ final class StatisticsController extends AbstractController
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/statistik', name: 'admin_statistics', methods: ['GET'])]
|
||||
#[Route('/%kant.admin_path%/statistik', name: 'admin_statistics', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
@@ -32,7 +32,7 @@ final class StatisticsController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/statistik.csv', name: 'admin_statistics_csv', methods: ['GET'])]
|
||||
#[Route('/%kant.admin_path%/statistik.csv', name: 'admin_statistics_csv', methods: ['GET'])]
|
||||
public function csv(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Backup;
|
||||
use App\Ops\Installation;
|
||||
use App\Ops\Updater;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -19,11 +21,11 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
*/
|
||||
final class SystemController extends AbstractController
|
||||
{
|
||||
public function __construct(private Updater $updater, private Backup $backup)
|
||||
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/system', name: 'admin_system', methods: ['GET'])]
|
||||
#[Route('/%kant.admin_path%/system', name: 'admin_system', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
@@ -38,10 +40,94 @@ final class SystemController extends AbstractController
|
||||
'asked' => $request->query->getBoolean('pruefen'),
|
||||
'steps' => $request->getSession()->remove('update_steps') ?: [],
|
||||
'backups' => $this->backup->all(),
|
||||
'adminPath' => $this->adminPath,
|
||||
// Der Weg ohne Git: das Releasearchiv. Auf geteiltem Hosting ist das der einzige.
|
||||
'archiveVersion' => $request->query->getBoolean('pruefen') ? $this->archive->updateAvailable() : null,
|
||||
'archivePending' => $this->archive->pending(),
|
||||
// Wer die Dateien von Hand hochgeladen hat - über FTP, ohne Git und ohne das
|
||||
// Releasearchiv -, hat danach neuen Code und eine alte Datenbank. Dieser Bildschirm
|
||||
// ist die einzige Stelle, an der das jemand merken kann.
|
||||
'pendingMigrations' => $this->pendingMigrations(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/system/update', name: 'admin_system_update', methods: ['POST'])]
|
||||
/**
|
||||
* Update über das Releasearchiv - für Installationen, die kein Git kennen.
|
||||
*
|
||||
* Zwei Schritte, und zwar mit Absicht: Der erste ersetzt die Dateien, der zweite führt die
|
||||
* Migrationen aus. Dazwischen liegt eine neue Anfrage, denn erst die startet mit dem Code der
|
||||
* neuen Fassung; ein Prozess, der sich selbst ausgetauscht hat, arbeitet mit dem alten weiter.
|
||||
*/
|
||||
/**
|
||||
* Ausstehende Migrationen ausführen - ohne Git, ohne Archiv, ohne Konsole.
|
||||
*
|
||||
* Der Weg für ein Update, das von Hand eingespielt wurde: Dateien liegen neu, die Datenbank
|
||||
* ist alt. Ohne diesen Knopf bräuchte es eine Konsole, und genau die gibt es auf geteiltem
|
||||
* Hosting nicht.
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/system/migrationen', name: 'admin_system_migrate', methods: ['POST'])]
|
||||
public function migrate(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
@set_time_limit(0);
|
||||
|
||||
try {
|
||||
$namen = $this->installation->migrate();
|
||||
$this->addFlash('success', [] === $namen
|
||||
? 'Es stand keine Migration an - die Datenbank war bereits auf dem Stand des Codes.'
|
||||
: sprintf('%d Migration(en) ausgeführt: %s.', count($namen), implode(', ', $namen)));
|
||||
} catch (\Throwable $fehler) {
|
||||
$this->addFlash('error', 'Die Migration ist fehlgeschlagen: '.$fehler->getMessage());
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_system');
|
||||
}
|
||||
|
||||
/** Das Lesen der Liste darf nie der Grund sein, dass dieser Bildschirm nicht erscheint. */
|
||||
private function pendingMigrations(): array
|
||||
{
|
||||
try {
|
||||
return $this->installation->pendingMigrations();
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/system/archiv', name: 'admin_system_archive', methods: ['POST'])]
|
||||
public function archive(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
@set_time_limit(0);
|
||||
if ($request->request->getBoolean('finish')) {
|
||||
$ergebnis = $this->archive->finish($this->installation);
|
||||
$request->getSession()->set('update_steps', $ergebnis['steps']);
|
||||
$this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok']
|
||||
? 'Das Update ist fertig. Die Website ist wieder erreichbar.'
|
||||
: 'Die Migrationen sind fehlgeschlagen. Die Website bleibt im Wartungszustand.');
|
||||
|
||||
return $this->redirectToRoute('admin_system');
|
||||
}
|
||||
$ergebnis = $this->archive->run($request->request->getString('target') ?: null);
|
||||
$request->getSession()->set('update_steps', $ergebnis['steps']);
|
||||
if ($ergebnis['migrate'] ?? false) {
|
||||
$this->addFlash('success', 'Die Dateien sind ersetzt. Jetzt noch die Migrationen - der Knopf steht oben.');
|
||||
|
||||
return $this->redirectToRoute('admin_system');
|
||||
}
|
||||
$this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok']
|
||||
? 'Es war nichts einzuspielen.'
|
||||
: 'Das Update ist nicht durchgelaufen. Die Schritte stehen unten.');
|
||||
|
||||
return $this->redirectToRoute('admin_system');
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/system/update', name: 'admin_system_update', methods: ['POST'])]
|
||||
public function update(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
@@ -69,7 +155,7 @@ final class SystemController extends AbstractController
|
||||
* `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'])]
|
||||
#[Route('/%kant.admin_path%/system/sicherung', name: 'admin_system_backup', methods: ['POST'])]
|
||||
public function backup(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
@@ -81,6 +167,12 @@ final class SystemController extends AbstractController
|
||||
$result = $this->backup->create('Verwaltung');
|
||||
} catch (InvalidPage $error) {
|
||||
return $this->back($error->getMessage());
|
||||
} catch (\Throwable $error) {
|
||||
// Alles andere ist kein erwarteter Fall, sondern einer, den dieses Hosting mitbringt -
|
||||
// eine alte SQLite-Fassung, ein fehlendes Schreibrecht, ein voller Datentraeger. Eine
|
||||
// 500-Seite laesst den Betreiber damit allein; der Satz hier sagt wenigstens, woran es
|
||||
// lag.
|
||||
return $this->back('Die Sicherung ist fehlgeschlagen: '.$error->getMessage());
|
||||
}
|
||||
$removed = $this->backup->rotate();
|
||||
|
||||
@@ -94,7 +186,7 @@ final class SystemController extends AbstractController
|
||||
), 'success');
|
||||
}
|
||||
|
||||
#[Route('/admin/system/wartung', name: 'admin_system_maintenance', methods: ['POST'])]
|
||||
#[Route('/%kant.admin_path%/system/wartung', name: 'admin_system_maintenance', methods: ['POST'])]
|
||||
public function maintenance(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\EventSubscriber\TaskWorkerSubscriber;
|
||||
use App\Ops\Scheduler;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* Der Bildschirm „Aufgaben“ und der Takt des geöffneten Backends.
|
||||
*
|
||||
* Zwei verschiedene Dinge in einer Datei, weil sie dasselbe Thema haben: hier sieht der Betreiber,
|
||||
* was der Plan vorhat und was zuletzt passiert ist – und hier klopft die geöffnete Verwaltung im
|
||||
* Minutentakt an, damit auf einer Website ohne Besucher trotzdem etwas läuft.
|
||||
*/
|
||||
final class TaskController extends AbstractController
|
||||
{
|
||||
public function __construct(private Scheduler $scheduler, private string $secret)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/aufgaben', name: 'admin_tasks', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
$state = $this->scheduler->fastState();
|
||||
|
||||
return $this->render('admin/tasks.html.twig', [
|
||||
'tasks' => $this->scheduler->overview(),
|
||||
'traffic' => $state['traffic'],
|
||||
'admin' => $state['admin'],
|
||||
'next' => $state['next'],
|
||||
'now' => gmdate('Y-m-d H:i:s'),
|
||||
'protocol' => $this->scheduler->protocol(30),
|
||||
// Der Schlüssel für einen URL-Cronjob. Er steht in keiner Datenbank: er wird aus dem
|
||||
// Anwendungsgeheimnis abgeleitet, ist damit auf jedem Server ein anderer und ändert
|
||||
// sich, wenn das Geheimnis gewechselt wird.
|
||||
'cronUrl' => $this->generateUrl('cms_cron', ['token' => Scheduler::cronToken($this->secret)], UrlGeneratorInterface::ABSOLUTE_URL),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/aufgaben/ausfuehren', name: 'admin_tasks_run', methods: ['POST'])]
|
||||
public function run(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('tasks', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
@set_time_limit(0);
|
||||
$result = $this->scheduler->runNow($request->request->getString('name'));
|
||||
if (null === $result) {
|
||||
return $this->back('Diese Aufgabe gibt es nicht oder sie läuft gerade schon.');
|
||||
}
|
||||
$this->addFlash('ok' === $result['status'] ? 'success' : 'error', sprintf(
|
||||
'%s: %s (%d ms)',
|
||||
$result['label'],
|
||||
$result['message'],
|
||||
$result['ms'],
|
||||
));
|
||||
|
||||
return $this->redirectToRoute('admin_tasks');
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstand, Ein/Aus je Aufgabe und die beiden Auslöser, die in Anfragen stecken.
|
||||
*
|
||||
* Ein Formular für alles, weil es eine Entscheidung ist: wie dieser Server seine Aufgaben
|
||||
* abarbeitet. Zwei getrennte Formulare hätten zwei Speicherknöpfe und dieselbe Wirkung.
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/aufgaben/einstellen', name: 'admin_tasks_configure', methods: ['POST'])]
|
||||
public function configure(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('tasks', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
$this->scheduler->setWorkers(
|
||||
$request->request->getBoolean('traffic'),
|
||||
$request->request->getBoolean('admin'),
|
||||
);
|
||||
$enabled = $request->request->all('enabled');
|
||||
foreach ($request->request->all('interval') as $name => $minutes) {
|
||||
$name = (string) $name;
|
||||
$this->scheduler->setInterval($name, max(1, (int) $minutes) * 60);
|
||||
$this->scheduler->enable($name, isset($enabled[$name]));
|
||||
}
|
||||
$this->addFlash('success', 'Der Plan ist gespeichert.');
|
||||
|
||||
return $this->redirectToRoute('admin_tasks');
|
||||
}
|
||||
|
||||
/** Eine Sperre, die stehen geblieben ist, weil das Hosting den Prozess mitten im Lauf beendet hat. */
|
||||
#[Route('/%kant.admin_path%/aufgaben/freigeben', name: 'admin_tasks_release', methods: ['POST'])]
|
||||
public function release(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('tasks', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
$this->scheduler->release($request->request->getString('name'));
|
||||
$this->addFlash('success', 'Die Sperre ist aufgehoben. Die Aufgabe darf wieder starten.');
|
||||
|
||||
return $this->redirectToRoute('admin_tasks');
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Takt der geöffneten Verwaltung.
|
||||
*
|
||||
* Damit läuft der Plan auch auf einer Website, die gerade niemand besucht – solange jemand im
|
||||
* Backend arbeitet. Das Skript fragt nur, während der Reiter sichtbar ist; ein im Hintergrund
|
||||
* vergessener Reiter soll keine Sitzung endlos offen halten.
|
||||
*
|
||||
* Die Grenzen sind hier großzügiger als beim Besuch: hier wartet niemand auf eine Seite.
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/aufgaben/takt', name: 'admin_tasks_tick', methods: ['POST'])]
|
||||
public function tick(Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('tasks-tick', $request->request->getString('_csrf_token'))) {
|
||||
return new JsonResponse(['error' => 'token'], Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
$request->attributes->set(TaskWorkerSubscriber::HANDLED, true);
|
||||
$state = $this->scheduler->fastState();
|
||||
if (!$state['admin']) {
|
||||
return new JsonResponse(['off' => true, 'ran' => []]);
|
||||
}
|
||||
@set_time_limit(120);
|
||||
$ran = $this->scheduler->runDue('verwaltung', 3, 45);
|
||||
|
||||
return new JsonResponse([
|
||||
'ran' => $ran,
|
||||
'next' => $this->scheduler->fastState()['next'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function back(string $error): Response
|
||||
{
|
||||
$this->addFlash('error', $error);
|
||||
|
||||
return $this->redirectToRoute('admin_tasks');
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidPage;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -11,21 +12,60 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class ThemeController extends AbstractController
|
||||
{
|
||||
public function __construct(private Theme $theme)
|
||||
public function __construct(private Theme $theme, private FontLibrary $fonts)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/theme', name: 'admin_theme', methods: ['GET'])]
|
||||
#[Route('/%kant.admin_path%/theme', name: 'admin_theme', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$theme = $this->theme->load();
|
||||
|
||||
return $this->render('admin/theme.html.twig', [
|
||||
'theme' => $this->theme->load(),
|
||||
'theme' => $theme,
|
||||
'fallbacks' => Theme::FALLBACKS,
|
||||
'weights' => Theme::WEIGHTS,
|
||||
'fontsLocal' => $this->fonts->hasLocal($theme),
|
||||
'fontsMissing' => $this->fonts->missing($theme),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/theme/save', name: 'admin_theme_save', methods: ['POST'])]
|
||||
/**
|
||||
* Holt die gewaehlten Google-Schriften auf den eigenen Server.
|
||||
*
|
||||
* Auf Zuruf, nicht bei einem Seitenaufruf: das dauert Sekunden und braucht eine Verbindung
|
||||
* nach aussen, die ein Besucher nie abwarten soll.
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/theme/fonts', name: 'admin_theme_fonts', methods: ['POST'])]
|
||||
public function fonts(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('theme', $request->request->getString('_csrf_token'))) {
|
||||
$this->addFlash('error', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
|
||||
return $this->redirectToRoute('admin_theme');
|
||||
}
|
||||
try {
|
||||
$result = $this->fonts->fetch($this->theme->load());
|
||||
$this->addFlash('success', sprintf(
|
||||
'%d Schriftdatei(en) geladen, %s insgesamt: %s. Die Website lädt jetzt nichts mehr von Google.',
|
||||
$result['files'],
|
||||
$this->bytes($result['bytes']),
|
||||
implode(', ', $result['families']),
|
||||
));
|
||||
} catch (\RuntimeException $error) {
|
||||
$this->addFlash('error', $error->getMessage());
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_theme');
|
||||
}
|
||||
|
||||
private function bytes(int $bytes): string
|
||||
{
|
||||
return $bytes < 1024 * 1024 ? round($bytes / 1024).' KB' : round($bytes / 1024 / 1024, 1).' MB';
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/theme/save', name: 'admin_theme_save', methods: ['POST'])]
|
||||
public function save(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
|
||||
@@ -22,7 +22,7 @@ final class UserController extends AbstractController
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/benutzer', name: 'admin_users', methods: ['GET'])]
|
||||
#[Route('/%kant.admin_path%/benutzer', name: 'admin_users', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
@@ -37,7 +37,7 @@ final class UserController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/benutzer/neu', name: 'admin_user_create', methods: ['POST'])]
|
||||
#[Route('/%kant.admin_path%/benutzer/neu', name: 'admin_user_create', methods: ['POST'])]
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
if (null !== $refusal = $this->guard($request)) {
|
||||
@@ -56,7 +56,7 @@ final class UserController extends AbstractController
|
||||
return $this->back('Das Konto wurde angelegt.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/admin/benutzer/{id}/rolle', name: 'admin_user_role', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['POST'])]
|
||||
#[Route('/%kant.admin_path%/benutzer/{id}/rolle', name: 'admin_user_role', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['POST'])]
|
||||
public function role(Request $request, string $id): Response
|
||||
{
|
||||
if (null !== $refusal = $this->guard($request)) {
|
||||
@@ -71,7 +71,7 @@ final class UserController extends AbstractController
|
||||
return $this->back('Die Rolle wurde geändert. Offene Sitzungen dieses Kontos sind damit abgemeldet.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/admin/benutzer/{id}/passwort', name: 'admin_user_password', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['POST'])]
|
||||
#[Route('/%kant.admin_path%/benutzer/{id}/passwort', name: 'admin_user_password', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['POST'])]
|
||||
public function password(Request $request, string $id): Response
|
||||
{
|
||||
if (null !== $refusal = $this->guard($request)) {
|
||||
@@ -86,7 +86,7 @@ final class UserController extends AbstractController
|
||||
return $this->back('Das Passwort wurde gesetzt. Offene Sitzungen dieses Kontos sind damit abgemeldet.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/admin/benutzer/{id}/loeschen', name: 'admin_user_delete', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['POST'])]
|
||||
#[Route('/%kant.admin_path%/benutzer/{id}/loeschen', name: 'admin_user_delete', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['POST'])]
|
||||
public function delete(Request $request, string $id): Response
|
||||
{
|
||||
if (null !== $refusal = $this->guard($request)) {
|
||||
@@ -101,7 +101,7 @@ final class UserController extends AbstractController
|
||||
return $this->back('Das Konto wurde gelöscht.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/admin/konto', name: 'admin_account', methods: ['GET'])]
|
||||
#[Route('/%kant.admin_path%/konto', name: 'admin_account', methods: ['GET'])]
|
||||
public function account(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
@@ -122,7 +122,7 @@ final class UserController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/konto/passwort', name: 'admin_account_password', methods: ['POST'])]
|
||||
#[Route('/%kant.admin_path%/konto/passwort', name: 'admin_account_password', methods: ['POST'])]
|
||||
public function ownPassword(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
|
||||
@@ -3,19 +3,46 @@
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
final class AdminHeadersSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private string $adminPath)
|
||||
{
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [KernelEvents::RESPONSE => ['onResponse', -300]];
|
||||
return [KernelEvents::REQUEST => ['onRequest', 300], KernelEvents::RESPONSE => ['onResponse', -300]];
|
||||
}
|
||||
|
||||
private function isAdmin(string $path): bool
|
||||
{
|
||||
return 1 === preg_match('#^/'.preg_quote($this->adminPath, '#').'(?:/|$)#', $path);
|
||||
}
|
||||
|
||||
/**
|
||||
* One value per administration page, for the single inline script there is: the import map.
|
||||
*
|
||||
* It has to be inline - the specification knows no external import maps - and it has to work
|
||||
* under `script-src 'self'`. Without it the map is dropped without a word, the modules load
|
||||
* without a version key, and a year-old `editor-model.js` out of the cache meets a new
|
||||
* `editor.js`. That is not a theory: it broke the editor after 1.5.0 went out.
|
||||
*/
|
||||
public function onRequest(RequestEvent $event): void
|
||||
{
|
||||
$request = $event->getRequest();
|
||||
if (!$event->isMainRequest() || !$this->isAdmin($request->getPathInfo())) {
|
||||
return;
|
||||
}
|
||||
$request->attributes->set('csp_nonce', rtrim(strtr(base64_encode(random_bytes(16)), '+/', '-_'), '='));
|
||||
}
|
||||
|
||||
public function onResponse(ResponseEvent $event): void
|
||||
{
|
||||
if (!preg_match('#^/admin(?:/|$)#', $event->getRequest()->getPathInfo())) {
|
||||
if (!$this->isAdmin($event->getRequest()->getPathInfo())) {
|
||||
return;
|
||||
}
|
||||
$headers = $event->getResponse()->headers;
|
||||
@@ -23,8 +50,13 @@ final class AdminHeadersSubscriber implements EventSubscriberInterface
|
||||
$headers->set('X-Robots-Tag', 'noindex, nofollow');
|
||||
$headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$headers->set('Referrer-Policy', 'same-origin');
|
||||
$headers->set('Permissions-Policy', 'accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()');
|
||||
// The preview must show the same typeface as the published page, so the font hosts are
|
||||
// allowed here as well. Everything else stays as narrow as on the public side.
|
||||
$headers->set('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||
// The nonce only ever adds the one script it names; 'self' keeps applying to everything
|
||||
// else, so nothing is loosened by it.
|
||||
$nonce = $event->getRequest()->attributes->get('csp_nonce');
|
||||
$script = is_string($nonce) && '' !== $nonce ? sprintf("'self' 'nonce-%s'", $nonce) : "'self'";
|
||||
$headers->set('Content-Security-Policy', "default-src 'self'; script-src {$script}; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Ops\Installation;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
/**
|
||||
* An installation that does not exist yet answers with the installer instead of an error.
|
||||
*
|
||||
* Without this, the first visit to a freshly unpacked website is a 500 from a missing table - the
|
||||
* one moment where a person needs a sentence, not a stack trace.
|
||||
*
|
||||
* The cost of knowing is kept at zero for a running website: once an account exists, a marker file
|
||||
* is written and no request asks the database again. The marker only ever silences this
|
||||
* subscriber; the installer itself always asks the database, so deleting the file does not reopen
|
||||
* anything.
|
||||
*/
|
||||
final class InstallSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public const MARKER = 'var/installed';
|
||||
|
||||
public function __construct(private Installation $installation, private string $projectDir)
|
||||
{
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
// After maintenance (40), before the router: an unfinished installation has no routes
|
||||
// worth matching except its own.
|
||||
return [KernelEvents::REQUEST => ['onRequest', 36]];
|
||||
}
|
||||
|
||||
public function onRequest(RequestEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest() || is_file($this->projectDir.'/'.self::MARKER)) {
|
||||
return;
|
||||
}
|
||||
if ($this->installation->isInstalled()) {
|
||||
@file_put_contents($this->projectDir.'/'.self::MARKER, gmdate('c')."\n");
|
||||
|
||||
return;
|
||||
}
|
||||
$path = $event->getRequest()->getPathInfo();
|
||||
if (str_starts_with($path, '/install') || preg_match('#^/(assets/|favicon)#', $path)) {
|
||||
return;
|
||||
}
|
||||
$event->setResponse(new RedirectResponse('/install'));
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ use Twig\Environment;
|
||||
*/
|
||||
final class MaintenanceSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private Environment $twig, private string $projectDir)
|
||||
public function __construct(private Environment $twig, private string $projectDir, private string $adminPath)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ final class MaintenanceSubscriber implements EventSubscriberInterface
|
||||
return;
|
||||
}
|
||||
$path = $event->getRequest()->getPathInfo();
|
||||
if (preg_match('#^/(admin|assets/|media/|favicon)#', $path)) {
|
||||
if (preg_match('#^/('.preg_quote($this->adminPath, '#').'|assets/|media/|favicon)#', $path)) {
|
||||
return;
|
||||
}
|
||||
$since = @file_get_contents($this->projectDir.'/'.Updater::MAINTENANCE_FILE);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Ops\Scheduler;
|
||||
use App\Ops\Updater;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\TerminateEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
/**
|
||||
* Der Auslöser, den ein Hosting ohne Cronjob hat: die Anfrage selbst.
|
||||
*
|
||||
* Nach `kernel.terminate`, also nachdem die Antwort das Haus verlassen hat. Unter PHP-FPM ruft
|
||||
* Symfony dafür `fastcgi_finish_request()` auf - der Browser ist fertig, während hier noch
|
||||
* gearbeitet wird. Unter Apache mit mod_php gibt es das nicht: die Seite steht dann zwar schon
|
||||
* im Browser, der Prozess bleibt aber belegt, bis die Aufgabe fertig ist. Deshalb sind die
|
||||
* Grenzen klein - zwei Aufgaben, zwanzig Sekunden -, und deshalb lässt sich dieser Auslöser
|
||||
* abschalten, wenn ein echter Cronjob die Arbeit übernimmt.
|
||||
*
|
||||
* Der Normalfall kostet nichts: eine Datei lesen, Zeitpunkt vergleichen, fertig. Erst wenn
|
||||
* wirklich etwas ansteht, wird die Datenbank gefragt.
|
||||
*/
|
||||
final class TaskWorkerSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
/** Anfragen, hinter denen kein Mensch und keine Seite steht. */
|
||||
private const IGNORED = '#^/(assets/|media/|favicon)#';
|
||||
|
||||
/** Ein Aufruf, der die Aufgaben schon selbst abgearbeitet hat, setzt dieses Attribut. */
|
||||
public const HANDLED = 'kant_tasks_handled';
|
||||
|
||||
public function __construct(
|
||||
private Scheduler $scheduler,
|
||||
private string $projectDir,
|
||||
private string $adminPath,
|
||||
private int $limit = 2,
|
||||
private int $budget = 20,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
// Nach dem Zähler der Statistik (Standardpriorität 0): der schreibt eine Zeile, das hier
|
||||
// kann Sekunden dauern.
|
||||
return [KernelEvents::TERMINATE => ['onTerminate', -64]];
|
||||
}
|
||||
|
||||
public function onTerminate(TerminateEvent $event): void
|
||||
{
|
||||
$request = $event->getRequest();
|
||||
if ($request->attributes->getBoolean(self::HANDLED)) {
|
||||
return;
|
||||
}
|
||||
$path = $request->getPathInfo();
|
||||
if (1 === preg_match(self::IGNORED, $path)) {
|
||||
return;
|
||||
}
|
||||
// Während eines Updates läuft nichts: die Dateien sind vielleicht schon die neuen und
|
||||
// die Datenbank noch die alte. Und vor der Installation gibt es keine Tabelle.
|
||||
if (is_file($this->projectDir.'/'.Updater::MAINTENANCE_FILE) || !is_file($this->projectDir.'/'.InstallSubscriber::MARKER)) {
|
||||
return;
|
||||
}
|
||||
$admin = str_starts_with($path, '/'.$this->adminPath);
|
||||
$state = $this->scheduler->fastState();
|
||||
if (!($admin ? $state['admin'] : $state['traffic'])) {
|
||||
return;
|
||||
}
|
||||
if (null !== $state['next'] && $state['next'] > time()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Die Antwort ist raus; ein abgebrochener Aufruf soll die Arbeit trotzdem zu Ende
|
||||
// bringen, sonst bliebe eine Aufgabe auf halbem Weg mit gesetzter Sperre liegen.
|
||||
@ignore_user_abort(true);
|
||||
@set_time_limit($this->budget + 60);
|
||||
$this->scheduler->runDue($admin ? 'verwaltung' : 'besuch', $this->limit, $this->budget);
|
||||
} catch (\Throwable) {
|
||||
// Eine Aufgabe darf niemals eine Website kaputtmachen. Was schiefging, steht in der
|
||||
// Zeile der Aufgabe und in var/tasks.log - hier ist niemand mehr, dem man es sagen
|
||||
// könnte: die Antwort ist längst verschickt.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops;
|
||||
|
||||
/**
|
||||
* Ein Update für Installationen ohne Git.
|
||||
*
|
||||
* Der Updater über Git setzt eine Arbeitskopie voraus. Auf geteiltem Hosting gibt es die nicht:
|
||||
* dort wird über das Releasearchiv installiert, und danach steht in der Verwaltung „Dieses
|
||||
* Verzeichnis ist keine Git-Arbeitskopie" - also kein Update, außer von Hand über FTP. Das ist
|
||||
* genau der Fall, für den dieses CMS gedacht ist.
|
||||
*
|
||||
* Hier läuft derselbe Weg wie bei der Erstinstallation: Archiv laden, Prüfsumme vergleichen,
|
||||
* entpacken, Dateien ersetzen, Migrationen ausführen. Mit drei Unterschieden, die ein Update von
|
||||
* einer Installation trennen:
|
||||
*
|
||||
* 1. **Was dem Betreiber gehört, wird nicht angefasst.** `var/`, `.env`, `.env.local`, die
|
||||
* hochgeladenen Schriften und das erzeugte Stylesheet-Bündel bleiben, wie sie sind.
|
||||
* 2. **Der alte Stand wird beiseitegelegt, nicht gelöscht.** Geht etwas schief, steht er unter
|
||||
* `var/update/` und lässt sich zurückschieben.
|
||||
* 3. **Die Website ist währenddessen im Wartungszustand** - eine halb ersetzte Anwendung darf
|
||||
* niemand zu sehen bekommen.
|
||||
*/
|
||||
final class ArchiveUpdate
|
||||
{
|
||||
/** Was aus dem Archiv übernommen wird. Alles andere im Projektstamm bleibt unberührt. */
|
||||
private const ERSETZEN = ['bin', 'config', 'migrations', 'src', 'templates', 'vendor', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION'];
|
||||
|
||||
/** Was innerhalb von public/ dem Betreiber gehört und deshalb stehen bleibt. */
|
||||
private const EIGENES_IN_PUBLIC = ['assets/fonts', 'assets/build'];
|
||||
|
||||
/**
|
||||
* Die Marke zwischen den beiden Schritten.
|
||||
*
|
||||
* Sie liegt auf der Platte und nicht in der Sitzung: Fällt die Sitzung zwischen „Dateien
|
||||
* ersetzt" und „Migrationen" aus - Zeitüberschreitung, anderer Browser, anderer Rechner -,
|
||||
* bliebe die Website sonst im Wartungszustand und der Knopf, der sie herausholt, wäre weg.
|
||||
*/
|
||||
public const MARKE = 'var/update/offen.json';
|
||||
|
||||
/** Das Protokoll - dieselbe Datei, in die auch der Updater über Git schreibt. */
|
||||
private const PROTOKOLL = 'var/updates.log';
|
||||
|
||||
public function __construct(
|
||||
private string $root,
|
||||
private Updater $updater,
|
||||
private Backup $backup,
|
||||
private Download $download,
|
||||
private string $releaseBase = 'https://git.kant-it.de/kantarci/KantSites/releases/download',
|
||||
private string $apiBase = 'https://git.kant-it.de/api/v1/repos/kantarci/KantSites',
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Die neueste veröffentlichte Fassung, oder null wenn sie sich nicht ermitteln lässt.
|
||||
*
|
||||
* @return array{version: string, url: string, checksum: ?string}|null
|
||||
*/
|
||||
public function latest(): ?array
|
||||
{
|
||||
$antwort = $this->download->get($this->apiBase.'/releases/latest');
|
||||
if (null === $antwort) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$daten = json_decode($antwort, true, 16, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
return null;
|
||||
}
|
||||
$tag = (string) ($daten['tag_name'] ?? '');
|
||||
if (1 !== preg_match('/^v(\d+\.\d+\.\d+)$/D', $tag, $treffer)) {
|
||||
return null;
|
||||
}
|
||||
$version = $treffer[1];
|
||||
|
||||
return [
|
||||
'version' => $version,
|
||||
'url' => sprintf('%s/%s/kantsites-%s.zip', $this->releaseBase, $tag, $version),
|
||||
'checksum' => $this->checksumOf(sprintf('%s/%s/kantsites-%s.zip.sha256', $this->releaseBase, $tag, $version)),
|
||||
];
|
||||
}
|
||||
|
||||
/** Gibt es etwas Neueres als das, was hier läuft? */
|
||||
public function updateAvailable(): ?string
|
||||
{
|
||||
$neueste = $this->latest();
|
||||
|
||||
return null !== $neueste && version_compare($neueste['version'], $this->updater->version(), '>')
|
||||
? $neueste['version']
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spielt die angegebene (oder die neueste) Fassung ein.
|
||||
*
|
||||
* @return array{ok: bool, steps: list<array{step: string, ok: bool, detail: string}>}
|
||||
*/
|
||||
public function run(?string $version = null): array
|
||||
{
|
||||
$schritte = [];
|
||||
// Jeder Schritt geht sofort ins Protokoll, nicht erst am Ende.
|
||||
//
|
||||
// Der Grund steht in ADR 071: Wenn die Seite, die das Ergebnis anzeigt, nicht erscheint -
|
||||
// aus welchem Grund auch immer -, weiß niemand mehr, was passiert ist. Eine Zeile in
|
||||
// einer Datei überlebt das, und auf einem Hosting ohne Konsole ist sie manchmal das
|
||||
// Einzige, was von einem gescheiterten Update übrig bleibt.
|
||||
$melden = function (array &$schritte, string $schritt, bool $ok, string $detail = ''): bool {
|
||||
$schritte[] = ['step' => $schritt, 'ok' => $ok, 'detail' => $detail];
|
||||
$this->protokoll(sprintf('%s %s: %s', $ok ? 'ok ' : 'FEHLER', $schritt, $detail));
|
||||
|
||||
return $ok;
|
||||
};
|
||||
$this->protokoll(sprintf('--- Update aus dem Archiv, %s UTC', gmdate('Y-m-d H:i:s')));
|
||||
|
||||
$neueste = $this->latest();
|
||||
if (null === $neueste) {
|
||||
$melden($schritte, 'Fassung ermitteln', false, trim('Die Releaseseite antwortet nicht. Ohne sie ist nicht feststellbar, was aktuell ist. '.$this->download->reason()));
|
||||
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
}
|
||||
$ziel = $version ?? $neueste['version'];
|
||||
$melden($schritte, 'Fassung ermitteln', true, sprintf('Installiert: %s, verfügbar: %s.', $this->updater->version(), $neueste['version']));
|
||||
if (version_compare($ziel, $this->updater->version(), '<=')) {
|
||||
$melden($schritte, 'Nichts zu tun', true, 'Diese Installation ist bereits auf dem neuesten Stand.');
|
||||
|
||||
return ['ok' => true, 'steps' => $schritte];
|
||||
}
|
||||
|
||||
$arbeit = $this->root.'/var/update/'.gmdate('Ymd-His');
|
||||
if (!@mkdir($arbeit, 0o775, true) && !is_dir($arbeit)) {
|
||||
$melden($schritte, 'Arbeitsordner', false, $arbeit.' ließ sich nicht anlegen. Fehlt das Schreibrecht auf var/?');
|
||||
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
}
|
||||
|
||||
// Erst sichern, dann anfassen. Eine Sicherung nach dem Ersetzen wäre keine.
|
||||
try {
|
||||
$sicherung = $this->backup->create('vor dem Update');
|
||||
$melden($schritte, 'Sicherung', true, sprintf('Datenbank und Medien liegen unter var/backups/%s.', (string) ($sicherung['name'] ?? 'sicherung')));
|
||||
} catch (\Throwable $fehler) {
|
||||
$melden($schritte, 'Sicherung', false, 'Die Sicherung ist fehlgeschlagen: '.$fehler->getMessage().' Ohne sie wird nichts ersetzt.');
|
||||
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
}
|
||||
|
||||
$archiv = $arbeit.'/kantsites-'.$ziel.'.zip';
|
||||
$inhalt = $this->download->get(sprintf('%s/v%s/kantsites-%s.zip', $this->releaseBase, $ziel, $ziel));
|
||||
if (null === $inhalt || '' === $inhalt) {
|
||||
$melden($schritte, 'Archiv laden', false, trim('Das Archiv ließ sich nicht herunterladen. '.$this->download->reason()));
|
||||
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
}
|
||||
file_put_contents($archiv, $inhalt);
|
||||
$melden($schritte, 'Archiv laden', true, sprintf('%d KB geladen.', (int) round(strlen($inhalt) / 1024)));
|
||||
|
||||
$erwartet = $ziel === $neueste['version'] ? $neueste['checksum'] : $this->checksumOf(sprintf('%s/v%s/kantsites-%s.zip.sha256', $this->releaseBase, $ziel, $ziel));
|
||||
if (null === $erwartet) {
|
||||
$melden($schritte, 'Prüfsumme', false, 'Zu diesem Archiv gibt es keine Prüfsumme. Ohne sie wird nichts entpackt.');
|
||||
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
}
|
||||
$tatsaechlich = hash_file('sha256', $archiv);
|
||||
if (!hash_equals($erwartet, (string) $tatsaechlich)) {
|
||||
$melden($schritte, 'Prüfsumme', false, 'Die Prüfsumme stimmt nicht. Das Archiv wird nicht verwendet.');
|
||||
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
}
|
||||
$melden($schritte, 'Prüfsumme', true, 'Stimmt mit der Angabe auf der Releaseseite überein.');
|
||||
|
||||
$entpackt = $arbeit.'/neu';
|
||||
$zip = new \ZipArchive();
|
||||
if (true !== $zip->open($archiv) || !$zip->extractTo($entpackt)) {
|
||||
$melden($schritte, 'Entpacken', false, 'Das Archiv ließ sich nicht entpacken.');
|
||||
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
}
|
||||
$zip->close();
|
||||
$quelle = $entpackt.'/kantsites-'.$ziel;
|
||||
if (!is_file($quelle.'/VERSION') || !is_file($quelle.'/vendor/autoload.php')) {
|
||||
$melden($schritte, 'Entpacken', false, 'Im Archiv fehlen VERSION oder vendor/ - das ist kein vollständiges Release.');
|
||||
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
}
|
||||
$melden($schritte, 'Entpacken', true, 'Vollständig: VERSION und vendor/ sind vorhanden.');
|
||||
|
||||
$this->updater->maintenance(true);
|
||||
$melden($schritte, 'Wartungszustand', true, 'Die Website antwortet währenddessen mit 503.');
|
||||
|
||||
$alt = $arbeit.'/alt';
|
||||
@mkdir($alt, 0o775, true);
|
||||
$ersetzt = 0;
|
||||
try {
|
||||
foreach (self::ERSETZEN as $eintrag) {
|
||||
$ziant = $this->root.'/'.$eintrag;
|
||||
if (file_exists($ziant) && !@rename($ziant, $alt.'/'.$eintrag)) {
|
||||
throw new \RuntimeException($eintrag.' ließ sich nicht beiseitelegen.');
|
||||
}
|
||||
if (file_exists($quelle.'/'.$eintrag) && !@rename($quelle.'/'.$eintrag, $ziant)) {
|
||||
throw new \RuntimeException($eintrag.' ließ sich nicht ersetzen.');
|
||||
}
|
||||
++$ersetzt;
|
||||
}
|
||||
// public/ wird Datei für Datei aktualisiert: Schriften und erzeugte Bündel gehören dem
|
||||
// Betreiber und wären sonst weg.
|
||||
$this->mergePublic($quelle.'/public', $this->root.'/public');
|
||||
} catch (\Throwable $fehler) {
|
||||
foreach (self::ERSETZEN as $eintrag) {
|
||||
if (is_dir($alt.'/'.$eintrag) || is_file($alt.'/'.$eintrag)) {
|
||||
@rename($this->root.'/'.$eintrag, $quelle.'/'.$eintrag);
|
||||
@rename($alt.'/'.$eintrag, $this->root.'/'.$eintrag);
|
||||
}
|
||||
}
|
||||
$melden($schritte, 'Dateien ersetzen', false, $fehler->getMessage().' Der alte Stand wurde zurückgeschoben.');
|
||||
$this->updater->maintenance(false);
|
||||
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
}
|
||||
$melden($schritte, 'Dateien ersetzen', true, sprintf('%d Einträge ersetzt, var/ und .env unberührt.', $ersetzt));
|
||||
|
||||
// Der Zwischenspeicher gehört zum alten Stand.
|
||||
$this->removeDirectory($this->root.'/var/cache');
|
||||
$melden($schritte, 'Zwischenspeicher', true, 'Geleert - sonst liefe die neue Fassung mit den Wegweisern der alten.');
|
||||
|
||||
// Hier endet dieser Durchgang mit Absicht. Der laufende Prozess hat den alten Code im
|
||||
// Speicher - Klassen, die PHP schon geladen hat, laedt es nicht neu. Die Migrationen
|
||||
// gehoeren deshalb in eine eigene Anfrage, die mit der neuen Fassung startet. Der
|
||||
// Wartungszustand bleibt so lange an.
|
||||
@file_put_contents($this->root.'/'.self::MARKE, json_encode([
|
||||
'fassung' => $ziel,
|
||||
'seit' => gmdate('c'),
|
||||
'alt' => basename($arbeit),
|
||||
], JSON_UNESCAPED_SLASHES));
|
||||
$melden($schritte, 'Dateien sind ersetzt', true, sprintf(
|
||||
'Fassung %s liegt bereit. Jetzt fehlen noch die Migrationen - sie laufen im nächsten Schritt mit dem neuen Code.',
|
||||
$ziel,
|
||||
));
|
||||
|
||||
return ['ok' => true, 'steps' => $schritte, 'migrate' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Steht ein zweiter Schritt aus? Gibt Fassung und Zeitpunkt zurück, sonst null.
|
||||
*
|
||||
* @return array{fassung: string, seit: string, alt: string}|null
|
||||
*/
|
||||
public function pending(): ?array
|
||||
{
|
||||
$inhalt = @file_get_contents($this->root.'/'.self::MARKE);
|
||||
if (!is_string($inhalt)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$daten = json_decode($inhalt, true, 8, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_array($daten) && isset($daten['fassung']) ? $daten : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der zweite Schritt: Migrationen ausführen und die Website wieder freigeben.
|
||||
*
|
||||
* Läuft in einer eigenen Anfrage, also mit dem Code der neuen Fassung.
|
||||
*
|
||||
* @return array{ok: bool, steps: list<array{step: string, ok: bool, detail: string}>}
|
||||
*/
|
||||
public function finish(Installation $installation): array
|
||||
{
|
||||
$schritte = [];
|
||||
$this->protokoll(sprintf('--- Migrationen nach dem Update, %s UTC', gmdate('Y-m-d H:i:s')));
|
||||
try {
|
||||
$namen = $installation->migrate();
|
||||
$schritte[] = ['step' => 'Migrationen', 'ok' => true, 'detail' => [] === $namen
|
||||
? 'Es stand keine an - die Datenbank war bereits auf dem Stand der neuen Fassung.'
|
||||
: sprintf('%d ausgeführt: %s.', count($namen), implode(', ', $namen))];
|
||||
} catch (\Throwable $fehler) {
|
||||
$schritte[] = ['step' => 'Migrationen', 'ok' => false, 'detail' => $fehler->getMessage().' Die Website bleibt im Wartungszustand, bis das geklärt ist.'];
|
||||
$this->protokoll('FEHLER Migrationen: '.$fehler->getMessage());
|
||||
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
}
|
||||
|
||||
@unlink($this->root.'/'.self::MARKE);
|
||||
$this->updater->maintenance(false);
|
||||
$this->protokoll('ok Migrationen: '.$schritte[0]['detail']);
|
||||
$schritte[] = ['step' => 'Fertig', 'ok' => true, 'detail' => sprintf(
|
||||
'Fassung %s läuft. Der vorherige Stand liegt unter var/update/, bis du ihn löschst.',
|
||||
$this->updater->version(),
|
||||
)];
|
||||
|
||||
return ['ok' => true, 'steps' => $schritte];
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Zeile ins Protokoll, und zwar sofort.
|
||||
*
|
||||
* Ohne Ausnahme bei Fehlschlag: Ein Update, das daran scheitert, dass sein Protokoll nicht
|
||||
* schreibbar ist, wäre absurd.
|
||||
*/
|
||||
private function protokoll(string $zeile): void
|
||||
{
|
||||
@file_put_contents($this->root.'/'.self::PROTOKOLL, $zeile.PHP_EOL, FILE_APPEND);
|
||||
}
|
||||
|
||||
/** Kopiert das neue public/ über das alte, ohne das zu berühren, was dort entstanden ist. */
|
||||
private function mergePublic(string $von, string $nach): void
|
||||
{
|
||||
if (!is_dir($von)) {
|
||||
return;
|
||||
}
|
||||
foreach (scandir($von) ?: [] as $eintrag) {
|
||||
if ('.' === $eintrag || '..' === $eintrag) {
|
||||
continue;
|
||||
}
|
||||
$quelle = $von.'/'.$eintrag;
|
||||
$ziel = $nach.'/'.$eintrag;
|
||||
$relativ = ltrim(str_replace($this->root.'/public', '', $ziel), '/');
|
||||
if (in_array($relativ, self::EIGENES_IN_PUBLIC, true)) {
|
||||
continue;
|
||||
}
|
||||
if (is_dir($quelle)) {
|
||||
@mkdir($ziel, 0o775, true);
|
||||
$this->mergePublic($quelle, $ziel);
|
||||
|
||||
continue;
|
||||
}
|
||||
@copy($quelle, $ziel);
|
||||
}
|
||||
}
|
||||
|
||||
/** Liest die Prüfsumme aus der Datei neben dem Archiv. */
|
||||
private function checksumOf(string $url): ?string
|
||||
{
|
||||
$inhalt = $this->download->get($url);
|
||||
if (null === $inhalt || 1 !== preg_match('/^([a-f0-9]{64})/', trim($inhalt), $treffer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $treffer[1];
|
||||
}
|
||||
|
||||
private function removeDirectory(string $pfad): void
|
||||
{
|
||||
if (!is_dir($pfad)) {
|
||||
return;
|
||||
}
|
||||
foreach (scandir($pfad) ?: [] as $eintrag) {
|
||||
if ('.' === $eintrag || '..' === $eintrag) {
|
||||
continue;
|
||||
}
|
||||
$voll = $pfad.'/'.$eintrag;
|
||||
is_dir($voll) ? $this->removeDirectory($voll) : @unlink($voll);
|
||||
}
|
||||
@rmdir($pfad);
|
||||
}
|
||||
}
|
||||
+67
-3
@@ -35,6 +35,14 @@ final class Backup
|
||||
private string $mediaDirectory,
|
||||
private Connection $db,
|
||||
private CommandRunner $runner,
|
||||
/**
|
||||
* Ab welcher SQLite-Fassung `VACUUM INTO` benutzt wird.
|
||||
*
|
||||
* Als Parameter und nicht als Konstante, damit der Weg fuer aeltere Fassungen im Test
|
||||
* wirklich laeuft. Ein Ersatzweg, der nur auf fremden Servern ausgefuehrt wird, ist ein
|
||||
* ungeprueftes Versprechen.
|
||||
*/
|
||||
private string $vacuumSince = '3.27.0',
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -214,9 +222,19 @@ final class Backup
|
||||
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))));
|
||||
// VACUUM INTO statt copy(): Eine Kopie, die waehrend eines Schreibvorgangs entsteht, kann
|
||||
// unbrauchbar sein - und sie sagt das nicht, sondern erst an dem Tag, an dem sie gebraucht
|
||||
// wird. Der Pfad kommt von uns, nicht aus einer Eingabe.
|
||||
//
|
||||
// **Es gibt VACUUM INTO aber erst seit SQLite 3.27.** Auf Umuts Hosting ist die Fassung
|
||||
// aelter: Die Installation lief (dafuer reicht 3.16), die Sicherung endete mit 500. Auf
|
||||
// einer solchen Installation wird stattdessen unter einer Schreibsperre kopiert - das ist
|
||||
// der Weg, den SQLite selbst fuer diesen Fall nennt, und er ist ebenso konsistent.
|
||||
if (version_compare($this->sqliteVersion(), $this->vacuumSince, '>=')) {
|
||||
$this->db->executeStatement(sprintf("VACUUM INTO '%s'", str_replace("'", "''", str_replace('\\', '/', $target))));
|
||||
} else {
|
||||
$this->copyUnderLock($file, $target);
|
||||
}
|
||||
if (!is_readable($target)) {
|
||||
throw new InvalidPage('Die Datenbank ließ sich nicht sichern.');
|
||||
}
|
||||
@@ -224,6 +242,52 @@ final class Backup
|
||||
return ['datei' => basename($file), 'bytes' => (int) filesize($target), 'sha256' => (string) hash_file('sha256', $target)];
|
||||
}
|
||||
|
||||
/** Die Fassung von SQLite, oder '0' wenn das keine SQLite-Verbindung ist. */
|
||||
private function sqliteVersion(): string
|
||||
{
|
||||
try {
|
||||
return (string) $this->db->fetchOne('SELECT sqlite_version()');
|
||||
} catch (\Throwable) {
|
||||
return '0';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine konsistente Kopie ohne VACUUM INTO.
|
||||
*
|
||||
* `BEGIN IMMEDIATE` haelt jeden anderen Schreibvorgang an, solange kopiert wird; der
|
||||
* Checkpoint davor schreibt zurueck, was noch im Write-Ahead-Log steht, damit die Kopie
|
||||
* vollstaendig ist und nicht die Haelfte der letzten Aenderungen fehlt.
|
||||
*
|
||||
* @throws InvalidPage
|
||||
*/
|
||||
private function copyUnderLock(string $file, string $target): void
|
||||
{
|
||||
try {
|
||||
$this->db->executeStatement('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
} catch (\Throwable) {
|
||||
// Ohne WAL gibt es nichts zurueckzuschreiben - das ist kein Fehler.
|
||||
}
|
||||
|
||||
try {
|
||||
$this->db->executeStatement('BEGIN IMMEDIATE');
|
||||
} catch (\Throwable $fehler) {
|
||||
throw new InvalidPage('Die Datenbank ließ sich fuer die Sicherung nicht sperren: '.$fehler->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
if (!@copy($file, $target)) {
|
||||
throw new InvalidPage('Die Datenbankdatei ließ sich nicht kopieren. Schreibrechte auf var/backups pruefen.');
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
$this->db->executeStatement('COMMIT');
|
||||
} catch (\Throwable) {
|
||||
// Die Sperre faellt spaetestens mit der Verbindung.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{count: int, bytes: int} */
|
||||
private function copyMedia(string $target): array
|
||||
{
|
||||
|
||||
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user