Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
@@ -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
|
||||
|
||||
@@ -55,7 +57,7 @@ Unter Linux dieselben PHP-Argumente mit vorangestelltem `APP_ENV=prod APP_DEBUG=
|
||||
|
||||
## Medien, Galerien und Effekte
|
||||
|
||||
Links im Werkzeugkasten **Medienverwaltung öffnen** oder auf der Seitenübersicht **Medienverwaltung** wählen. JPEG/PNG/WebP hochladen, Titel und Alternativtext bearbeiten und speichern. Bilder lassen sich suchen, archivieren und wiederherstellen. Die Dateien werden als optimierte WebP-Varianten gespeichert; Originaldateien werden nicht aufgehoben. Uploadgrenzen: 8 MB, 12 Megapixel und 8000 px Kantenlänge; Hostinglimits können niedriger sein. Bild-URLs sind öffentlich erreichbar, auch wenn sie noch in keinem veröffentlichten Inhalt verwendet werden.
|
||||
Links im Werkzeugkasten **Medienverwaltung öffnen** oder auf der Seitenübersicht **Medienverwaltung** wählen. JPEG/PNG/WebP hochladen, Titel und Alternativtext bearbeiten und speichern. Bilder lassen sich suchen, archivieren und wiederherstellen. Die Dateien werden als optimierte WebP-Varianten in vier Breiten gespeichert (480, 960, 1600, 2400 px; ein schmaleres Bild bekommt weniger Stufen und wird nie hochskaliert). Originaldateien werden nicht aufgehoben. Bildbausteine liefern `srcset`, Hintergrundbilder wählen die Variante je Gerät. Uploadgrenzen: 8 MB, 12 Megapixel und 8000 px Kantenlänge; Hostinglimits können niedriger sein. Bild-URLs sind öffentlich erreichbar, auch wenn sie noch in keinem veröffentlichten Inhalt verwendet werden.
|
||||
|
||||
Für ein einzelnes Bild **Bild aus Mediathek wählen** anklicken. Für eine Galerie den Block **Galerie** einfügen und **Galeriebilder auswählen** öffnen. Mehrere Bilder markieren, Alt-Texte speichern oder die dekorative Verwendung wählen und **Auswahl übernehmen**. Im Strukturbaum einzelne Bilder auswählen, beschriften und sortieren; Reihenfolge und Einfügen lassen sich rückgängig machen. Unter **Design** Spaltenzahl und Abstände pro Gerät einstellen.
|
||||
|
||||
@@ -77,6 +79,7 @@ composer build
|
||||
npm.cmd ci
|
||||
npx.cmd playwright install chromium
|
||||
npm.cmd run test:browser
|
||||
npm.cmd run test:links
|
||||
npm.cmd test
|
||||
npm.cmd run test:editor
|
||||
npm.cmd run test:editor -- --audit
|
||||
@@ -85,9 +88,15 @@ npm.cmd run audit
|
||||
|
||||
Unter Linux/macOS `npm` und `npx` ohne `.cmd`; in CI `npx playwright install --with-deps chromium`. `test:browser` und `audit` benötigen den laufenden lokalen Produktionsmodus auf Port 8080. Ein anderes Ziel lässt sich über `CMS_URL` konfigurieren. `test:editor` startet einen eigenen PHP-Prozess auf einem freien Port mit isolierter Datenbank und zufälligem Testkonto; es ändert keine echten Seiten oder Benutzer. `PHP_BINARY` kann dafür einen alternativen PHP-Pfad angeben. Testdaten bleiben unter `var/browser-tests`, Berichte/Screenshots unter `reports/`. Das Skript erneuert zuvor den Produktionscache.
|
||||
|
||||
**Datenbank:** SQLite ab Fassung 3.16 (Doctrine liest das Schema mit Tabellenfunktionen, die es erst seit dieser Fassung gibt) oder MySQL/MariaDB. Auf älteren Systemen – manche Shared Hoster liefern noch SQLite 3.7 – bleibt MySQL; die Systemprüfung des Installers nennt die gefundene Fassung und bietet SQLite gar nicht erst an, wenn sie zu alt ist.
|
||||
|
||||
Die Verwaltung liegt unter `/admin`. `php bin/console cms:admin:path meine-verwaltung` legt sie auf eine andere Adresse: der Wert landet in `.env.local`, gilt für Routen, Firewall und Schutzkopfzeilen, und wird in der `robots.txt` bewusst nicht genannt. Ohne Argument zeigt der Befehl die aktuelle Adresse.
|
||||
|
||||
`composer format` formatiert PHP. `composer build` wärmt den Symfony-Produktionscache; öffentliche CSS-Dateien sind direkt auslieferbar, es ist kein Frontend-Bundler nötig. Bei Änderungen an `public/assets/design-tokens.json` einmal `npm.cmd run build:design` ausführen und die erzeugte CSS-Datei mitliefern; Node ist auf dem Hosting nicht erforderlich. Nach Änderungen an Produktionskonfiguration/Templates `php bin/console cache:clear --env=prod --no-debug` ausführen. Falls lokale Tests alte Templates verwenden, vor PHPUnit auch `php bin/console cache:clear --env=test --no-debug` ausführen.
|
||||
|
||||
Das Lighthouse-Skript misst die Startseite dreimal je Profil und prüft die Mediane gegen die Projektziele. `test:editor -- --audit` prüft die im isolierten Editortest gestaltete v3-Seite mit Abschnittsvorlage, Galerie, tatsächlichen Bildvarianten und Effekten; Berichte stehen unter `reports/lighthouse-editor`. Uploads und Datenbank des Tests liegen getrennt unter `var/browser-tests`. Diese kleinen Prototypseiten ersetzen nicht die spätere Referenzsuite mit Kundeninhalten, Fotos, Beitrag und Formular. Messgrenzen und Ergebnisse: `docs/QUALITY.md`.
|
||||
`test:links` folgt der Sitemap, sammelt jeden internen Verweis samt Bildern, Stylesheets und Hintergrundbildern und meldet jede Antwort ab 400 – gebaut, nachdem im Nachbau von kant-it.de vier Verweise ins Leere zeigten, die kein Test finden konnte.
|
||||
|
||||
Das Lighthouse-Skript misst dreimal je Profil und prüft die Mediane gegen die Projektziele. Welche Adressen geprüft werden, sagt `CMS_PATHS` (mit Komma getrennt, Standard `/`); in der Git-Bash davor `MSYS_NO_PATHCONV=1` setzen, sonst wird aus `/` ein Windows-Pfad. `test:editor -- --audit` prüft die im isolierten Editortest gestaltete v3-Seite mit Abschnittsvorlage, Galerie, tatsächlichen Bildvarianten und Effekten; Berichte stehen unter `reports/lighthouse-editor`. Uploads und Datenbank des Tests liegen getrennt unter `var/browser-tests`. Diese kleinen Prototypseiten ersetzen nicht die spätere Referenzsuite mit Kundeninhalten, Fotos, Beitrag und Formular. Messgrenzen und Ergebnisse: `docs/QUALITY.md`.
|
||||
|
||||
## Inhalte und Entwicklung
|
||||
|
||||
@@ -107,6 +116,6 @@ Anforderungen vor dem Pilotbetrieb beim Anbieter bestätigen:
|
||||
|
||||
Serverkonfiguration über echte Umgebungsvariablen oder eine nicht öffentliche `.env.local`: `APP_ENV=prod`, `APP_DEBUG=0`, eigenes zufälliges `APP_SECRET`, `SITE_ORIGIN=https://eigene-domain.example` und `DATABASE_URL=mysql://USER:URL_ENCODED_PASSWORD@HOST:3306/DATABASE?charset=utf8mb4`. Das ist eine Vorlage, keine funktionierende Zugangskonfiguration.
|
||||
|
||||
Deployment später mit `composer install --no-dev --optimize-autoloader`, Backup, Migration und Cacheaufbau. Composer darf auf einem geeigneten Build-Rechner laufen, sodass das Hosting weder Node noch Composer benötigt; CLI-Zugriff oder ein verlässlich dokumentierter Deploymentweg für Datenbankmigrationen bleibt zu klären. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
|
||||
**Composer wird auf dem Hosting nicht vorausgesetzt.** Ein Release wird auf einem Rechner gebaut, der Composer hat: `php scripts/build-release.php` legt `var/releases/kantsites-<Fassung>.zip` samt `.sha256` an. Das Archiv enthält die Anwendung mit fertigem `vendor/` ohne Entwicklungspakete, aber keine Tests, keine Skripte, kein `var/` und kein `.env`; als Vorlage für die eigene Konfiguration liegt `.env.example` bei. Auf dem Hosting wird es entpackt, `.env.local` angelegt, migriert und der Cache gebaut. Node wird dort ebenfalls nicht gebraucht. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
|
||||
|
||||
Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `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 `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` und `php bin/console cms:backup` gehören danach in einen täglichen Cronjob – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore <name>`. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Ändert ein Update die Abhängigkeiten (`composer.lock`) und ist Composer auf dem Server nicht aufrufbar, bricht der Updater ab und verweist auf das Releasearchiv; Updates ohne Abhängigkeitsänderung laufen auch ohne Composer durch. Ablauf und Grenzen stehen in `docs/UPDATE.md`. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt.
|
||||
@@ -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,3 +1,16 @@
|
||||
parameters:
|
||||
# Die Adresse der Verwaltung. Standard bleibt /admin; wer sie aendert, nimmt Scannern und
|
||||
# Anmeldeversuchen das Ziel - erraten laesst sie sich nicht mehr. Sie steht in der Umgebung und
|
||||
# nicht in der Datenbank, weil die Routen beim Start feststehen muessen. Geaendert wird sie
|
||||
# ueber `php bin/console cms:admin:path <neu>`; der Befehl prueft und raeumt den Cache auf.
|
||||
kant.admin_path: '%env(string:default:kant.admin_path_default:ADMIN_PATH)%'
|
||||
kant.admin_path_default: 'admin'
|
||||
# Wo Schriften und Stylesheets liegen. Im Normalfall im oeffentlichen Ordner; ueber
|
||||
# FONT_DIRECTORY und ASSET_DIRECTORY umstellbar - die Tests nutzen das, damit sie nicht in
|
||||
# das Projekt schreiben, und eine Installation mit schreibgeschuetztem public/ ebenfalls.
|
||||
kant.fonts_dir: '%kernel.project_dir%/public/assets/fonts'
|
||||
kant.asset_dir: '%kernel.project_dir%/public/assets'
|
||||
|
||||
services:
|
||||
_defaults:
|
||||
autowire: true
|
||||
@@ -15,6 +28,21 @@ services:
|
||||
App\Content\MediaLibrary:
|
||||
arguments:
|
||||
$directory: '%env(resolve:MEDIA_DIRECTORY)%'
|
||||
# Die Schriften liegen im oeffentlichen Ordner: sie sollen vom Webserver direkt ausgeliefert
|
||||
# werden, ohne PHP dazwischen - das ist der Punkt der Uebung.
|
||||
# Die Stellen, die wissen muessen, wie die Verwaltung heisst.
|
||||
App\EventSubscriber\AdminHeadersSubscriber:
|
||||
arguments:
|
||||
$adminPath: '%kant.admin_path%'
|
||||
App\Content\Statistics:
|
||||
arguments:
|
||||
$adminPath: '%kant.admin_path%'
|
||||
App\Content\FontLibrary:
|
||||
arguments:
|
||||
$directory: '%env(default:kant.fonts_dir:FONT_DIRECTORY)%'
|
||||
App\Content\StyleBundle:
|
||||
arguments:
|
||||
$assetDirectory: '%env(default:kant.asset_dir:ASSET_DIRECTORY)%'
|
||||
App\Controller\FormController:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
@@ -22,16 +50,34 @@ 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\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%'
|
||||
|
||||
+364
-1
@@ -95,7 +95,7 @@ Migration `20260912000300` ergänzt ausschließlich die Tabelle `media`: stabile
|
||||
|
||||
Uploads und Metadatenänderungen verlangen `ROLE_EDITOR` und einen eigenen CSRF-Token. Metadaten nutzen erwartete Versionen; konkurrierende Änderungen liefern 409. Bildtitel/Alt-Text im Medienkatalog sind Vorlagen für künftiges Einfügen. Bereits gespeicherte Seiten behalten ihre kontextbezogenen Texte, Beschriftungen und Bildvarianten. Archivieren blendet Bilder aus der normalen Auswahl aus und ist umkehrbar; bestehende URLs, Seiten und Undo-Stände bleiben funktionsfähig. Es gibt noch keine physische Löschung, Nutzungsübersicht, Ordner oder Katalog-Paginierung.
|
||||
|
||||
Nur tatsächlich decodierbare JPEG-, PNG- und WebP-Dateien werden angenommen. Dateinamen und vom Browser gemeldete MIME-Typen bestimmen weder Speicherpfade noch Dateiformat. Maximal 8 MiB, 12 Millionen Pixel und 8000 px pro Kante; zusätzlich konservative Prüfung gegen das PHP-Speicherlimit. GD decodiert und erzeugt WebP-Varianten bis 480/960/1600 px Breite ohne Hochskalieren. Das hochgeladene Original wird nicht übernommen; Metadaten und angehängte Nutzdaten werden nicht in die Varianten kopiert. EXIF-Orientierung wird bei JPEG berücksichtigt, sofern die EXIF-Erweiterung vorhanden ist. Transparenz wird erhalten; animierte Bilder/Dateianhänge/SVG gehören nicht zum unterstützten Uploadvertrag. Grundlage: [PHP-Bilddecodierung](https://www.php.net/manual/en/function.imagecreatefromstring.php).
|
||||
Nur tatsächlich decodierbare JPEG-, PNG- und WebP-Dateien werden angenommen. Dateinamen und vom Browser gemeldete MIME-Typen bestimmen weder Speicherpfade noch Dateiformat. Maximal 8 MiB, 12 Millionen Pixel und 8000 px pro Kante; zusätzlich konservative Prüfung gegen das PHP-Speicherlimit. GD decodiert und erzeugt WebP-Varianten (Qualität 82) bis 480/960/1600/2400 px Breite ohne Hochskalieren; ein schmaleres Original bekommt entsprechend weniger Stufen, die letzte davon in seiner eigenen Breite. Das hochgeladene Original wird nicht übernommen; Metadaten und angehängte Nutzdaten werden nicht in die Varianten kopiert. EXIF-Orientierung wird bei JPEG berücksichtigt, sofern die EXIF-Erweiterung vorhanden ist. Transparenz wird erhalten; animierte Bilder/Dateianhänge/SVG gehören nicht zum unterstützten Uploadvertrag. Grundlage: [PHP-Bilddecodierung](https://www.php.net/manual/en/function.imagecreatefromstring.php).
|
||||
|
||||
Dateien liegen unter `MEDIA_DIRECTORY`, standardmäßig `var/media` außerhalb des Webroots. Nur zufällige IDs und bekannte Variantengrößen gelangen über `/media/{id}/{width}` mit festem `image/webp`, `nosniff` und unveränderlichem Langzeitcache nach außen. Erweiterungslose URLs funktionieren auch über die Frontcontroller-Weiterleitung des lokalen PHP-Servers; Pfade mit nicht vorhandenen `.webp`-Dateien wurden dort als statische 404 behandelt. Bild-URLs sind öffentlich, auch vor Veröffentlichung einer Seite und nach Archivieren; der Uploaddialog benennt dies. Die Katalog-API bleibt geschützt. Dies ist keine private Dokumentablage.
|
||||
|
||||
@@ -440,6 +440,369 @@ Vom Verweis bleibt nur der Host. Die vollständige Adresse, aus der jemand kam,
|
||||
|
||||
**Aufbewahrung:** nach 60 Tagen werden die Stunden eines Tages zu einer Zeile verschmolzen, nach 425 Tagen wird gelöscht. Gut ein Jahr, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. `cms:stats:prune` macht beides und ist für einen Cronjob gedacht.
|
||||
|
||||
## ADR 056 – Das Vorschaubild beim Teilen, und die robots.txt als Bot-Steuerung (13.09.2026)
|
||||
|
||||
Umut: „Hast du auch schon SEO und Siteindex usw. übernommen?“ Der Abgleich mit dem Original war die Antwort – und er förderte einen Fehler der eigenen Software zutage.
|
||||
|
||||
**Das Feld „Vorschaubild beim Teilen“ gab es seit dem SEO-Paket, ausgegeben wurde es nie.** Im Kopf stand `{% if socialImage %}`, gesetzt hat `socialImage` kein Controller. Damit hatte **keine** mit KantSites gebaute Seite ein `og:image` – beim Teilen in WhatsApp, LinkedIn oder Slack erschien eine Karte ohne Bild. Jetzt wird es aufgelöst: erst das eigens gesetzte Vorschaubild, sonst bei einem Beitrag sein Beitragsbild, als absolute Adresse und in der Variante bis 1600 px. Dasselbe Bild steht im strukturierten Datensatz des Beitrags. Ohne Bild gibt es `twitter:card: summary` statt der großen Karte, die sonst eine leere Fläche wäre. Dazu `og:site_name` und `og:locale`, die schlicht fehlten.
|
||||
|
||||
**Die robots.txt war zu eng gefasst.** Das Original pflegt eine Liste, welche KI-Crawler die Website lesen dürfen (GPTBot, ClaudeBot, PerplexityBot …) und welche nicht (Bytespider, Diffbot, omgili, ImagesiftBot) – rund siebzig Zeilen in Blöcken je Bot. Das Feld für eigene Zeilen nahm dreißig und warf Leerzeilen weg, machte also aus Blöcken einen Klumpen. Jetzt 200 Zeilen, Leerzeilen bleiben als Trenner erhalten. Die erlaubten Anweisungen bleiben dieselben: Kommentar, `User-agent`, `Allow`, `Disallow`, `Crawl-delay`.
|
||||
|
||||
**Kein Sitemap-Index.** Das Original liefert einen (drei Teilkarten von Yoast), KantSites eine einzelne Datei mit allen Adressen. Ein Index ist erst ab 50.000 Adressen oder 50 MB vorgeschrieben; bei 35 Adressen wäre er eine Datei mehr ohne jeden Unterschied für eine Suchmaschine. Aufgeteilt wird, wenn es eine Website gibt, die das braucht – und dann automatisch, nicht als Einstellung.
|
||||
|
||||
## ADR 059 – Cloudflare Turnstile: optional, und der Preis steht dabei (13.09.2026)
|
||||
|
||||
Umut: „bei Formularen hätte ich gerne optional Cloudflare." Eingebaut sind drei Prüfungen ohne Dritte – ein verstecktes Feld, ein signierter Zeitstempel, fünf Absendungen je Absender und Stunde. Sie halten ab, was ein Skript ausfüllt; gegen jemanden, der einen echten Browser fernsteuert, helfen sie nicht.
|
||||
|
||||
**Turnstile und nicht reCAPTCHA:** keine Cookies, keine Google-Anmeldung, keine Verhaltensauswertung über Websites hinweg. Ein Dritter bleibt es trotzdem – die IP-Adresse des Absenders geht an Cloudflare, und das gehört in die Datenschutzerklärung.
|
||||
|
||||
Drei Entscheidungen, die nicht offensichtlich sind:
|
||||
|
||||
**Aus, bis jemand sie einschaltet.** Eine Website ohne Spamwelle soll keinen Dritten einbinden. Eingeschaltet ohne Schlüssel wird abgelehnt: das wäre ein Formular, das jede Anfrage verschluckt.
|
||||
|
||||
**Antwortet Cloudflare nicht, gilt die Absendung als bestanden.** Ein Ausfall bei einem Dritten darf keine Kundenanfrage kosten. Spam ist ärgerlich, eine verlorene Anfrage ist teurer.
|
||||
|
||||
**Die Sicherheitsrichtlinie öffnet sich nur dort, wo ein Formular steht** – Skript und Rahmen von `challenges.cloudflare.com`, und das auch nur auf den betroffenen Seiten. Jede andere Seite behält `script-src 'none'`.
|
||||
|
||||
Der Preis steht in der Verwaltung: das Widget braucht JavaScript. Ohne Skript ist das Formular dann nicht absendbar – genau die Art von Verschlechterung, die dieses Projekt sonst vermeidet. Deshalb steht im Formular ein `<noscript>`-Hinweis mit dem Weg über E-Mail.
|
||||
|
||||
## ADR 065 – Die Verwaltung muss nicht /admin heißen (13.09.2026)
|
||||
|
||||
Umut: „Erstelle mal Möglichkeit, Backend anzupassen. Also URL, Admin auf irgendwas."
|
||||
|
||||
`/admin` ist die erste Adresse, die ein Scanner probiert, und jeder Anmeldeversuch, der dort ankommt, kostet Rechenzeit und Logzeilen. Ein anderer Name nimmt der Maschinerie das Ziel. **Er ersetzt kein gutes Passwort** – die Anmeldesperre nach fünf Versuchen bleibt die eigentliche Verteidigung –, aber er macht die Website für automatisierte Angriffe uninteressant.
|
||||
|
||||
**Der Name steht in der Umgebung, nicht in der Datenbank.** Routen stehen fest, bevor die erste Datenbankabfrage läuft; eine Adresse, die erst beim Rendern bekannt wird, käme zu spät. `ADMIN_PATH` füllt einen Parameter, der als Präfix in jeder Verwaltungsroute steht (`#[Route('/%kant.admin_path%/media')]`), in der Firewall, in den Schutzkopfzeilen und in der Statistik.
|
||||
|
||||
Drei Dinge, die dabei nicht offensichtlich sind:
|
||||
|
||||
**Die robots.txt nennt den Namen nicht mehr.** Sie ist öffentlich und das erste, was ein Angreifer liest – ein `Disallow: /stiller-eingang` wäre ein Wegweiser. Beim Standardnamen bleibt die Zeile, weil diese Adresse ohnehin jeder kennt; bei jedem anderen entfällt sie. Vom Indexieren hält die Verwaltung ihr eigener Kopf `X-Robots-Tag: noindex` ab.
|
||||
|
||||
**Die Auffangroute für Seiten steht jetzt zuletzt.** Sie schließt per Regex `/admin` aus – einen frei wählbaren Namen kann sie nicht kennen. Statt die Regex dynamisch zu machen, lädt `routes.yaml` erst alle benannten Routen und dann die Auffangroute. Das ist ohnehin die richtige Reihenfolge.
|
||||
|
||||
**Der Befehl räumt den Zwischenspeicher auf, aber nicht den, in dem er selbst läuft.** Die Routen tragen den Namen einbetoniert; ohne Aufräumen gälte der alte weiter. Wer sich dabei den Boden unter den Füßen wegzieht, endet mit einem Fehler, obwohl alles geklappt hat – also bleibt die laufende Umgebung stehen, und der Befehl sagt, wie man sie leert.
|
||||
|
||||
`php bin/console cms:admin:path` zeigt die aktuelle Adresse, mit Argument ändert er sie – nach einer Prüfung, dass der Name frei ist und keine Seite verdeckt.
|
||||
|
||||
## ADR 064 – Ein Stylesheet, das nichts mehr tat (13.09.2026)
|
||||
|
||||
Umut: „ganz unten Einwilligung ändern, Datenschutz usw. ist links, komplett bitte besser lösen." Das war kein Gestaltungswunsch, sondern ein Fehlerbericht: Der **Einwilligungsdialog stand ungestaltet unter dem Fuß**, linksbündig, ohne Rand, ohne Hintergrund.
|
||||
|
||||
Die Ursache lag drei Veröffentlichungen zurück. Beim Entfernen alter Regeln war zweimal der Selektorrest stehen geblieben:
|
||||
|
||||
```css
|
||||
.site-header
|
||||
.site-footer
|
||||
/* Kommentar */
|
||||
.legal-bar{padding:16px 20px;text-align:center;…}
|
||||
```
|
||||
|
||||
CSS liest darüber hinweg: Der offene Selektor nimmt den nächsten mit, und aus zwei Regeln wird eine, die nirgends greift. Dasselbe war der `.consent-bar`-Regel passiert. **Ein Stylesheet beschwert sich nicht – es wirkt nur nicht mehr**, und beides blieb unbemerkt, weil kein Test das Aussehen dieser beiden Leisten prüft.
|
||||
|
||||
Repariert, und ein Test liest jetzt jede CSS-Datei zeilenweise: Was wie ein Selektor beginnt, muss auch wie einer enden. Er findet den Fehler in beiden Fassungen, geprüft gegen den alten Stand.
|
||||
|
||||
## ADR 063 – Der Bildbetrachter: ein Verweis, den ein Skript veredelt (13.09.2026)
|
||||
|
||||
Umut: „Bilder vergrößern Funktion, ein Popup-Viewer – gerne auch auswählbar wie bei WordPress, ob man bei Klick Galerie anschauen möchte."
|
||||
|
||||
Auswählbar heißt hier: Jedes Bild hat unter „Beim Klick" drei Möglichkeiten – nichts tun, vergrößern, zu einer Adresse führen –, und eine Galerie hat einen Schalter „Bilder vergrößerbar".
|
||||
|
||||
**Die Grundlage ist ein gewöhnlicher Verweis auf die große Bildfassung.** Ohne JavaScript öffnet ein Klick sie, zurück führt die Zurück-Taste; nichts ist kaputt. Das Skript – geladen nur auf Seiten, die tatsächlich ein vergrößerbares Bild enthalten – fängt den Klick ab und zeigt das Bild über der Seite, mit Blättern innerhalb einer Galerie.
|
||||
|
||||
Benutzt wird `<dialog>`: Fokusfang, Schließen mit Escape und die Auszeichnung als Dialog bringt der Browser mit. Eine nachgebaute Fassung aus `<div>` und Tastaturabfragen wäre mehr Code und weniger zugänglich. Mit gedrückter Zusatztaste bleibt es ein normaler Verweis – wer ein Bild in einem neuen Reiter öffnen will, soll das können.
|
||||
|
||||
## ADR 062 – Zwei Dinge, die am Rand kleben: Knopf nach oben und Rechtszeile (13.09.2026)
|
||||
|
||||
**Der Knopf „nach oben" ist ein Anker, kein Skript.** `<a href="#seitenanfang">` springt an den Seitenanfang – das kann ein Browser seit jeher allein. Eingeblendet wird er über eine Scroll-Zeitachse (`animation-timeline: scroll(root)`), dieselbe Technik wie bei den Einblendungen; wo es sie nicht gibt, steht er von Anfang an da. Sichtbar und nutzbar ist besser als unsichtbar. Alles daran ist einstellbar – Seite, Form, Größe, Farben, Schwelle, Beschriftung –, weil er auf jeder Seite in der Ecke klebt und deshalb zur Gestaltung passen muss. Solange der Einwilligungsdialog offen steht, tritt er zurück.
|
||||
|
||||
**Die Rechtszeile wird ein Baustein.** Bisher hängte das System sie als eigene Leiste unter den Fuß: auf einer Website mit dunklem Fuß ein heller Streifen, den niemand dort haben wollte. Als Baustein steht sie im Fußbereich, erbt dessen Farben und lässt sich wie jeder andere Baustein gestalten. Die eingebaute Leiste erscheint weiterhin – aber nur, wenn die Seite den Baustein nicht enthält. Die Pflicht, Impressum und Datenschutz von jeder Seite aus erreichbar zu halten, bleibt damit erfüllt, ohne dass zweimal dasselbe dasteht.
|
||||
|
||||
## ADR 061 – Die Vorschau zeigt nie eine Seite ohne ihre Gestaltung (13.09.2026)
|
||||
|
||||
Umut, mit Blick auf einen Screenshot des Editors: „im Screenshot sind die Buttons nicht gleiche Farbe, es sollte exakt nachladen, die Vorschau soll wie das echte aussehen."
|
||||
|
||||
**Nachgemessen: die Farben sind identisch.** Knopf, Hero-Hintergrund, Hintergrundbild und Textfarbe geben in der Vorschau und auf der veröffentlichten Seite exakt dieselben berechneten Werte. Was auf dem Bild zu sehen war, war ein Zwischenzustand – links oben stand „Vorschau wird aktualisiert …".
|
||||
|
||||
Der Zwischenzustand war trotzdem ein Fehler, und ein langer: **1440 Millisekunden** lang war die Vorschau sichtbar und ungestaltet, gemessen an der nachgebauten Startseite. Der Grund liegt in einer Zeile: die erzeugten Stile wurden im `load`-Ereignis des Rahmens angewandt – und das feuert erst, wenn **das letzte Bild** geladen ist. Bei sechzehn Bildern sieht man bis dahin die Seite ohne eine einzige Farbe.
|
||||
|
||||
Drei Änderungen, jede aus einem eigenen Fehlversuch:
|
||||
|
||||
1. **Angewandt wird, sobald das Dokument geparst ist**, nicht wenn es fertig geladen ist. Für `srcdoc` gibt es dafür kein verlässliches Ereignis, also wird Bild für Bild nachgesehen, ob der Kopf schon da ist.
|
||||
2. **Der Rahmen bleibt verdeckt, bis die Gestaltung sitzt.** Eine leere Fläche für einen Moment ist besser als eine falsche Seite.
|
||||
3. **Nur das neue Dokument wird gestaltet.** Der erste Anlauf sah beim Warten das *alte* Dokument – es trägt dieselben Kennzeichen –, gestaltete es, schaltete sichtbar, und `srcdoc` ersetzte es gleich darauf durch den ungestalteten neuen Inhalt. Der Fehler war damit derselbe wie vorher, nur schneller.
|
||||
|
||||
Gegen einen Rückfall prüft die Editorkontrolle nicht die Zeit, sondern das Verhalten: der Rahmen **muss** beim Aufbau verdeckt werden und danach wieder sichtbar. Eine Zeitmessung hätte auf der kleinen Testseite nichts gefunden – das Fenster ist dort zu kurz.
|
||||
|
||||
## ADR 060 – Die Fehlerseite eines Formulars ist dieselbe Seite (13.09.2026)
|
||||
|
||||
Beim Einbau von Turnstile gefunden: wer ein Pflichtfeld vergaß, bekam die Seite **ohne Gestaltung** zurück. Der Formularcode baute sie selbst ein zweites Mal auf und ließ dabei alles weg, was er nicht kannte – Theme, Blockstile, Schriften, das Stylesheet-Bündel. Zwei Renderwege für dieselbe Seite, und der zweite hinkte hinterher, seit es den ersten gibt.
|
||||
|
||||
Jetzt geht auch dieser Weg durch den Seitencontroller. Die Meldungen und die eingetippten Werte reisen als Anfrageattribut mit; die Seite entsteht genau einmal, mit allem, was dazugehört. Eine Sonderregel blieb nötig: die Startseite leitet von ihrem eigenen Pfad auf `/` um, und diese Umleitung hätte die Meldungen verworfen – sie gilt für diesen Weg nicht.
|
||||
|
||||
## ADR 058 – Bilder in der Größe, in der sie erscheinen (13.09.2026)
|
||||
|
||||
Gemessen auf der nachgebauten Startseite: ein Telefon lud das Hintergrundbild des Hero mit **1920 Pixeln und 189 KB**. Zu sehen war davon nie mehr als ein Viertel. Die Ursache ist bauartbedingt: wer im Editor ein Hintergrundbild wählt, sitzt am Schreibtisch und setzt es unter „Desktop“; kleine Geräte erben diese Regel, und CSS kennt kein `srcset`.
|
||||
|
||||
Die Regeln stehen aber ohnehin je Gerät da. Der Kompilierer ergänzt deshalb für Tablet und Telefon dieselbe Bildwahl, die entstünde, wenn jemand sie einzeln gesetzt hätte – samt Verlauf und Abdunkelung, denn ohne sie stünde heller Text plötzlich auf einem ungedämpften Bild. Ein ausdrücklich gesetztes Bild wird nie ersetzt. **189 KB → 40 KB auf dem Telefon.**
|
||||
|
||||
Dasselbe Thema bei `<img>`: jedes Bild behauptete, 800 Pixel breit zu erscheinen – auch ein Logo, das 190 Pixel breit ist. Jetzt gilt: eine im Block gesetzte feste Breite ist die Antwort; sonst steht bei verzögert geladenen Bildern `sizes="auto"`, womit der Browser selbst nachmisst (Rückfallangaben dahinter für Safari). Das vorgezogene Bild behält die Annahme, weil seine Größe zum Zeitpunkt der Anfrage noch nicht feststeht.
|
||||
|
||||
Und die Eile lag auf der falschen Stelle: `fetchpriority="high"` bekam das erste Bild überhaupt – das Logo im Kopfbereich. Es bekommt es jetzt das erste Bild im **ersten Abschnitt des Inhalts**; Bilder im Kopfbereich laden weiterhin sofort, aber ohne Vorrang.
|
||||
|
||||
**Was nicht blieb:** ein Vorabladen des Hero-Hintergrunds (`<link rel="preload" as="image">`). Der Gedanke stimmt – ein Bild im Stylesheet wird spät entdeckt –, die Messung nicht: mobil **95 → 90**. Das Bild nimmt genau die Bandbreite weg, die der Text für den größten Aufbauschritt braucht. Wieder entfernt.
|
||||
|
||||
## ADR 057 – Schriften vom eigenen Server (13.09.2026)
|
||||
|
||||
Eine Schrift von `fonts.googleapis.com` kostet zweierlei. Technisch: der Browser holt erst das Stylesheet, findet darin die Adresse der Schriftdatei und holt dann diese – zwei fremde Verbindungen nacheinander, gemessen 820 ms, in denen keine Zeile in der richtigen Schrift steht. Rechtlich: ein Dritter erfährt die IP-Adresse jedes Besuchers, und dafür gibt es in Deutschland Abmahnungen (LG München I, 3 O 17493/20).
|
||||
|
||||
Deshalb: **einmal holen, selbst ausliefern.** Ein Knopf im Designbereich (und `cms:fonts:fetch` auf der Konsole) lädt die gewählten Schnitte als WOFF2 nach `public/assets/fonts`, schreibt die `@font-face`-Regeln daneben und meldet, was geladen wurde. Die Lizenz erlaubt das ausdrücklich – Google Fonts stehen unter OFL oder Apache 2.0.
|
||||
|
||||
Drei Entscheidungen dabei: **Kein neues Paket** – geholt wird mit curl, ersatzweise über `allow_url_fopen`, genau wie der Installer es tut; eine zusätzliche Abhängigkeit würde jedes Update auf einem Hosting ohne Composer scheitern lassen. **Nur Latein** – Kyrillisch und Vietnamesisch wären zwei Drittel der Bytes für nichts. **Nicht beim Seitenaufruf** – ein Besucher wartet nie auf einen fremden Server, auch nicht mittelbar. Liegen die Dateien nicht, bleibt es beim Verweis auf Google statt einer Seite ohne Schrift.
|
||||
|
||||
## ADR 056 – Vier Stylesheets werden eines, und der Webserver bekommt Anweisungen (13.09.2026)
|
||||
|
||||
Vor dem Livegang gemessen, nicht geschätzt. Jede der vier CSS-Dateien hält das Zeichnen der Seite auf, zusammen 46 KB. Sie sind getrennt, weil sie beim Schreiben Verschiedenes tun; beim Ausliefern ist das kein Argument. Ein Bündel trägt die Prüfsumme seines Inhalts im Namen, wird beim ersten Aufruf nach einer Änderung erzeugt und ohne Kommentare geschrieben. Minifiziert wird bewusst nur das: Kommentare und Einrückung. Ein Minifier, der Selektoren umbaut, spart Zeichen und kostet einen Abend Fehlersuche – in `calc(100% - 20px)` und `.a :hover` trägt ein Leerzeichen Bedeutung. Fehlt das Schreibrecht, werden die Quellen einzeln geladen: langsamer, aber heil.
|
||||
|
||||
Dazu eine `public/.htaccess`, die diesen Namen verdient: Kompression (mod_deflate/mod_brotli), ein Jahr Cache für alles, was seine Fassung oder Kennung in der Adresse trägt – Stylesheets und Skripte bekommen dafür `?v=` mit der Programmfassung –, und ein Riegel vor Dateien, die niemanden angehen. HTTPS-Umleitung und HSTS stehen auskommentiert mit dem Grund dabei: eine Umleitung ohne Zertifikat ist eine Endlosschleife, und HSTS lässt sich ein halbes Jahr lang nicht zurücknehmen.
|
||||
|
||||
**Der Entwicklungsserver sendet jetzt dieselben Kopfzeilen und komprimiert ebenso.** Ohne das misst man hier Größen, die es im Betrieb nie gibt: dieselbe Startseite ging von 90 auf 96 Punkte, ohne dass sich an ihr etwas geändert hätte.
|
||||
|
||||
## ADR 055 – Das Symbol der Website entsteht aus einem Bild der Mediathek (13.09.2026)
|
||||
|
||||
Umut: „dann brauchen wir Favicon-Möglichkeit zum Festlegen“. Ein Favicon ist der einzige Teil der Gestaltung, den ein Besucher sieht, bevor die Seite geladen ist – und bisher war es das mitgelieferte Zeichen, für jede mit KantSites gebaute Website dasselbe.
|
||||
|
||||
Naheliegend wäre gewesen, die WebP-Varianten der Mediathek zu verwenden. Dagegen sprechen zwei Dinge: die kleinste Variante ist 480 px breit (für ein 32-px-Symbol zwei Größenordnungen zu viel), und ein Favicon ist **quadratisch** – ein breites Logo würde gestaucht oder zur Briefmarke.
|
||||
|
||||
**Deshalb entstehen die Symboldateien beim Speichern der Einstellung, nicht beim Aufruf**: ein mittiger quadratischer Ausschnitt in 32 px (Reiter) und 180 px (Startbildschirm), als PNG, weil das jeder Browser liest. Schlägt das fehl, wird die Einstellung gar nicht erst gespeichert – ein Verweis auf ein Symbol, das es nicht gibt, wäre schlechter als das alte Symbol. Ausgeliefert werden sie unter `/favicon.png` und `/apple-touch-icon.png`, mit der Bildkennung als Version am Verweis, damit ein Wechsel im Browser ankommt.
|
||||
|
||||
Nebenwirkung, die die Tests fanden: `SiteSettings` fragt die Datenbank jetzt auch aus der Nicht-gefunden-Seite heraus – und **vor** der Installation gibt es die Tabelle noch nicht. Eine gezielt gefangene `TableNotFoundException` liefert dort die Standardwerte; jeder andere Datenbankfehler bleibt ein Fehler.
|
||||
|
||||
## ADR 054 – Unterstrichene Links: nur im Fließtext, und dort abschaltbar (13.09.2026)
|
||||
|
||||
Umut: „und links haben alle underlined“. Stimmte: `a` hatte keine eigene Textdekoration, also entschied der Browser – und der unterstreicht alles. Sichtbar wurde das an Stellen, wo ein Unterstrich nur wie ein Fehler aussieht: im „Ansehen“-Streifen über einem Kartenbild, im Menü bei der aktuellen Seite, in der Rechtszeile des Fußes, neben dem Telefonsymbol im Kontaktblock.
|
||||
|
||||
Die Trennlinie ist nicht Geschmack, sondern Funktion: **ein Link mitten im Fließtext ist ohne Unterstrich nur an seiner Farbe zu erkennen** – für Menschen mit Farbsehschwäche also gar nicht (WCAG 1.4.1). Ein Link, der als Karte, Knopf, Menüeintrag oder Kontaktzeile mit Symbol auftritt, ist an Form und Ort erkennbar und braucht keinen.
|
||||
|
||||
Also: strukturelle Links ohne Unterstrich (beim Überfahren erscheint er), Fließtext mit – und für den Fließtext **ein Schalter im Designbereich** statt einer stillen Entscheidung, mit dem Hinweis, was man sich damit einhandelt. Die aktuelle Seite im Menü wird über das Schriftgewicht kenntlich und nicht mehr über die Akzentfarbe: die reichte im dunklen Fußmenü nicht für den Kontrast – gefunden von der Browserprüfung, nicht von mir.
|
||||
|
||||
## ADR 053 – Eine Website ohne Beiträge redet nicht von Beiträgen (13.09.2026)
|
||||
|
||||
Umut: „Können wir Beiträge optional machen? Wenn's keine gibt, überall ausblenden – ist das schon so?“ Teilweise: die Sitemap ließ das Archiv weg, `llms.txt` auch. Der Rest nicht – `/beitraege` antwortete mit einer leeren Seite, `/feed.xml` mit einem Feed ohne Einträge, jede Seite verwies im Kopf auf diesen Feed, und ein Beitragslisten-Baustein zeigte öffentlich den Kasten „Hier erscheinen die Beiträge, sobald welche veröffentlicht sind“.
|
||||
|
||||
**Eine Adresse, die nichts zeigt, ist kein Inhalt, sondern eine Baustelle mit eigener Adresse.** Archiv und Feed antworten deshalb mit 404, solange sie nichts zu zeigen haben – das gilt auch für eine einzelne Kategorie, deren letzter Beitrag gelöscht wurde: der Begriff bleibt als wiederverwendbare Beschriftung bestehen, sein Archiv verschwindet. Der Verweis auf den Feed steht nur im Kopf, wenn es ihn gibt. Der Hinweiskasten bleibt – aber **nur im Editor**, denn dort erklärt er die leere Stelle, während er öffentlich nur ein Versprechen wäre.
|
||||
|
||||
Beim Nachmessen fiel eine zweite Sache auf, die nichts mit Beiträgen zu tun hat: die Beitragsliste fragte **je Beitrag** nach seinem Bild und seinen Begriffen. Zwölf Beiträge kosteten 24 Abfragen, und `neighbours()` (Vor/Zurück) lädt bis zu 500 Beiträge. Bilder und Begriffe kommen jetzt in je einer Abfrage für die ganze Liste: die Startseite des Nachbaus fiel von 22 auf 16 Abfragen.
|
||||
|
||||
## ADR 052 – Einblenden beim Scrollen, ohne eine Zeile JavaScript (13.09.2026)
|
||||
|
||||
Umut: „Effekte gibt es keine, sind die aktiv aktuell?“ Nachgemessen statt geraten: die Zeigereffekte liefen alle (Karte −8 px, Bild 1,06×, Symbolkarte −4 px, Knopf −2 px, Titel in Akzentfarbe). Die **Einblendungen beim Laden** gab es seit Dokumentschema v3 – und **benutzt hatte sie niemand**. Ein Werkzeug, das im Kasten bleibt, ist kein Werkzeug.
|
||||
|
||||
Der Grund, warum es im Kasten blieb, war ein echter: eine Animation, die beim *Laden* läuft, ist unterhalb der Falz wertlos – sie ist vorbei, bevor jemand hinsieht. Öffentliche Seiten laufen ohne JavaScript, also gab es bisher nichts, was den Scrollstand kennt.
|
||||
|
||||
**Inzwischen kennt CSS ihn.** `animation-timeline: view()` bindet den Ablauf einer Animation an die Sichtbarkeit des Elements: dieselben Keyframes, aber sie laufen, wenn der Abschnitt in den Blick kommt. Dreifach abgesichert: hinter der Bewegungsschranke, hinter `@supports` (Firefox und Safari behalten heute die Fassung von vorher – Einblenden beim Laden, nichts fehlt), und mit einem Bereich, der endet, bevor der Abschnitt in der Bildschirmmitte steht.
|
||||
|
||||
**Und dann hat die Prüfung etwas gefunden, das ich selbst gebaut hatte.** Eine Scroll-Zeitachse ist **umkehrbar**: ein Abschnitt außerhalb des Blicks steht wieder am Anfang der Kurve. Mit der ursprünglichen Kurve (`opacity: .35`) hieß das: alles unterhalb der Falz ist dauerhaft blass – und blasser Text ist kein Effekt, sondern schlechterer Kontrast. axe meldete prompt fünfzehn Verstöße. Der Versuch mit 0,85 half nicht: aus `#3d4d5c` wird bei 85 Prozent `#78838d`, also 3,86:1 statt 7,1:1.
|
||||
|
||||
**Auf dem Scrollweg wird deshalb keine Deckkraft animiert, nur Bewegung.** 20 px für „rise“, 10 px für „fade“, Deckkraft bleibt 1. Der Effekt trägt trotzdem, und der Text ist an jeder Stelle lesbar – auch wenn er gerade nicht zu sehen ist. Beim Laden (ohne Scroll-Zeitachse) bleiben die alten Kurven mit Deckkraft, weil sie dort einmalig laufen und nicht zurückspulen.
|
||||
|
||||
**Benutzt wird es jetzt auch:** alle drei Beitragsvorlagen bringen die Einblendung je Abschnitt mit, und der Nachbau von kant-it.de hat sie auf jeder Seite – im Hero gestaffelt, darunter je Abschnitt.
|
||||
|
||||
## ADR 051 – Vier Darstellungen für dieselbe Beitragsliste (13.09.2026)
|
||||
|
||||
Umut wollte auch für die Auflistung mehrere Formen. Dieselben Daten, dasselbe Markup – nur eine Klasse wechselt. Eine Darstellung kostet dadurch ein paar Regeln CSS und keine Zeile PHP.
|
||||
|
||||
- **Karten** – Raster mit Bild oben. Für eine Übersicht.
|
||||
- **Liste** – Bild links, Titel und Auszug rechts. Für ein Archiv.
|
||||
- **Kompakt** – nur Titel und Datum, durch Linien getrennt, ohne Bilder. Für eine Randspalte oder ein langes Verzeichnis.
|
||||
- **Wechselseitig** – großes Bild, Text daneben, und bei jedem zweiten Beitrag die Seiten getauscht.
|
||||
|
||||
**Die erste Fassung von „Wechselseitig" war ein umgedrehter Flexkasten und sah auch so aus.** Umut hat es zu Recht hässlich genannt. Was gefehlt hat, war nicht die Umkehrung, sondern alles andere: das Bild sitzt jetzt in einer Karte mit Ecken und weichem Schatten in festem Verhältnis 16:10, damit die Reihen gleich hoch sind; der Text bekommt eine Nummer aus einem CSS-Zähler, eine begrenzte Zeilenlänge und einen sichtbaren Verweis; zwischen den Reihen liegt eine feine Linie. Beim Zeigen zieht das Bild auf und der Titel nimmt die Akzentfarbe – die Reihe selbst bleibt stehen, denn sie ist keine Karte.
|
||||
|
||||
**Zwei Details, die der Bau gelehrt hat:** die Karte *ist* das Listenelement, also zählt `:nth-child` auf ihr und nicht eine Ebene tiefer – die erste Fassung wechselte deshalb gar nicht. Und die Umkehrung läuft über `order` der beiden Spalten statt über `row-reverse`, weil sich damit auf schmalen Bildschirmen sauber wieder Bild-über-Text ergibt.
|
||||
|
||||
**Die Nummer kommt aus einem Zähler, nicht aus dem Inhalt.** Auf Seite zwei beginnt sie wieder bei 01. Das ist ehrlich: der Beitrag weiß nicht, der wievielte er ist, und eine Nummer, die das behauptet, wäre erfunden.
|
||||
|
||||
## ADR 050 – Drei Aufbauten für einen Beitrag (13.09.2026)
|
||||
|
||||
Umut: „ich hätte gerne mehrere Designs zur Auswahl, 2–3 Stück für Beiträge.“
|
||||
|
||||
**Warum das bei Beiträgen sinnvoll ist und bei Seiten nicht:** bei einer Seite weiß vorher niemand, was daraus wird – ein leeres Dokument ist dort die ehrliche Voreinstellung. Ein Beitrag hat dagegen fast immer dieselbe Form: Kopf, Text, Bilder, ein Weg weiter. Wer jedes Mal von vorn anfängt, baut jedes Mal etwas leicht anderes, und nach zwanzig Beiträgen sieht ein Archiv aus wie zwanzig Websites.
|
||||
|
||||
**Die drei:** *Bildkopf* (Bild über die volle Breite, Titel darauf – für Projekte), *Klassisch* (Titel und Vorspann schmal, Bild darunter – für lange Texte), *Magazin* (Titel neben den Eckdaten – wenn Kunde, Leistung und Jahr zählen). Gewählt wird beim Anlegen, im selben Formular wie Titel und Adresse.
|
||||
|
||||
**Es sind Startpunkte, keine Schablonen.** Jeder Block lässt sich danach verschieben, ändern und löschen wie jeder andere. Festgelegt wird nur die erste Entscheidung – und die ist die, die am schwersten nachzuholen ist.
|
||||
|
||||
**Kein Aufbau verlässt sich auf ein Bild, das es noch nicht gibt.** Wo ein Beitragsbild hingehört, steht ein Platzhalter; der dunkle Kopf ohne Bild sieht schlicht aus statt kaputt. Ein Aufbau, der erst nach dem ersten Upload funktioniert, wäre beim Anlegen wertlos.
|
||||
|
||||
**Und die Regel, die dabei gefallen ist:** der erste Textblock eines Abschnitts wurde automatisch zur Kleinbeschriftung in Akzentfarbe. Das gestaltet nach **Position statt nach Absicht** – im Fußbereich wurde daraus roter Versalientext für den Firmennamen, in der Magazin-Vorlage aus dem Einstiegsabsatz eine Kleinbeschriftung. Beides sah aus wie ein Fehler, weil es einer war. Wer eine Beschriftung will, stellt sie ein; dafür gibt es den Designbereich. Die Demoseite verliert dadurch ihre Versalienzeilen und wirkt ruhiger – dasselbe Ergebnis wie beim Rahmen der Spalten.
|
||||
|
||||
## ADR 049 – Die Beitragsseite, und warum das kein Plugin wurde (Dokumentschema v18, 13.09.2026)
|
||||
|
||||
Umut: „Beitragsseite gefällt mir nicht, sollte wie bei kant-it.de sein oder besser. Falls nötig Plugin bauen.“
|
||||
|
||||
Nachgesehen: die Originalseite besteht aus Kopfbild, Text, **Bildergalerie**, **Vor/Zurück zwischen Projekten** und einem **Abschlussaufruf**. Galerie und Aufruf gab es als Bausteine längst – sie waren nur nicht benutzt. Die Navigation zwischen Beiträgen gab es nicht.
|
||||
|
||||
**Warum das kein Plugin geworden ist.** Ein Plugin ist in diesem CMS ein Bausteintyp mit Namensraum, dessen Eigenschaften der Kern *aufbewahrt, aber nicht rendert* – genau damit eine abgeschaltete Erweiterung keine Seite unlesbar macht. Ein Plugin kann also nichts ausgeben, solange es keine Laufzeit dafür gibt (Arbeitspaket H). Und die Nachbarn eines Beitrags sind keine Fremdfunktion: sie stehen in derselben Tabelle wie die Beitragsliste, die der Kern schon füllt. Ein Plugin dafür wäre eine Hülle um eine Kernabfrage gewesen. Also ein Baustein: `postnav`, Dokumentschema v18.
|
||||
|
||||
**Die Regel der Beitragsliste gilt auch hier: der gespeicherte Inhalt kennt keinen Beitrag.** Gespeichert sind zwei Beschriftungen, mehr nicht. Welcher Beitrag vorher und nachher kommt, entscheidet der Renderer beim Ausliefern – sonst könnte ein altes Dokument auf einen Beitrag zeigen, den es nicht mehr gibt oder der nie veröffentlicht wurde.
|
||||
|
||||
**Gesucht wird innerhalb derselben Kategorie:** von einem Projekt führt man zum nächsten Projekt, nicht zur nächsten Pressemitteilung. **Am Rand wird nicht umgebrochen** – der neueste Beitrag hat keinen Vorgänger. Ein Ring wäre bequem zu programmieren und eine Falle für den Leser, der glaubt, er sei am Anfang.
|
||||
|
||||
**Dabei zwei Dinge gelernt:** `present()` gibt für eine Liste bewusst keine Kennung heraus, nur Titel und Adresse – die Nachbarsuche vergleicht deshalb über die Adresse. Und die Abfrage kostet etwas, also läuft sie nur, wenn die Seite den Baustein wirklich enthält.
|
||||
|
||||
**Was ich zusätzlich geändert habe, ohne gefragt zu werden:** die Absätze der übernommenen Projekttexte begannen mit dem wiederholten Titel und der Zwischenüberschrift, weil beides im selben Absatz stand – 20 Beiträge betroffen. Und schmale Abschnitte standen am linken Rand statt in der Mitte: `max-width` ohne `margin:auto` ist eine halbe Einstellung.
|
||||
|
||||
## ADR 047 – Ein Slider, der von selbst weiterblättert (13.09.2026)
|
||||
|
||||
Umut wollte die Autorotation. Die Regel „öffentliche Seiten laufen ohne JavaScript“ steht seit dem ersten Tag, und sie bleibt – **mit einer Ausnahme, die es schon gab**: die Tabs laden ein kleines Skript, aber nur auf Seiten, die Tabs enthalten. Der Slider folgt genau diesem Muster.
|
||||
|
||||
**Die Einstellung sind Sekunden, kein Häkchen.** 0 heißt aus, unter drei Sekunden wäre es keine Hilfe mehr, sondern Hektik. Die Zahl steht im Baustein, nicht im Skript.
|
||||
|
||||
**Was das Skript nicht tut, ist der interessantere Teil:**
|
||||
- Es läuft **gar nicht**, wenn das System weniger Bewegung verlangt.
|
||||
- Es hört **endgültig** auf, sobald jemand tippt, klickt, wischt oder von Hand scrollt. Nicht „pausiert kurz“ – aus. Wer selbst blättert, hat entschieden.
|
||||
- Es pausiert, solange der Zeiger darin ist, solange etwas darin den Fokus hat, solange der Tab im Hintergrund liegt und solange der Slider nicht zu sehen ist.
|
||||
|
||||
**Ohne das Skript bleibt alles benutzbar**: wischen, scrollen, Tastatur, Punkte. Es kommt nur eine Bequemlichkeit dazu, es hängt nichts davon ab.
|
||||
|
||||
**Und ein Fehler beim Bauen, der zeigt, warum man so etwas nachmisst:** die erste Fassung rechnete das Sprungziel aus `offsetLeft` und zog zusätzlich den Offset des Sliders ab – bei einem Slider, der selbst der Bezugsrahmen ist, kam dabei −78 heraus, also gar keine Bewegung. Gemessen wird jetzt über die Bildschirmrechtecke. Der Test dazu ist kein Schnappschuss, sondern ein Verlauf: 0 → 666 → 1332 → 1998 in drei Schritten, und nach einem Mausrad-Ereignis bleibt der Wert stehen.
|
||||
|
||||
## ADR 048 – Eine Beitragsliste darf blättern (13.09.2026)
|
||||
|
||||
Umut: „fehlt die Pagination bei Projekte? Es werden nur 12 angezeigt.“ Richtig, und es widersprach der eigenen Regel aus Arbeitspaket M: *eine feste Obergrenze ist kein Blättern, sie verschweigt, dass etwas fehlt.* Für die Verwaltung war das behoben, für die Beitragsliste einer Seite nicht.
|
||||
|
||||
Der Unterschied ist absichtlich und heißt jetzt `paginate`: eine **Auswahl** auf der Startseite zeigt die neuesten vier und hört auf – dort will niemand blättern, dort will man weiter zur Übersicht. Eine **Übersichtsseite** blättert und sagt dabei, wie viele es insgesamt sind.
|
||||
|
||||
Zwei Kleinigkeiten, die dabei wichtig waren: eine blätternde Liste nimmt die Seitenzahl aus der Anfrage, eine Auswahl immer die erste – sonst würden zwei Listen auf derselben Seite einander umblättern. Und eine Seitenzahl jenseits des Endes landet auf der letzten Seite statt im Leeren.
|
||||
|
||||
**Beim Bauen zum zweiten Mal dieselbe Falle:** die Seitenzahl war in der Schließung, die die Blöcke durchläuft, nicht gebunden – genau wie vorgestern beim Filter für Überschriften. Diesmal fiel es sofort auf, weil die Seite mit 500 antwortete statt still das Falsche zu tun.
|
||||
|
||||
## ADR 046 – Der eigene Blick: was ich vor einem Livegang ändern würde (13.09.2026)
|
||||
|
||||
Umut: „schau selbst drüber, ohne zu vergleichen – würdest du so online gehen?“ Antwort: **so nicht.** Fünf Dinge sind mir aufgefallen, und drei davon waren Fehler im CMS, nicht im Inhalt.
|
||||
|
||||
**1. Verborgener Text stand sichtbar auf der Seite.** `.visually-hidden` gibt es in `admin.css`, aber **nicht in `site.css`** – auf jeder öffentlichen Seite waren damit die Zusätze zu lesen, die nur für Vorlesesoftware gedacht sind: „Bewertung: 5 von 5“ neben den Sternen, „(Pflichtfeld)“ hinter jedem Formularfeld. Das ist der peinlichste Fund der Reihe, weil er seit dem ersten Formular dasteht und in keinem Test auffiel: axe prüft, ob etwas **angesagt** wird, nicht ob es fälschlich **sichtbar** ist.
|
||||
|
||||
**2. Jede Spalte war ein Kasten.** `.block-column` brachte Rahmen, Ecken und 30 px Innenabstand mit. Eine Spalte ist aber ein Layoutmittel – wer damit zwei Textblöcke nebeneinander stellt, bekam zwei umrandete Kästchen und fragt sich, warum seine Seite aussieht wie ein Formular. Die Karte ist jetzt das, was sie sein sollte: eine Einstellung, kein Standard. Die Demoseite sieht dadurch ruhiger aus, nicht ärmer.
|
||||
|
||||
**3. Der Knopf im Aufruf-Band war unsichtbar.** Er nahm die Tintenfarbe – auf einem dunklen Band also Tinte auf Tinte, ausgerechnet beim Handlungsaufruf. Aufruf-Band und Formular nehmen jetzt die Akzentfarbe.
|
||||
|
||||
**4. Gestapelte Karten klebten aneinander.** In einer Zeile sorgt das Raster für Abstand, untereinander gab es keinen. Aufeinanderfolgende Karten bekommen 18 px.
|
||||
|
||||
**5. Und mein eigener Inhalt:** die linke Spalte im Kontaktbereich war zur Hälfte leer, weil sie sich auf die Höhe des Formulars zog. Sie steht jetzt oben und trägt drei Karten, die die Frage beantworten, die man an dieser Stelle hat: was passiert, wenn ich abschicke?
|
||||
|
||||
**Was ich bewusst gelassen habe:** die Einwilligung liegt weiterhin als Kasten mitten auf der Seite. Das ist richtig so – sie ist eine Entscheidung, keine Verzierung –, aber sie verdeckt auf jedem Bildschirmfoto den Inhalt dahinter. Wer die Seite abnimmt, sollte sie einmal wegklicken, bevor er urteilt.
|
||||
|
||||
## ADR 045 – Der Zeigereffekt, und warum Feinschliff kein Zusatz ist (13.09.2026)
|
||||
|
||||
Umut: „es gibt keinen Mausover-Effekt wenn man auf einen Beitrag geht, das fehlt … grob siehts ähnlich aus, aber im Detail fehlen viele Kleinigkeiten.“ Das ist die richtige Beobachtung: eine Karte, die sich beim Zeigen nicht rührt, wirkt kaputt, und zwar bevor jemand sagen kann, warum.
|
||||
|
||||
**Die Beitragskarte reagiert jetzt**, und zwar wie im Original nachgemessen: die Karte hebt sich um 8 px, bekommt einen weichen Schatten, das Bild zieht auf 1,06 auf, und über dem Bild blendet ein Hinweis ein. Dauer und Kurven sind aus dem Original übernommen (0,28 s für die Karte, 0,5 s für das Bild, 0,3 s für den Hinweis).
|
||||
|
||||
**Drei Entscheidungen dabei:**
|
||||
|
||||
- **Die Bewegung steht vollständig hinter der Bewegungsschranke.** Wer im Betriebssystem weniger Bewegung eingestellt hat, bekommt keine sanftere Fahrt, sondern keine – aber den Hinweis über dem Bild trotzdem, denn der ist Information und keine Verzierung. Ein Test prüft genau diese Trennung: kein `translateY` außerhalb der Schranke.
|
||||
- **Auch mit der Tastatur.** Alles hängt zusätzlich an `:focus-within`, sonst wäre der Effekt eine Belohnung für Mausbenutzer.
|
||||
- **Der Hinweis ist für Vorlesesoftware ausgeblendet.** Er wiederholt nur, wohin der Titel darunter ohnehin führt; zweimal dasselbe Ziel anzusagen hilft niemandem.
|
||||
|
||||
**Der Hero war das falsche Bild.** Im Original ist er kein Foto, sondern Marineblau mit einer Binärtextur bei 10 Prozent und einem roten Schein oben rechts. Nachgebaut mit den Mitteln, die der Designer hat: dasselbe Bild als Textur, die Überlagerung auf 93 Prozent, und ein Verlauf schräg darüber für den Schein. Die Augenbraue ist im Original eine **Pille** mit Rahmen, kein nackter Text – auch das ließ sich ohne neue Funktion bauen.
|
||||
|
||||
**Und die Effekte, die es längst gibt, waren schlicht nicht benutzt.** `layout.effects` kennt Anheben, Zoom, Schatten und Einblenden seit Dokumentschema v3; im Nachbau standen sie auf „nichts“. Jetzt heben sich Leistungskarten und Lösungskarten beim Zeigen. Das ist keine Änderung am CMS, sondern die Antwort auf die Frage, warum es „im Detail“ fehlte: ein Werkzeug, das man nicht in die Hand nimmt, ist so gut wie nicht vorhanden.
|
||||
|
||||
**Nebenbei entfernt:** der Leuchteffekt (`fx-hover-glow`) trug noch das Grün der Vorlage statt der Akzentfarbe der Website.
|
||||
|
||||
**Nachgetragen am 13.09.2026:** Umut meinte den Kopf **beim Öffnen eines Beitrags**, nicht den der Startseite. Im Original füllt dort das Projektbild den ganzen Kopf (75 vh), darüber liegt ein Verlauf von fast durchsichtig oben nach fast deckend unten, und darin stehen ein Badge in der Akzentfarbe, der Titel und eine 60 × 4 Pixel kurze Linie. Nachgebaut mit den vorhandenen Mitteln – Hintergrundbild, Verlauf, Flexausrichtung –, der Text mittig gesetzt. Dabei fiel auf, dass die Zeile mit Datum, Autor und Kategorie oberhalb davon stand: bei einem Beitrag, der seinen Titel im Bild trägt, ist sie doppelt. Sie ist jetzt abschaltbar (`postMetaHeader`), Voreinstellung bleibt an.
|
||||
|
||||
## ADR 044 – Genauer hinsehen: Punkte statt Rollleiste, Karten statt Zeilen (13.09.2026)
|
||||
|
||||
Umut hat den Nachbau Detail für Detail mit dem Original verglichen, und er hatte in jedem Punkt recht. Was dabei herauskam, sind nicht Geschmacksfragen, sondern vier Stellen, an denen das CMS etwas nicht konnte oder etwas Falsches tat.
|
||||
|
||||
**Ein Slider ist kein Schiebefeld.** Der Slider war ein Scrollbereich mit sichtbarer Rollleiste – im Hero sah das aus wie ein Versehen. Das Original hat Punkte. Punkte gibt es jetzt, und zwar **ohne Skript**: `scroll-marker-group` und `::scroll-marker` lassen den Browser je Folie einen Punkt zeichnen, der aktive ist über `:target-current` erkennbar, und ein Klick scrollt. Wo ein Browser das noch nicht kennt (heute Firefox und Safari), bleibt ein wisch- und tastaturbedienbares Schiebefeld – also kein Ausfall, nur weniger Komfort. Die Rollleiste ist dort ausgeblendet, wo Punkte die Orientierung übernehmen.
|
||||
|
||||
**Pfeile gibt es bewusst nicht.** `::scroll-button()` existiert, aber Chrome legt die Knöpfe weder absolut noch klebend in den Slider, sondern an den Rand der Seite. Lieber keine Pfeile als ein Knopf, der am Seitenrand schwebt.
|
||||
|
||||
**Was der Slider nicht kann, steht hier:** er rotiert nicht von selbst. Eine öffentliche Seite lädt kein JavaScript, und eine CSS-Animation, die sich nicht anhalten lässt, wäre ein Barrierefreiheitsproblem statt einer Lösung.
|
||||
|
||||
**Eine Beitragskarte war keine Karte.** Sie war eine Spalte aus Bild, Überschrift und Text ohne Rahmen. Im Original ist sie eine Karte: weiß, 18 px Ecken, Bild bündig oben im Verhältnis 16:10, Text in einem eigenen Feld darunter. Genau so sieht sie jetzt aus – und zwar für jede Website, weil es die Darstellung des Bausteins ist und keine Einstellung dieser einen Seite.
|
||||
|
||||
**Die Schriftgrößen der Bausteine verloren gegen die Theme-Skala.** `.kant h3` aus dem Theme und `.block-iconcard h3` aus `site.css` hatten dieselbe Spezifität, und der Themeblock steht später im Dokument – also gewann er. Jede Kartenüberschrift war so groß wie eine Abschnittsüberschrift, auf jeder Website mit eigener Schriftskala. Die Bausteinregeln tragen jetzt `.kant` davor und gewinnen wieder.
|
||||
|
||||
**Eine Überschrift darf ein Wort hervorheben.** Im Original ist die zweite Zeile der Hauptüberschrift rot. Dafür dürfen Überschriften ab v17 Auszeichnungen tragen – durch denselben Filter wie jeder Text. **Dabei wäre beinahe eine Lücke entstanden:** die Fassung des Dokuments war in der Schliessung, die den Filter läuft, gar nicht vorhanden; der Filter lief nie, und der Inhalt wird unescaped ausgegeben. Der Test, der ein `<script>` in eine Überschrift schreibt, hat es sofort gezeigt – und er bleibt genau dafür stehen. Zusätzlich entscheidet die Ausgabe jetzt an einer eigenen Fahne (`richHeadings`), damit eine ältere Seite ihre Überschrift weiterhin wörtlich zeigt.
|
||||
|
||||
## ADR 043 – Was beim Nachbauen noch fehlte (Dokumentschema v17, 13.09.2026)
|
||||
|
||||
Umut hat den Nachbau angesehen und vier Dinge benannt: der Abstand zwischen den Lösungskarten und der Projektliste fehlt, die Karten sind zu hoch, die Beiträge sehen nicht aus wie im Original, und später: im Hero fehlen die letzten Projekte als Slider, der Kopfbereich klebt nicht beim Scrollen, das Logo verlinkt nicht auf die Startseite. Zwei davon waren mein Aufbau, vier waren fehlende Fähigkeiten.
|
||||
|
||||
**Eine Beitragsliste kann jetzt ein Projektraster sein.** Im Original zeigt eine Projektkarte Bild und Titel. Die Beitragsliste zeigte immer zusätzlich Datum, Autor, Auszug und Kategorie – richtig für ein Blogarchiv, falsch für eine Projektseite, und nicht abstellbar. Drei Schalter (`showMeta`, `showExcerpt`, `showTerms`) beantworten das, mit den bisherigen Werten als Voreinstellung, damit bestehende Seiten sich nicht ändern.
|
||||
|
||||
**Dabei wäre es beinahe schiefgegangen, und zwar lautlos:** `{{ block.props.showMeta|default(true) }}` gibt in Twig auch dann `true` zurück, wenn der Wert `false` ist – der `default`-Filter greift auf jeden leeren Wert, nicht nur auf einen fehlenden. Die Einstellung hätte sich speichern lassen und nichts getan. Mit `??` statt `|default()` bleibt `false` ein `false`. Der Test prüft beide Richtungen, weil nur die Gegenprobe diesen Fehler findet.
|
||||
|
||||
**Ein Bild darf verlinken.** Klingt selbstverständlich und fehlte: ein Logo im Kopfbereich ohne Verweis auf die Startseite ist der erste Klick, den jeder sucht. Die Adresse geht durch dieselbe Prüfung wie jeder andere Link, also kein `javascript:`, kein `data:`, kein schemaloses `//fremd.test`.
|
||||
|
||||
**Der Kopfbereich kann beim Scrollen oben bleiben.** Das ist bewusst eine Einstellung der Website und keine Eigenschaft eines Blocks: kleben kann nur das Element, das den Bereich umschließt (`.site-header`), und ein Abschnitt *innerhalb* davon würde nur solange kleben, wie sein Elternelement sichtbar ist – also gar nicht. Wer es im Designer an einem Abschnitt versucht hätte, hätte eine Einstellung gefunden, die nichts tut.
|
||||
|
||||
**Der Slider im Hero** brauchte nichts Neues: vier Folien mit Bild und Titel im vorhandenen Slider-Baustein. Ehrlich dazugesagt: **er rotiert nicht von selbst.** Öffentliche Seiten laufen ohne JavaScript, und daran ändert eine Animation nichts – gewischt, gescrollt und mit der Tastatur bedient wird er, und er rastet je Folie ein. Eine automatische Rotation wäre als reine CSS-Animation möglich, aber sie wäre nicht anhaltbar, und ein Karussell, das sich nicht anhalten lässt, ist ein Barrierefreiheitsproblem.
|
||||
|
||||
**Und ein Fehler, der größer ist als alles andere in diesem Paket:** die Liste bekannter Schemafassungen im Editor endete bei **12**, während Bausteine längst v13 bis v15 verlangten. Folge: **in einem aktuellen Dokument ließ sich im Editor gar kein Baustein mehr einfügen** – jeder Versuch endete mit „Unbekanntes Dokumentschema“ – und Symbol, Fortschritt, Slider und eingebetteter Inhalt nirgends. Der Playwright-Editortest hat es nicht gefunden, weil er auf einem alten Dokument arbeitet und die betroffenen Typen nicht einfügt. Jetzt steht die höchste Fassung als **eine Zahl an einer Stelle** (`SCHEMA_VERSION`), und ein Test fügt jeden Bausteintyp in Dokumente von v1 bis v17 ein.
|
||||
|
||||
## ADR 042 – Der Nachbau von kant-it.de, und was er gefunden hat (12.09.2026)
|
||||
|
||||
Umut: „Baue mal lokal kant-it.de komplett nach. Ich möchte sehen ob das funktioniert.“ Gebaut ist die Startseite mit allen neun Abschnitten, die Projekteseite, vier Projektbeiträge, Impressum und Datenschutzgerüst, Kopf- und Fußbereich, zwei Menüs, das Kontaktformular, zwölf Bilder und die Palette der echten Website – in einer eigenen Datenbank, damit die Entwicklungsinstallation unberührt bleibt.
|
||||
|
||||
**Das Ergebnis der Übung sind nicht die Bilder, sondern fünf Fehler, die sonst ein Kunde gefunden hätte.** Eine echte Website zu bauen ist eine andere Prüfung als ein Test: sie benutzt alles gleichzeitig.
|
||||
|
||||
**1. Die Gestaltung färbte die Bausteine nicht.** Symbole, Knöpfe, Rahmen, ruhiger Text und der Einwilligungsdialog kamen aus festen Token in `site.css` – eine Website mit roter Palette behielt das Grün der Vorlage. Die Farben ließen sich einstellen und taten nichts, dieselbe Familie wie Rahmenstärke und Bildrundung (ADR 036). Das Theme setzt jetzt `--ink`, `--paper`, `--accent`, `--muted`, `--line` und `--mint` selbst. Als Akzent dient die **Linkfarbe**, nicht ein Palettenname: dieses Token färbt auch kleine Beschriftungen, und eine helle Akzentfarbe wäre dort nicht mehr lesbar. `--muted` und `--line` werden aus Textton und Hintergrund gemischt; eine Palettenfarbe gleichen Namens gewinnt.
|
||||
|
||||
**2. Der Einwilligungsdialog war grün**, unabhängig von der Website. Er nimmt jetzt die Primärfarbe – und lässt sich eigens färben, indem im Design eine Farbe mit dem Schlüssel `consent` angelegt wird. Kein zusätzlicher Schalter, nur eine Regel, die eine Palettenfarbe bevorzugt.
|
||||
|
||||
**3. „Di-gitale Exzellenz“.** `hyphens:auto` galt für h1 bis h3. Im Fließtext ist Trennung nützlich, in einer 64-Pixel-Überschrift sieht sie nach einem Defekt aus – und genau dort fällt sie auf. Überschriften trennen nicht mehr.
|
||||
|
||||
**4. Ruhiger Text auf dunklem Grund.** `.block-text` hatte fest `color:var(--muted)`, eine Farbe für helle Hintergründe. In einem dunkel gefärbten Abschnitt stand damit Grau auf Marineblau. Jetzt wird die Farbe aus der geerbten gemischt (`currentColor` zu 82 Prozent), womit dieselbe Regel auf hellem und dunklem Grund funktioniert. Dasselbe gilt für die automatische Kleinbeschriftung des ersten Textblocks: sie mischt den Akzent in die geerbte Farbe, statt ihn zu setzen.
|
||||
|
||||
**5. Ein Kontrastfehler, den ich selbst eingebaut hatte.** Die erste Ableitung von `--muted` mischte zu 72 Prozent und ergab für eine Palette mit hellem Textton 4,09:1 statt 4,5:1. axe hat ihn im selben Durchlauf gefunden, in dem die Farben zum ersten Mal wirkten. Jetzt 80 Prozent.
|
||||
|
||||
**Was sich nicht als Fehler herausstellte, aber auffällt:** Ecken und Seiten müssen einzeln angegeben werden (`borderRadius` als Kurzschreibweise wird abgewiesen) – der Designer schreibt das ohnehin so, von Hand ist es überraschend. Und die Beschriftung eines Einwilligungsfeldes ist auf 120 Zeichen begrenzt; der Einwilligungssatz der echten Website hat 148. Der Rest musste in den Hilfetext.
|
||||
|
||||
**Was der Nachbau belegt:** die Seite läuft mit **null axe-Verstößen** auf vier Seiten in Desktop- und Handybreite, **ohne JavaScript**, mit Bildern als WebP in vier Breiten und einer Startseite, die dem Original so nahe kommt, dass der Unterschied in der Typografie liegt und nicht in der Technik. Was fehlt, ist ehrlich zu benennen: die vollständige Datenschutzerklärung (das Gerüst ist da, der Text der laufenden Website ist länger), die übrigen Projektbeiträge (vier von fünf Seiten Archiv) und die Unterseiten von HausKlar und KantTresor.
|
||||
|
||||
## ADR 041 – Der Installer: sechs Bildschirme, und danach ist er weg (12.09.2026)
|
||||
|
||||
Umut: „Einen Installer brauche ich noch wie wordpress schritt für schritt alles einrichten migration ausführen usw.“ und, später, „einen installer das eine php ist und alle dateien herunterladet“. Beides ist gebaut, und beides hängt an derselben Frage: was darf eine Datei dürfen, die auf einem leeren Webspace liegt und von jedem aufrufbar ist?
|
||||
|
||||
**Der Ablauf schreibt jeden Schritt sofort**, statt Antworten in einer Sitzung zu sammeln. Nach dem Datenbankschritt gibt es `.env.local`, nach dem Migrationsschritt Tabellen, nach dem Websiteschritt Inhalte. Ein Neuladen, ein Zeitüberlauf, ein geschlossener Tab verliert damit nichts – der Installer sieht, was schon da ist, und macht an der nächsten fehlenden Stelle weiter. Wer eine spätere Adresse rät, wird zurückgeschickt, und zwar vom Server, nicht von einem Link.
|
||||
|
||||
**Das Ende ist das erste Konto, und es ist ein Zustand der Datenbank, keine Datei.** Eine Datei, die den Installer sperrt, ist eine Datei, die jemand löschen kann – und dann steht auf einer laufenden Website wieder ein Formular, das ein Administratorkonto anlegt. Gibt es ein Konto, antwortet jede Adresse unter `/install` mit 404.
|
||||
|
||||
**Kein Sitzungstoken, sondern eine Unterschrift.** Das Sitzungscookie dieser Anwendung gilt nur für `/admin` – eine bewusste Entscheidung, die öffentliche Seiten cachebar hält –, und für sechs Bildschirme wird sie nicht aufgeweicht. Jedes Formular trägt Schritt und Zeitpunkt, mit `APP_SECRET` unterschrieben, zwei Stunden gültig. Ehrlich dazugesagt: das ist kein CSRF-Schutz, denn wer den Installer erreicht, bekommt auch eine gültige Unterschrift. Der Schutz ist, dass es den Installer nach dem ersten Konto nicht mehr gibt – deshalb steht auf dem letzten Bildschirm, dass sofort nach dem Hochladen installiert werden soll.
|
||||
|
||||
**Migrationen laufen im Prozess, nicht über die Konsole.** Auf geteiltem Hosting gibt es oft keine Shell, und `proc_open` ist ein häufiges Opfer einer gehärteten `php.ini`. Der Installer benutzt dieselbe `migrations.php` und dieselbe `DependencyFactory` wie das Konsolenkommando. Bricht der Browser ab, ist nichts kaputt: angewandte Migrationen sind vermerkt, ein erneuter Aufruf macht weiter.
|
||||
|
||||
**Ein Huhn-Ei-Problem und seine Lösung:** ohne `APP_SECRET` bootet Symfony nicht – also nicht einmal die Seite, die erklären würde, warum. Deshalb erzeugt der erste Start das Geheimnis selbst und schreibt es nach `.env.local`. Geht das nicht, läuft die Anfrage trotzdem, und die Systemprüfung sagt in einem Satz, dass im Projektstamm das Schreibrecht fehlt. Eine weiße Seite wäre die Alternative gewesen.
|
||||
|
||||
**Und eine Weiterleitung statt eines Fehlers:** solange keine Installation existiert, führt jede Adresse zu `/install`. Der erste Besuch auf einem frisch entpackten Webspace ist sonst ein 500 aus einer fehlenden Tabelle – genau der Moment, in dem jemand einen Satz braucht und keinen Stacktrace. Sobald ein Konto existiert, wird eine Marke unter `var/installed` geschrieben und keine Anfrage fragt die Datenbank noch einmal; die Marke schweigt nur diesen Subscriber, der Installer selbst fragt immer die Datenbank.
|
||||
|
||||
**Die einzelne Datei davor** (`install.php`) lädt das Releasearchiv, prüft dessen SHA-256, entpackt es und löscht sich danach selbst. Adresse und Prüfsumme stehen **in der Datei**, eingetragen beim Bauen des Releases: ein Installer, der eine URL aus einem Formular entgegennimmt, ist eine Einladung, fremden Code auf den Server zu legen. Stimmt die Prüfsumme nicht, wird nicht entpackt – es gibt kein Häkchen daran vorbei. Jeder Archiveintrag wird gegen das Zielverzeichnis geprüft, bevor er geschrieben wird; geprüft wird nicht durch Suchen nach „..“, sondern indem der zusammengesetzte Zielpfad unterhalb des Ziels liegen muss. In ein Verzeichnis, in dem schon eine Installation liegt, arbeitet sie gar nicht erst.
|
||||
|
||||
**Was der Installer anlegt, ist nicht die Demoseite.** Eine Startseite mit dem Namen der Website, Kopf- und Fußbereich mit Menü – und Impressum und Datenschutzerklärung als Gerüst mit markierten Lücken. Das Letzte ist kein Entgegenkommen: eine deutsche Unternehmenswebsite ohne beides ist ab dem ersten Tag ein Problem, und die Installation ist der einzige Moment, in dem mit Sicherheit jemand hinsieht.
|
||||
|
||||
## ADR 040 – Bildgrößen, und ein Release für Hosting ohne Composer (12.09.2026)
|
||||
|
||||
Zwei Fragen von Umut in einem Satz: „werden Bilder in verschiedenen Größen angelegt und in WebP umgewandelt?“ und „auf einem Hostingpaket ist kein Composer, meistens, oder?“ – die erste war zur Hälfte schon beantwortet, die zweite ist berechtigt und hatte bis heute keine Antwort.
|
||||
|
||||
**Die Bilder: ja, und jetzt eine Stufe weiter.** Ein Upload wird nie im Original gespeichert. GD decodiert ihn, dreht ihn nach EXIF gerade, behandelt Transparenz und schreibt WebP mit Qualität 82 – bisher in drei Breiten, jetzt in **vier: 480, 960, 1600, 2400**. Die vierte Stufe ist keine Kosmetik: ein vollflächiges Startseitenbild auf einem zweifach skalierten Bildschirm wurde vorher aus 1600 px hochgezogen und sah entsprechend aus. Hochskaliert wird dabei nichts – ist die Quelle 1800 px breit, ist die letzte Stufe 1800 px, nicht 2400.
|
||||
|
||||
Der Bildbaustein liefert `srcset` und `sizes`, der Browser wählt. **Ein Hintergrundbild kann das nicht**, denn CSS hat kein `srcset` – und genau da lag bisher die Lücke: ein Sektionshintergrund bekam immer die größte Variante, auch auf dem Telefon. Da die Gestaltung ohnehin je Gerät geschrieben wird, entscheidet nun das Gerät: Mobil 960, Tablet 1600, Desktop die größte vorhandene. Das ist die richtige Stelle dafür, weil die Regel ohnehin in einem `@media`-Block steht – es kostet kein Byte zusätzlich.
|
||||
|
||||
Der Preis, ehrlich benannt: **vier Stufen kosten Rechenzeit und Platz beim Upload.** Deshalb bleibt die konservative Prüfung gegen das Speicherlimit vor dem Decodieren stehen; sie ist es, die ein Hosting mit 128 MB vor einem Abbruch mitten im Schreiben bewahrt. Ein Test hat das sofort gezeigt, als er im Rahmen der gesamten Suite lief und der Speicher schon halb belegt war – das war kein Testproblem, sondern die Schutzfunktion bei der Arbeit.
|
||||
|
||||
**Composer: Umut hat recht, und das ändert den Auslieferweg.** Geteiltes Hosting hat in aller Regel keinen Composer, oft nicht einmal eine Shell. Die Frage stand seit `docs/UPDATE.md` offen („Composer auf dem Server oder `vendor/` im Tag?“). Sie ist jetzt entschieden: **es gibt ein Releasearchiv.** `scripts/build-release.php` läuft auf einem Rechner, der Composer hat, baut `vendor/` ohne Entwicklungspakete in eine Kopie des Projekts und schreibt `var/releases/kantsites-<Fassung>.zip` samt SHA-256. Im Archiv liegt die Anwendung und sonst nichts: keine Tests, keine Skripte, kein `var/`, kein `.env`. Das Git-Repository bleibt dabei frei von `vendor/` – wer entwickelt, bekommt keine 3000 fremden Dateien in den Diff.
|
||||
|
||||
Daraus folgt eine Regel für den Updater, und sie ist wichtiger als das Archiv selbst: **ein Update, das `composer.lock` ändert, bricht ab, wenn Composer auf dem Server nicht aufrufbar ist.** Vorher hätte er den Code gewechselt und wäre mit einem `vendor/` weitergelaufen, das nicht mehr zum Code passt – der Fehler wäre erst beim ersten Seitenaufruf aufgetaucht, nach dem Rauchtest, als weiße Seite. Jetzt vergleicht der Updater die Prüfsumme der Datei vor und nach dem Checkout und sagt in einem Satz, was zu tun ist: das Releasearchiv einspielen, das `vendor/` mitbringt. Danach läuft der normale Rückweg.
|
||||
|
||||
**Und ein Fehler, den erst der Browsertest zeigte:** der Dokumentprüfer erlaubte drei `sources` je Bild. Die vierte Variante machte damit **jedes Dokument mit einem Bild ungültig** – ohne sichtbare Meldung, denn eine abgewiesene Speicherung sieht aus wie nichts. Das ist dieselbe Familie wie Rahmenstärke und Bildrundung: eine Zahl an einer Stelle, die niemand mit der Stelle verbindet, an der sie herkommt. Die Grenze liest jetzt `MediaLibrary::WIDTHS`, damit sie sich mit der Pipeline bewegt.
|
||||
|
||||
**Nebenbei gefunden:** `CommandRunner` konnte unter Windows `composer` nicht starten, weil das dort eine `.bat` ist und `CreateProcess` keine Stapeldatei ausführt. Ein Kandidat über `%COMSPEC% /c` behebt es – die Argumentliste bleibt fest, es kommt nichts aus einer Anfrage hinein.
|
||||
|
||||
## ADR 039 – Messen statt vermuten: Abfragen, Ladebudget, Blättern (12.09.2026)
|
||||
|
||||
Arbeitspaket M sagte „Abfragen je Seite zählen. Messen, dann zusammenfassen.“ Messen braucht ein Messgerät, also gibt es jetzt eines.
|
||||
|
||||
+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,55 @@
|
||||
# Livegang von kant-it.de
|
||||
|
||||
Stand: 13.09.2026. Diese Liste trennt, was **geprüft ist**, von dem, was **nur auf dem echten Hosting geprüft werden kann** – und in welcher Reihenfolge umgeschaltet wird.
|
||||
|
||||
## Geprüft (lokal, gegen den Nachbau)
|
||||
|
||||
| Prüfung | Ergebnis |
|
||||
| --- | --- |
|
||||
| PHPUnit, PHP 8.2 und 8.4 | 409 Tests, 1791 Zusicherungen, grün |
|
||||
| JavaScript-Modelltests | grün |
|
||||
| Editor durchgespielt (Playwright + axe) | grün, isolierte Datenbank |
|
||||
| Öffentliche Seiten (Playwright + axe, 1440 px und 390 px) | keine Beanstandung |
|
||||
| Interne Verweise (`npm run test:links`) | 35 Seiten, 408 Adressen, kein toter Verweis |
|
||||
| Lighthouse, vier Adressen, je drei Läufe | Barrierefreiheit, Best Practices, SEO, agentic browsing **100** überall; Performance 100 außer Startseite (98 mobil / 96 Desktop) |
|
||||
| Sicherung (`cms:backup`) | angelegt: 33 Seiten, 100 Fassungen, 388 Mediendateien |
|
||||
| Weiterleitungen der alten WordPress-Adressen | 14 Stück angelegt, alle beantwortet mit 301 |
|
||||
|
||||
Gemessen wurde im Produktionsmodus auf dem PHP-Entwicklungsserver, der dieselben Kopfzeilen sendet wie `public/.htaccess`. **Das ist kein Ersatz für eine Messung auf dem echten Hosting.**
|
||||
|
||||
## Nicht geprüft – muss vor dem Umschalten passieren
|
||||
|
||||
1. **E-Mail-Versand.** Nie ausgeführt, in keiner Umgebung. `MAILER_DSN=sendmail://default` funktioniert auf den meisten PHP-Hostings; verlangt der Anbieter SMTP, gehört dort eine Zeile der Form `smtp://benutzer:passwort@mailserver:587` hinein. **Nach dem Hochladen eine echte Anfrage über das Kontaktformular abschicken und prüfen, ob sie ankommt** – auch im Spam-Ordner. Die Anfrage wird vor dem Versand gespeichert; kommt keine Mail an, steht sie trotzdem unter „Formulare“ in der Verwaltung.
|
||||
2. **HTTPS.** Zertifikat einrichten, dann in `public/.htaccess` die drei Zeilen der HTTPS-Umleitung einschalten. **Erst wenn das eine Weile läuft**, die HSTS-Zeile aktivieren – sie lässt sich in einem Browser ein halbes Jahr lang nicht zurücknehmen.
|
||||
3. **www oder nicht.** Das alte kant-it.de ist kanonisch mit `www`. In `.htaccess` steht die Umleitung dafür vorbereitet und auskommentiert; sie muss zur Einstellung `SITE_ORIGIN` passen, die in `.env` steht. Beide zeigen auf dieselbe Schreibweise, sonst zeigt jede Seite auf eine Adresse, die weiterleitet.
|
||||
4. **Datenbank.** Geprüft ist ausschließlich SQLite. Für MySQL ist alles vorbereitet, aber nichts erprobt – wer MySQL nimmt, sollte danach Seiten anlegen, veröffentlichen, Bilder hochladen und ein Formular absenden.
|
||||
5. **Cronjobs.** Täglich `php bin/console cms:backup` und `php bin/console cms:stats:prune`. Ohne den ersten gibt es keine Sicherung außer der von Hand angelegten.
|
||||
6. **Schriften.** Auf dem neuen Server einmal `php bin/console cms:fonts:fetch` ausführen oder im Designbereich den Knopf drücken – die Dateien liegen nicht im Repository. Bis dahin lädt die Seite die Schriften von Google, was sie nicht soll.
|
||||
7. **Rechtstexte.** Impressum und Datenschutzerklärung sind vom alten Auftritt übernommen. Der Abschnitt über Google Fonts wurde an die Wirklichkeit angepasst (die Schriften kommen jetzt vom eigenen Server), die Abschnitte über Google Maps und reCAPTCHA entfernt, weil beides nicht eingebunden ist. **Das ersetzt keine anwaltliche Prüfung**, und es ist die Verantwortung des Betreibers, nicht des CMS.
|
||||
8. **Texte.** Acht Projektbeschreibungen stammen nicht vom alten Auftritt, sondern sind neu geschrieben. Sie sollten gelesen werden, bevor sie öffentlich sind.
|
||||
|
||||
## Optional: die Verwaltung umbenennen
|
||||
|
||||
`/admin` ist die erste Adresse, die jeder Scanner probiert. `php bin/console cms:admin:path meine-verwaltung` legt sie woanders hin; der Name steht danach in `.env.local` und in keiner öffentlichen Datei. **Vorher notieren, wohin** – danach antwortet `/admin` mit 404 wie jede unbekannte Adresse. Zurück geht es mit `php bin/console cms:admin:path admin`.
|
||||
|
||||
## Optional: Spamschutz mit Cloudflare
|
||||
|
||||
Die Formulare haben drei eingebaute Prüfungen ohne Dritte. Kommt trotzdem Spam durch, gibt es unter
|
||||
Einstellungen → „Spamschutz für Formulare" Cloudflare Turnstile: Schlüsselpaar im Cloudflare-Konto
|
||||
anlegen (kostenlos), beide Schlüssel eintragen, Haken setzen. **Vorher wissen:** das Widget braucht
|
||||
JavaScript, und die IP-Adresse des Absenders geht an Cloudflare – beides gehört in die
|
||||
Datenschutzerklärung, bevor es scharf geschaltet wird.
|
||||
|
||||
## Reihenfolge am Umzugstag
|
||||
|
||||
1. Dateien auf das Hosting, `.env` mit `APP_ENV=prod`, `APP_DEBUG=0`, `DATABASE_URL`, `MEDIA_DIRECTORY`, `SITE_ORIGIN`, `MAILER_DSN` und einem neuen `APP_SECRET`.
|
||||
2. Datenbank und Medienordner einspielen (oder `php vendor/bin/doctrine-migrations migrate` auf einer leeren Datenbank und dann der Installer).
|
||||
3. `php bin/console cms:fonts:fetch`, danach eine Seite aufrufen und im Quelltext prüfen, dass nichts von `fonts.googleapis.com` geladen wird.
|
||||
4. Kontaktformular abschicken, Mail prüfen.
|
||||
5. **Jetzt erst** die Domain umstellen.
|
||||
6. Danach: `CMS_URL=https://www.kant-it.de npm run test:links` von einem Rechner mit Node, `robots.txt` und `sitemap.xml` im Browser aufrufen, die Sitemap in der Google Search Console neu einreichen, und drei bis vier alte Adressen von Hand aufrufen (etwa `/productsolutions/`, `/kanttresor-dsgvo-konforme-archivierung-fuer-moderne-unternehmen/`) – sie müssen mit 301 auf die neue Adresse führen.
|
||||
7. Eine Woche lang in der Search Console auf 404-Meldungen schauen; jede weitere alte Adresse bekommt eine Weiterleitung unter „Einstellungen → Weiterleitungen“.
|
||||
|
||||
## Rückweg
|
||||
|
||||
Das alte WordPress bleibt bis auf Weiteres bestehen. Geht etwas schief, wird die Domain dorthin zurückgestellt; die Inhalte des neuen Auftritts liegen unverändert in der Sicherung unter `var/backups/`.
|
||||
@@ -0,0 +1,201 @@
|
||||
# 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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## 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,69 @@
|
||||
# Qualitätsbericht – erster ausführbarer Prototyp
|
||||
|
||||
## Ergänzung: Autorotation, Blättern, eigene Texte, 13.09.2026
|
||||
|
||||
- **395 PHP-Tests / 1672 Assertions** unter PHP 8.2.12 und 8.4.22, 18 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung. axe: null Verstöße auf Startseite, Projekteseite, Beitrag.
|
||||
- **Die Autorotation ist als Verlauf geprüft, nicht als Schnappschuss:** 0 → 666 → 1332 → 1998 in drei Schritten zu je sieben Sekunden, und nach einem Mausrad-Ereignis bleibt der Wert stehen. Dabei fiel der Fehler der ersten Fassung auf: das Sprungziel wurde aus `offsetLeft` berechnet und der Offset des Sliders doppelt abgezogen – Ergebnis −78, also keine Bewegung.
|
||||
- Belegt ist außerdem, dass das Skript **nur auf Seiten mit einem solchen Slider** geladen wird und die Richtlinie sonst bei `script-src 'none'` bleibt.
|
||||
- **Blättern in der Beitragsliste** mit sieben Beiträgen geprüft: erste Seite ohne „Neuere“, letzte ohne „Ältere“, Seite 99 landet auf der letzten, und ohne den Schalter bleibt es eine Auswahl ohne Leiste.
|
||||
- **Acht Projekttexte sind geschrieben, nicht übernommen** – für die Beiträge, die auf der laufenden Website keinen Text haben (zwei davon nicht einmal einen Auszug). Sie beschreiben Aufgabe, Weg und Ergebnis, ohne Zahlen, Namen oder Zusagen zu erfinden. **Sie gehören vor einem Livegang gegengelesen**, denn sie sind meine Formulierung über Umuts Projekte.
|
||||
- **Nicht geprüft:** die Autorotation auf einem echten Touchgerät und in Firefox oder Safari; gemessen ist sie im Chromium der Browserprüfung.
|
||||
|
||||
## Ergänzung: Nachbau vollständig, 13.09.2026
|
||||
|
||||
- **390 PHP-Tests / 1641 Assertions** unter PHP 8.2.12 und 8.4.22, 18 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung. **axe: null Verstöße** auf Startseite, Projekteseite, Datenschutzerklärung, Projektbeitrag und mobiler Projekteseite.
|
||||
- **Der Nachbau ist jetzt vollständig:** 29 Projektbeiträge mit den echten Bildern, Titeln, Auszügen und Texten der laufenden Website, die vollständige Datenschutzerklärung (27 Überschriften, rund 22.000 Zeichen), Impressum, Startseite, Projekteseite. 41 Medien, 33 Seiten.
|
||||
- **Ein Befund, der für den echten Umzug zählt:** **11 der 29 Projektbilder** hätte die Mediathek auf einem Hosting mit 128 MB abgewiesen, zwei davon (13,5 und 12,0 Megapixel) auch mit mehr Speicher – sie überschreiten die Pixelgrenze. Der Umzug muss solche Bilder vorher verkleinern; für den Nachbau ist genau das geschehen (9 Bilder auf 2400 px). Die Meldung der Mediathek ist dabei richtig und verständlich, sie kommt nur an einer Stelle, an der niemand mit ihr rechnet.
|
||||
- Die Größe der **Kennzahl** und der **Kundenstimme** ließ sich nicht einstellen: `.block-stat strong` und `.block-quote p` hatten feste Werte, gegen die eine Blockeinstellung nicht ankam. Beide erben jetzt vom Block – dieselbe Familie wie die Theme-Skala, die die Kartenüberschriften überschrieb.
|
||||
- Gemessen: Startseite 56 KB HTML mit 15 Bildern, Projekteseite 32 KB mit 13, Datenschutzerklärung 38 KB. **Kein ausführbares Skript** auf keiner Seite; das einzige `<script>` ist strukturierte Daten als `application/ld+json`, und die Richtlinie sendet weiterhin `script-src 'none'`.
|
||||
- **Nicht geprüft:** die Projektbeiträge sind inhaltlich übernommen, nicht redaktionell überarbeitet – einzelne Texte der laufenden Website sind kurz oder fehlen dort ganz (8 von 29 haben nur den Auszug). Und nichts davon steht auf echtem Hosting.
|
||||
|
||||
## Ergänzung: Detailabgleich mit dem Original, 13.09.2026
|
||||
|
||||
- **390 PHP-Tests / 1641 Assertions** unter PHP 8.2.12 und 8.4.22, 18 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung. **axe: null Verstöße** auf Startseite, Projekteseite und Handybreite des Nachbaus.
|
||||
- **Der ernsteste Fund ist eine beinahe entstandene Lücke:** beim Freigeben von Auszeichnungen in Überschriften war die Dokumentfassung in der Schliessung des Filters nicht gebunden – der Filter lief nie, während die Ausgabe schon unescaped war. Ein Test, der `<script>alert(1)</script>` in eine Überschrift schreibt, hat es im ersten Lauf gezeigt. Er prüft jetzt beides: das hervorgehobene Wort kommt durch, das Skript nicht.
|
||||
- Gegen das Original nachgemessen statt geschätzt: Kartenbreite 316 px (Original 324), Kartenhöhe 342 (330), Kartentitel 19 px/800 in beiden, Überschrift zweiter Ebene 44 px in beiden, Hauptüberschrift 66 px (68).
|
||||
- **Nicht geprüft:** Punkte am Slider in Firefox und Safari – dort greift der Rückfall auf ein wischbares Schiebefeld, gesehen habe ich ihn in diesen Browsern nicht.
|
||||
|
||||
## Ergänzung: Nachbau überarbeitet, Dokumentschema v17, 13.09.2026
|
||||
|
||||
- **389 PHP-Tests / 1636 Assertions** unter PHP 8.2.12 und 8.4.22, **18 JavaScript-Modelltests**, Playwright-Editortest, öffentliche Browserprüfung.
|
||||
- **Der schwerste Fund des Tages kommt aus dem Editor, nicht aus dem Nachbau:** in einem Dokument ab v13 ließ sich kein Baustein mehr einfügen, und Symbol, Fortschritt, Slider und eingebetteter Inhalt in gar keinem. Der bestehende Editortest hat das nicht bemerkt, weil er ein altes Dokument bearbeitet. Der neue Test fügt jeden Typ in Dokumente von v1 bis v17 ein.
|
||||
- **Ein zweiter, der nur in der Gegenprobe sichtbar wird:** `|default(true)` macht in Twig aus einem gespeicherten `false` ein `true`. Die neuen Anzeigeoptionen der Beitragsliste hätten sich speichern lassen und nichts getan – dieselbe Krankheit wie Rahmenstärke und Bildrundung. Der Test prüft beide Richtungen.
|
||||
- Neu geprüft: verlinktes Bild (mit denselben Linkregeln, sechs Fälle), klebender Kopfbereich als Einstellung, Projektraster gegen Blogarchiv.
|
||||
- Am Nachbau nachgemessen: Lösungskarte 213 px statt 300, Abstand zur Projektliste 54 px, Projektkarte 337 px mit Bild und Titel – das Original liegt bei 324 px.
|
||||
- **Nicht geprüft:** ob der klebende Kopfbereich auf einem Telefon mit dynamischer Adressleiste gut aussieht, und der Slider ist nicht auf einem echten Touchgerät gewischt worden.
|
||||
|
||||
## Ergänzung: kant-it.de nachgebaut, 12.09.2026
|
||||
|
||||
- **381 PHP-Tests / 1619 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung.
|
||||
- **Der Nachbau ist die Prüfung:** Startseite mit neun Abschnitten, Projekteseite, vier Beiträge, Impressum, Datenschutzgerüst, Kopf- und Fußbereich, zwei Menüs, Kontaktformular, zwölf Bilder, eigene Palette und Schrift – gegen eine eigene Datenbank und einen eigenen Medienordner, nicht gegen die Entwicklungsinstallation.
|
||||
- **Gefunden dabei: fünf Fehler** (ADR 042), darunter der größte: die Palette färbte Symbole, Knöpfe, Rahmen und den Einwilligungsdialog überhaupt nicht. Vier davon sind behoben und durch Tests abgesichert, der fünfte war mein eigener Kontrastfehler beim Beheben des ersten.
|
||||
- **axe: null Verstöße** auf Startseite, Projekteseite, Beitrag und Impressum, in 1440 px und 390 px – gemessen an der nachgebauten Seite, nicht an der Demo.
|
||||
- Gemessen am Nachbau: Startseite 45 KB HTML, 33 KB CSS, rund 430 KB Bilder in WebP, **0 Skripte**, 21 Abfragen. Die Abfragezahl ist die höchste bisher gemessene und erklärt sich aus der Beitragsliste; sie liegt unter der Obergrenze, ist aber der nächste Kandidat fürs Zusammenfassen.
|
||||
- **Nicht geprüft:** die vollständige Datenschutzerklärung der laufenden Website (nur das Gerüst), die restlichen Projektbeiträge, die Produktseiten von HausKlar und KantTresor, und nichts davon auf echtem Hosting. Der Nachbau ist ein Abbild, keine Migration: die Inhalte sind neu geschrieben, nicht aus WordPress exportiert.
|
||||
|
||||
## Ergänzung: Installer, 12.09.2026
|
||||
|
||||
- **379 PHP-Tests / 1606 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung.
|
||||
- **Die Abnahme des Pakets ist ein Test, kein Bericht:** ein Test geht die sechs Bildschirme durch die echte HTTP-Schicht, mit einem Kernel in einem wegwerfbaren Projektverzeichnis – leere Adresse → Weiterleitung auf `/install`, Datenbank, Migration, Website, Konto, fertig. Danach antwortet `/install` mit **404** und `/` mit **200**, und auf der Startseite steht der Name, den das Formular bekommen hat. Die Migrationen darin sind die echten.
|
||||
- **Von Hand im Browser nachgestellt**, gegen eine Kopie der Anwendung in einem leeren Verzeichnis mit leerer Datenbank: erste Anfrage 302 auf `/install`, Systemprüfung, Datenbank (SQLite), 13 Migrationen, Website „Tischlerei Berg“. Den letzten Schritt – das Konto – habe ich nicht im Browser ausgefüllt, sondern dem Test überlassen; ich tippe keine Passwörter in Formulare.
|
||||
- **axe auf allen vier erreichbaren Installationsbildschirmen: null Verstöße** (wcag2a, wcag2aa, wcag21a, wcag21aa).
|
||||
- **Das Releasearchiv wurde entpackt und gestartet**, nicht nur gebaut: `kantsites-1.3.0.zip` in ein leeres Verzeichnis, Server darauf, erste Anfrage → Installer. Damit ist belegt, dass `vendor/` vollständig ist und auf dem Zielsystem weder Composer noch eine Konsole gebraucht wird.
|
||||
- Die einzelne `install.php` ist dort geprüft, wo sie gefährlich wäre: neun Archiveinträge, die aus dem Zielverzeichnis ausbrechen wollen (`../`, absolut, Windows-Laufwerk, Rückwärtsschrägstrich, Nullbyte), landen nirgends – und ein Archiv mit einem solchen Eintrag bricht ab, ohne die Datei außerhalb anzulegen. Der Eintrag im Testarchiv ist echt: ZIP speichert `kantsites-1.2.0/../entwischt.txt` unverändert.
|
||||
- **Nicht geprüft:** der Download selbst. Solange das Archiv nicht unter seiner Adresse liegt, ist die Kette „Datei hochladen → lädt → entpackt“ nur bis zum Entpacken belegt. Ebenso ungeprüft: MySQL als Zieldatenbank im Installer (der Verbindungstest ist da, ein echter MySQL-Server war es nicht), echtes Hosting, und ein Screenreader.
|
||||
|
||||
## Ergänzung: Bildgrößen und Releasearchiv, 12.09.2026
|
||||
|
||||
- **352 PHP-Tests / 1479 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung.
|
||||
- **Der Browsertest hat den Fehler gefunden, den die Unittests nicht sehen konnten:** der Dokumentprüfer ließ höchstens **drei** `sources` je Bild zu. Mit der vierten Variante war jedes Dokument mit einem Bild darin ungültig – und eine abgewiesene Speicherung sieht im Editor so aus, als wäre nichts passiert. Gefunden wurde er, weil ein Galeriebild im Vorschaurahmen nicht erschien. Die Grenze hängt jetzt an `MediaLibrary::WIDTHS` statt an einer Zahl, und ein Test prüft beide Seiten: vier Quellen werden angenommen, fünf abgewiesen.
|
||||
- **Die Bildpipeline ist nachgerechnet, nicht behauptet:** ein Test lädt ein erzeugtes Foto hoch, öffnet jede entstandene Datei und prüft, dass sie WebP ist, dass die vier Breiten 480/960/1600/2400 entstehen und dass das Seitenverhältnis erhalten bleibt. Ein zweiter Upload mit 700 px Breite bekommt zwei Stufen – 480 und 700 –, womit „nie hochskalieren“ geprüft ist und nicht nur im Kommentar steht.
|
||||
- **Ein Testfehlschlag, der keiner war:** derselbe Test fiel um, sobald er in der gesamten Suite lief, mit der Meldung „dieses Bild benötigt mehr Arbeitsspeicher als das Hosting bereitstellt“. Das ist die Schutzprüfung vor dem Decodieren, die den bereits belegten Speicher mitrechnet – sie hat richtig gehandelt. Der Test benutzt jetzt eine kleinere Quelle und hebt das Limit für seine Dauer an, weil sein Gegenstand die Pipeline ist und nicht die Schutzprüfung.
|
||||
- Hintergrundbilder sind je Gerät geprüft: die Desktopregel nennt die größte Variante, die Regel im `max-width:767px`-Block die 960er.
|
||||
- **Das Releasearchiv ist gebaut, nicht geplant:** `kantsites-1.2.0.zip`, 3465 Dateien, 4,4 MB, SHA-256 `950eaa4c…`. Nachgesehen wurde, was **nicht** drin ist: kein PHPUnit, keine Tests, kein `var/`, kein `.env`.
|
||||
- Der Abbruch bei geänderten Abhängigkeiten ohne Composer ist mit zwei Tests belegt: geänderte `composer.lock` ohne Composer bricht ab und stellt den alten Stand wieder her; unveränderte `composer.lock` läuft durch.
|
||||
- **Nicht geprüft:** das Archiv ist auf keinem echten Hostingpaket entpackt und gestartet worden – das gehört zu den Arbeitspaketen J und N. Der Installer, der es herunterlädt, existiert noch nicht.
|
||||
|
||||
## Ergänzung: Ladeverhalten, 12.09.2026
|
||||
|
||||
- **347 PHP-Tests / 1422 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung mit neuem Ladebudget.
|
||||
|
||||
+47
-2
@@ -1,9 +1,17 @@
|
||||
# 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.
|
||||
Stand: 13.09.2026, Fassung **1.4.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.**
|
||||
|
||||
**Nachbau nachgezogen (13.09.2026):** Der Fußbereich ist jetzt vierspaltig wie im Original – Zeichen und Satz, „Leistungen", „Unternehmen", „Kontakt", darunter die Zeile mit Copyright. Die beiden Linklisten sind **Menüs**, keine Dokumentinhalte: sie lassen sich in der Verwaltung pflegen. Alle **29 Beiträge haben jetzt Text** – die elf fehlenden sind neu geschrieben, denn auch im Original stehen dort nur Bildergalerien ohne Beschreibung. Die Karte „Bereit für den nächsten Schritt" hat 34 px Abstand zu den Kontaktzeilen darunter. **Nicht erledigt: das TeamViewer-Logo** im Fernwartungsbereich – dazu unten.
|
||||
|
||||
**Vom Original übernommen (13.09.2026):** die Meta-Beschreibung der Startseite im Wortlaut von kant-it.de, das Symbol der Website (dort 192 × 192) und die Bot-Regeln der robots.txt. Titel, Canonical, Open Graph, strukturierte Daten (`LocalBusiness` statt Yoasts `ProfessionalService`), Sitemap, robots.txt und `llms.txt` entstehen in KantSites aus dem Inhalt und stimmen mit dem Original überein. **Beim Abgleich aufgefallen:** das `og:image` des echten kant-it.de zeigt auf `wp-content/uploads/kant-it-share.png` – die Datei antwortet mit 404, beim Teilen erscheint dort also kein Bild.
|
||||
|
||||
**Im Nachbau von kant-it.de gefunden und behoben:** vier tote Verweise im Hero-Slider – die Adressen waren im Aufbauskript von Hand geschrieben, die Beiträge bekamen längere. Sie werden jetzt aus derselben Regel abgeleitet, mit der die Beiträge entstehen, und ein zweiter Lauf des Skripts aktualisiert vorhandene Seiten, statt sie zu überspringen. Eine Prüfung aller internen Verweise auf 404 gibt es im CMS noch nicht; gefunden wurden sie mit einem einmaligen Durchlauf im Browser.
|
||||
|
||||
## Erledigt
|
||||
|
||||
- `AGENTS.md` und `ENTWICKLUNGSPLAN.md` unverändert aus Downloads im Projektstamm abgelegt; SHA-256 jeweils mit dem Original identisch.
|
||||
@@ -19,7 +27,7 @@ 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.
|
||||
@@ -27,6 +35,43 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- Adresshierarchie: neue Spalten `path`, `parent_id`, `type`, `created_at` über Migration `20260912000400`; websiteweit eindeutiger Pfad statt eindeutigem Slug, Tiefe bis vier Ebenen, Beiträge ohne Unterseiten, reservierte Adressen und Adressableitung aus dem Titel. Öffentliche Route bedient verschachtelte Pfade wie `/leistungen/webdesign`; ein Lookahead hält `/admin`, `/media` und `/assets` aus dem Auffangmuster.
|
||||
- **Ladeverhalten gemessen statt vermutet** (ADR 039, Arbeitspaket M): ein Zähler liefert die Datenbankabfragen je Anfrage im Kopf `X-Kant-Queries` (außerhalb der Produktion). Die erste Messung zeigte 18 Abfragen auf der Startseite, weil Einstellungen und Theme je Aufrufer erneut gelesen wurden; beide merken sich ihren Stand jetzt für die Dauer einer Anfrage – **18 → 8**. Die Browserprüfung misst je Seite HTML, CSS, Anfragen, Skripte und Abfragen gegen Obergrenzen: Startseite 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 0 Skripte.
|
||||
- **Das erste Bild einer Seite lädt sofort**, alles darunter weiterhin verzögert. Es ist meistens das größte Ding über der Falz.
|
||||
- **Slider mit Punkten ohne Skript** (ADR 044): `::scroll-marker` zeichnet je Folie einen Punkt, der aktive ist erkennbar, die Rollleiste entfällt; ältere Browser behalten das wischbare Schiebefeld. Beitragskarten sind echte Karten (Bild 16:10, 18 px Ecken). Überschriften dürfen ein Wort hervorheben (`<mark>`, in Akzentfarbe). Behoben: die Theme-Schriftskala überschrieb die Größen in Karten und Aufruf-Bändern.
|
||||
- **„Weiterlesen" ist jetzt ein Link.** In der wechselseitigen Beitragsliste stand dort eine reine Beschriftung: sie sah aus wie ein Knopf und war keiner. Der Titel steckt für Vorlesesoftware im Linktext, damit nicht zwölf Links gleich heißen.
|
||||
- **Behoben in 1.4.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.
|
||||
|
||||
+16
-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,23 @@ 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“.
|
||||
|
||||
## 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,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.');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -601,3 +601,56 @@ 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)}
|
||||
@@ -1,5 +1,5 @@
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', 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', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider' , legal: 'Rechtszeile'};
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄' };
|
||||
// A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the
|
||||
// document, stay selectable and movable, but the editor never edits or fills them.
|
||||
export const PLUGIN_TYPE = /^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/;
|
||||
@@ -36,7 +36,9 @@ export function createBlock(type) {
|
||||
if (type === 'heading') block.props = { text: 'Deine neue Überschrift', level: 2 };
|
||||
if (type === 'text') block.props = { text: 'Hier beginnt dein neuer Inhalt.' };
|
||||
if (type === 'button') block.props = { text: 'Mehr erfahren', href: '/' };
|
||||
if (type === 'postlist') block.props = { count: 3, category: '', tag: '' };
|
||||
if (type === 'postlist') block.props = { count: 3, category: '', tag: '', layout: 'karten' };
|
||||
if (type === 'postnav') block.props = { previousLabel: 'Vorheriger Beitrag', nextLabel: 'Nächster Beitrag' };
|
||||
if (type === 'legal') block.props = { label: 'Rechtliches' };
|
||||
if (type === 'iconcard') block.props = { icon: 'check', title: 'Deine Leistung', text: 'Beschreibe hier kurz, worum es geht.', href: '' };
|
||||
if (type === 'stat') block.props = { value: '15+', label: 'Jahre Erfahrung' };
|
||||
if (type === 'quote') block.props = { text: 'Ein Satz, den eine Kundin oder ein Kunde über dich gesagt hat.', author: 'Name', role: '', rating: 5 };
|
||||
@@ -52,7 +54,7 @@ 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;
|
||||
}
|
||||
@@ -61,6 +63,8 @@ export function insertBlock(document, type, selected) {
|
||||
const block = createBlock(type);
|
||||
if (type === 'gallery') upgradeDocument(document, 3);
|
||||
if (type === 'postlist') upgradeDocument(document, 5);
|
||||
if (type === 'postnav') upgradeDocument(document, 18);
|
||||
if (type === 'legal') upgradeDocument(document, 19);
|
||||
if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6);
|
||||
if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7);
|
||||
if (type === 'form') upgradeDocument(document, 8);
|
||||
@@ -108,8 +112,15 @@ export function moveBlock(document, selected, targetId, inside = false) {
|
||||
export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' };
|
||||
|
||||
// Called only inside a history transaction: all other fields and IDs stay untouched.
|
||||
// Die höchste Fassung, die dieser Editor kennt. Sie stand bisher als Liste an zwei Stellen und
|
||||
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
|
||||
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
|
||||
// nirgends. Eine Zahl, an einer Stelle.
|
||||
export const SCHEMA_VERSION = 19;
|
||||
const known = version => Number.isInteger(version) && version >= 1 && version <= SCHEMA_VERSION;
|
||||
|
||||
export function upgradeDocument(document, version = 2) {
|
||||
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].includes(document.schemaVersion) || ![2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].includes(version)) throw new Error('Unbekanntes Dokumentschema.');
|
||||
if (!known(document.schemaVersion) || !known(version) || version < 2) throw new Error('Unbekanntes Dokumentschema.');
|
||||
document.schemaVersion = Math.max(document.schemaVersion, version);
|
||||
return document;
|
||||
}
|
||||
@@ -126,7 +137,7 @@ export function effectiveDesign(block, device, key) {
|
||||
}
|
||||
|
||||
export function setDesign(document, id, device, key, value) {
|
||||
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
|
||||
if (!known(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
|
||||
if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.');
|
||||
const block = locate(document.blocks, id)?.block;
|
||||
if (!block) return;
|
||||
|
||||
+53
-2
@@ -170,6 +170,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 +261,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 +282,27 @@ function renderProperties() {
|
||||
field('Titel', 'title', block.props.title, { maxLength: 160 });
|
||||
field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true });
|
||||
}
|
||||
if (block.type === 'legal') {
|
||||
field('Beschriftung für Vorlesesoftware', 'label', block.props?.label || 'Rechtliches', { maxLength: 60, help: 'Der Baustein zeigt Impressum, Datenschutz und – sobald jemand gefragt wurde – den Weg zurück zur Einwilligung. Die Adressen stehen in den Einstellungen. Steht er im Fußbereich, entfällt die eingebaute Zeile am Seitenende.' });
|
||||
}
|
||||
if (block.type === 'postnav') {
|
||||
field('Beschriftung zurück', 'previousLabel', block.props.previousLabel, { maxLength: 60 });
|
||||
field('Beschriftung weiter', 'nextLabel', block.props.nextLabel, { maxLength: 60, help: 'Die Beiträge selbst sucht die Website beim Ausliefern - innerhalb derselben Kategorie.' });
|
||||
}
|
||||
if (block.type === 'postlist') {
|
||||
field('Anzahl Beiträge', 'count', block.props.count, { number: true, help: 'Zwischen 1 und 24. Die Beiträge selbst kommen beim Ausliefern dazu, nicht aus dem gespeicherten Inhalt.' });
|
||||
field('Nur Kategorie', 'category', block.props.category || '', { help: 'Adresse der Kategorie, zum Beispiel projekte. Leer lassen für alle Beiträge.' });
|
||||
field('Nur Schlagwort', 'tag', block.props.tag || '', { help: 'Adresse des Schlagworts. Leer lassen für alle Beiträge.' });
|
||||
field('Darstellung', 'layout', block.props.layout || 'karten', { choices: { karten: 'Karten (Raster mit Bild oben)', liste: 'Liste (Bild links, Text rechts)', kompakt: 'Kompakt (nur Titel und Datum)', wechsel: 'Wechselseitig (Bild mal links, mal rechts)' }, help: 'Dieselben Beiträge, drei Darstellungen. Karten für eine Übersicht, Liste für ein Archiv, Kompakt für eine Randspalte.' });
|
||||
field('Blättern statt abschneiden', 'paginate', block.props.paginate === true, { checkbox: true, help: 'Für eine Übersichtsseite. Eine Auswahl auf der Startseite lässt man aus.' });
|
||||
field('Datum und Autor zeigen', 'showMeta', block.props.showMeta !== false, { checkbox: true });
|
||||
field('Auszug zeigen', 'showExcerpt', block.props.showExcerpt !== false, { checkbox: true });
|
||||
field('Kategorie zeigen', 'showTerms', block.props.showTerms !== false, { checkbox: true, help: 'Alle drei aus: ein Raster aus Bild und Titel, wie es zu Projekten passt.' });
|
||||
}
|
||||
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
|
||||
if (block.type === 'image') {
|
||||
field('Beim Klick', 'click', block.props?.click || (block.props?.href ? 'link' : 'keine'), { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, help: 'Vergrößern öffnet das Bild über der Seite. Ohne JavaScript öffnet derselbe Klick die große Bilddatei – die Seite bleibt also in jedem Fall benutzbar.' });
|
||||
if ((block.props?.click || (block.props?.href ? 'link' : 'keine')) === 'link') field('Linkziel', 'href', block.props.href || '', { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse. Für ein Logo im Kopfbereich meist /.' });
|
||||
const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen';
|
||||
picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker);
|
||||
field('Bildadresse', 'src', block.props.src, { help: 'Vorhandene lokale Datei oder HTTPS-Adresse. Eine Mediathek folgt später.' });
|
||||
@@ -296,6 +313,7 @@ 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);
|
||||
@@ -551,9 +569,20 @@ async function updatePreview() {
|
||||
if (sequence !== previewSequence) return;
|
||||
const frame = $('#preview');
|
||||
const top = frame.contentWindow?.scrollY || 0;
|
||||
frame.onload = () => {
|
||||
if (sequence !== previewSequence) return;
|
||||
let angewandt = false;
|
||||
// Die Gestaltung muss sitzen, sobald der Kopf des Dokuments geparst ist. Vorher hing das an
|
||||
// frame.onload - und das feuert erst, wenn das letzte Bild geladen ist. Auf einer Seite mit
|
||||
// sechzehn Bildern stand die Vorschau dadurch sekundenlang ohne jede Gestaltung da: schwarze
|
||||
// Knöpfe statt roter, kein Hero, keine Farben. Genau das hat Umut im Bild gesehen.
|
||||
// Welches Dokument gerade im Rahmen steht. Ohne diesen Vergleich wird beim Warten auf das
|
||||
// neue Dokument das alte gestaltet - es hat dieselben Kennzeichen -, der Rahmen wird sichtbar
|
||||
// geschaltet, und gleich darauf ersetzt srcdoc den Inhalt durch den ungestalteten neuen.
|
||||
const altesDokument = frame.contentDocument;
|
||||
const anwenden = () => {
|
||||
if (angewandt || sequence !== previewSequence) return;
|
||||
const doc = frame.contentDocument;
|
||||
if (!doc || doc === altesDokument || !doc.getElementById('kant-styles')) return;
|
||||
angewandt = true;
|
||||
const style = doc.createElement('link'); style.rel = 'stylesheet'; style.href = '/assets/preview.css'; doc.head.append(style);
|
||||
// The preview delivers the generated stylesheet as inert template text (see page.html.twig).
|
||||
// Applying it as a constructed stylesheet is not a source in the sense of the policy, so the
|
||||
@@ -578,9 +607,31 @@ async function updatePreview() {
|
||||
});
|
||||
doc.addEventListener('submit', event => event.preventDefault());
|
||||
frame.contentWindow.scrollTo(0, top); highlight();
|
||||
// Erst jetzt ansehen lassen: bis hierher zeigte das Fenster eine Seite ohne Gestaltung.
|
||||
frame.style.visibility = '';
|
||||
$('#preview-status').textContent = 'Entwurf · Zum Auswählen direkt auf ein Element klicken.';
|
||||
};
|
||||
// Solange die Gestaltung fehlt, bleibt die Fläche leer statt falsch.
|
||||
frame.style.visibility = 'hidden';
|
||||
frame.srcdoc = html;
|
||||
// Sobald der Kopf da ist, wird gestaltet - geprüft Bild für Bild statt auf ein Ereignis zu
|
||||
// warten, das es für srcdoc nicht zuverlässig gibt.
|
||||
const warten = () => {
|
||||
if (angewandt || sequence !== previewSequence) return;
|
||||
anwenden();
|
||||
if (!angewandt) requestAnimationFrame(warten);
|
||||
};
|
||||
requestAnimationFrame(warten);
|
||||
// Das Netz darunter ist das Ladeereignis - und zwar ohne Uhr: bringt auch das fertig geladene
|
||||
// Dokument keine Gestaltung mit, stimmt etwas anderes nicht, und dann ist eine unfertige
|
||||
// Vorschau immer noch besser als eine unsichtbare.
|
||||
frame.onload = () => {
|
||||
anwenden();
|
||||
if (!angewandt) {
|
||||
frame.style.visibility = '';
|
||||
$('#preview-status').textContent = 'Vorschau ohne Gestaltung - bitte die Seite neu laden.';
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError' || sequence !== previewSequence) return;
|
||||
$('#preview-status').textContent = `Letzte gültige Vorschau: ${error.message}`;
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
});
|
||||
})();
|
||||
+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;max-width:900px}h2{font-size:clamp(32px,4vw,49px);font-weight:500;max-width:760px}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;max-width:690px;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
|
||||
@media(max-width:650px){.shell{width:calc(100% - 36px)}.site-header nav{width:100%;justify-content:space-between;gap:12px;font-size:12px}.site-header nav .nav-link{padding:10px}.nav-link span{margin-left:6px}.block-section,.block-section:first-child{padding-block:48px}h1{font-size:49px}.block-text{font-size:16px}.site-footer{align-items:flex-start;flex-direction:column;gap:10px}.site-footer p{margin:0}.block-row{gap:16px}}
|
||||
|
||||
/* Menus. Submenus open with details/summary, so the navigation works without JavaScript. */
|
||||
.menu-list{list-style:none;display:flex;flex-wrap:wrap;align-items:center;gap:4px 22px;margin:0;padding:0}
|
||||
.menu-list a{text-decoration:none;padding:6px 2px;display:inline-block}
|
||||
.menu-list a:hover,.menu-list summary:hover{text-decoration:underline}
|
||||
.menu-list [aria-current=page]{font-weight:700;text-decoration:underline;text-underline-offset:6px}
|
||||
.menu-list [aria-current=page]{font-weight:700;text-decoration:none}
|
||||
.menu-branch>summary{cursor:pointer;padding:6px 2px;list-style:none}
|
||||
.menu-branch>summary::after{content:' ▾'}
|
||||
.menu-branch[open]>summary::after{content:' ▴'}
|
||||
@@ -17,16 +21,19 @@ h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
|
||||
.menu-level-2{flex-direction:column;align-items:flex-start;gap:2px}
|
||||
|
||||
/* Posts */
|
||||
.post-list{list-style:none;margin:0;padding:0;display:grid;gap:26px;grid-template-columns:repeat(auto-fill,minmax(300px,1fr))}
|
||||
.post-card{display:flex;flex-direction:column;gap:14px}
|
||||
.post-image img{width:100%;height:210px;object-fit:cover;border-radius:10px;display:block}
|
||||
.post-card h3{margin:0;font-size:19px;line-height:1.35}
|
||||
.post-list{list-style:none;margin:0;padding:0;display:grid;gap:26px}.post-list-karten{grid-template-columns:repeat(auto-fill,minmax(260px,1fr))}.post-list-liste{gap:18px}.post-list-liste .post-card{flex-direction:row;align-items:stretch}.post-list-liste .post-image{flex:0 0 34%;max-width:300px}.post-list-liste .post-image img{height:100%;aspect-ratio:auto;min-height:170px}.post-list-liste .post-body{flex:1 1 auto;align-content:center;padding:22px 26px}.post-list-wechsel{gap:0;counter-reset:beitrag}.post-list-wechsel .post-card{position:relative;counter-increment:beitrag;display:grid;grid-template-columns:1fr 1fr;align-items:center;gap:0 56px;padding:38px 0;background:transparent;border:0;border-radius:0;overflow:visible}.post-list-wechsel .post-card + .post-card{border-top:1px solid var(--line)}.post-list-wechsel .post-image{border-radius:20px;overflow:hidden;box-shadow:0 18px 46px -22px color-mix(in srgb,var(--ink) 55%,transparent)}.post-list-wechsel .post-image img{aspect-ratio:16/10;border-radius:20px}.post-list-wechsel .post-body{padding:0;display:grid;gap:12px;align-content:center}.post-list-wechsel .post-body::before{content:counter(beitrag,decimal-leading-zero);font-size:13px;font-weight:800;letter-spacing:2px;color:var(--accent)}.post-list-wechsel .post-card h3{font-size:28px;line-height:1.18;letter-spacing:-.5px}.post-list-wechsel .post-excerpt{margin:0;max-width:46ch}.post-list-wechsel .post-meta,.post-list-wechsel .post-terms{margin:0}.post-list-wechsel .post-more{display:inline-flex;align-items:center;gap:8px;margin-top:4px;font-size:15px;font-weight:700;color:var(--accent);text-decoration:none;width:fit-content}
|
||||
.post-more::after{content:'→';transition:transform .2s}
|
||||
.post-more:hover::after{transform:translateX(4px)}.post-list-wechsel .post-more::after{content:'\2192'}.post-list-wechsel .post-card:nth-child(even) .post-image{order:2}.post-list-wechsel .post-card:nth-child(even) .post-body{order:1}@media(max-width:760px){.post-list-wechsel .post-card{grid-template-columns:1fr;gap:18px;padding:26px 0}.post-list-wechsel .post-card:nth-child(even) .post-image{order:0}.post-list-wechsel .post-card:nth-child(even) .post-body{order:0}.post-list-wechsel .post-card h3{font-size:22px}}.post-list-kompakt{gap:0}.post-list-kompakt .post-card{flex-direction:row;align-items:baseline;justify-content:space-between;gap:18px;background:transparent;border:0;border-bottom:1px solid var(--line);border-radius:0;overflow:visible}.post-list-kompakt .post-body{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 18px;padding:16px 2px}.post-list-kompakt .post-meta,.post-list-kompakt .post-terms{margin:0}.post-list-kompakt .post-excerpt{flex:1 1 100%;margin:0}
|
||||
.post-card{display:flex;flex-direction:column;background:var(--paper);border:1px solid var(--line);border-radius:18px;overflow:hidden}.post-card>.post-body{display:grid;gap:10px;padding:20px 22px 22px}
|
||||
.post-image{display:block;position:relative;overflow:hidden;text-decoration:none}.post-view{position:absolute;inset:0;display:grid;place-items:center;background:color-mix(in srgb,var(--ink) 55%,transparent);color:#fff;font-weight:700;font-size:15px;letter-spacing:.4px;opacity:0}.post-card:hover .post-view,.post-card:focus-within .post-view{opacity:1}.post-image img{width:100%;height:auto;aspect-ratio:16/10;object-fit:cover;border-radius:0;display:block;background:var(--ink)}
|
||||
.kant h1 mark,.kant h2 mark,.kant h3 mark{background:transparent;color:var(--accent);padding:0}
|
||||
.kant .post-card h3{margin:0;font-size:19px;font-weight:800;line-height:1.12;color:var(--ink);letter-spacing:-.2px}
|
||||
.post-card h3 a{text-decoration:none}
|
||||
.post-card h3 a:hover{text-decoration:underline}
|
||||
.post-meta,.post-header{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:baseline;font-size:13px;margin:0}
|
||||
.post-header{padding-block:22px 0}
|
||||
.post-excerpt{margin:0;line-height:1.65}
|
||||
.post-terms{display:flex;flex-wrap:wrap;gap:8px;margin:0;font-size:13px}
|
||||
.block-postnav{margin-top:10px}.post-nav{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:18px}.post-nav-link{display:grid;gap:8px;padding:22px 24px;border:1px solid var(--line);border-radius:18px;text-decoration:none;background:var(--paper)}.post-nav-next{text-align:right}.post-nav-label{font-size:12px;font-weight:700;letter-spacing:1.4px;text-transform:uppercase;color:var(--accent)}.post-nav-title{font-size:18px;font-weight:800;line-height:1.25;color:var(--ink)}.post-pager{display:flex;flex-wrap:wrap;gap:10px 22px;align-items:center;justify-content:center;margin-top:34px;font-size:14px}.post-pager span{color:var(--muted)}.post-terms{display:flex;flex-wrap:wrap;gap:8px;margin:0;font-size:13px}
|
||||
.post-empty{padding:26px;border:1px dashed currentColor;border-radius:10px}
|
||||
.pagination{display:flex;flex-wrap:wrap;gap:12px 24px;align-items:center;margin-top:38px;padding-top:22px;border-top:1px solid currentColor;font-size:14px}
|
||||
.post-terms-label{font-size:11px;font-weight:700;letter-spacing:1.6px;text-transform:uppercase;margin-bottom:10px}
|
||||
@@ -34,26 +41,30 @@ h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
|
||||
/* Blocks introduced with schema v6. No JavaScript: the accordion is details/summary. */
|
||||
.block-icon{flex-shrink:0;display:block}
|
||||
.block-iconcard{display:grid;gap:12px;padding:26px;border:1px solid currentColor;border-radius:12px}
|
||||
.block-iconcard h3{margin:0;font-size:19px;line-height:1.3}
|
||||
.block-iconcard + .block-iconcard,.block-quote + .block-quote,.block-cta + .block-cta,.block-iconcard + .block-quote,.block-quote + .block-iconcard{margin-top:18px}.kant .block-iconcard h3{margin:0;font-size:19px;line-height:1.3}
|
||||
.block-iconcard h3 a{text-decoration:none}
|
||||
.block-iconcard h3 a:hover{text-decoration:underline}
|
||||
.block-iconcard p{margin:0;line-height:1.65}
|
||||
.block-stat{display:grid;gap:4px;margin:0}
|
||||
.block-stat strong{font-size:44px;font-weight:600;line-height:1.05;letter-spacing:-1.5px}
|
||||
.block-stat{display:grid;gap:4px;margin:0;font-size:44px;font-weight:600}
|
||||
.block-stat strong{font-size:inherit;font-weight:inherit;line-height:1.05;letter-spacing:-1.5px}
|
||||
.block-stat span{font-size:14px;line-height:1.5}
|
||||
.block-quote{margin:0;display:grid;gap:14px;padding:28px;border:1px solid currentColor;border-radius:12px}
|
||||
.block-quote{margin:0;display:grid;gap:14px;padding:28px;border:1px solid currentColor;border-radius:12px;font-size:18px}
|
||||
.block-quote blockquote{margin:0}
|
||||
.block-quote blockquote p{margin:0;font-size:18px;line-height:1.65}
|
||||
.block-quote blockquote p{margin:0;font-size:inherit;line-height:1.65}
|
||||
.block-quote figcaption{font-size:14px;font-weight:600}
|
||||
.block-quote figcaption span{font-weight:400;opacity:.75}
|
||||
.block-quote figcaption span{font-weight:400;opacity:.85}
|
||||
.quote-rating{margin:0;letter-spacing:3px}
|
||||
.block-logos{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));align-items:center;gap:24px}
|
||||
.block-logos img{max-height:52px;width:auto;margin-inline:auto;display:block}
|
||||
.block-cta{display:flex;flex-wrap:wrap;gap:20px 34px;align-items:center;justify-content:space-between;padding:32px;border:1px solid currentColor;border-radius:14px}
|
||||
.block-cta h2{margin:0 0 6px;font-size:26px;letter-spacing:-.6px}
|
||||
.kant .block-form button[type=submit]{background:var(--accent);color:#fff;border-color:var(--accent)}.kant .block-form button[type=submit]:hover{background:color-mix(in srgb,var(--accent) 86%,#000)}.kant .block-cta .button{background:var(--accent);color:#fff;border-color:var(--accent)}.kant .block-cta .button:hover{background:color-mix(in srgb,var(--accent) 86%,#000)}.block-cta{display:flex;flex-wrap:wrap;gap:20px 34px;align-items:center;justify-content:space-between;padding:32px;border:1px solid currentColor;border-radius:14px}
|
||||
.kant .block-cta h2{margin:0 0 6px;font-size:26px;letter-spacing:-.6px}
|
||||
.block-cta p{margin:0;line-height:1.6}
|
||||
.block-contact{display:grid;gap:14px;font-style:normal}
|
||||
.block-contact span{display:flex;align-items:center;gap:12px;line-height:1.5}
|
||||
/* Telefonnummer und E-Mail stehen neben ihrem Symbol - das sagt deutlicher als ein Strich,
|
||||
dass man darauf tippen kann. Beim Überfahren erscheint er trotzdem. */
|
||||
.block-contact a{text-decoration:none}
|
||||
.block-contact a:hover{text-decoration:underline}
|
||||
.block-divider{border:0;border-top:1px solid currentColor;opacity:.35;margin:0}
|
||||
.block-spacer{display:block}
|
||||
.spacer-small{height:18px}.spacer-medium{height:38px}.spacer-large{height:68px}.spacer-huge{height:110px}
|
||||
@@ -71,7 +82,7 @@ h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
|
||||
.tabs-nav [aria-selected=true]{border-color:currentColor;background:#fff}
|
||||
.tabs-ready .tabs-nav a{cursor:pointer}
|
||||
.block-tabitem{padding-block:4px}
|
||||
.block-tabitem:focus-visible{outline:3px solid #b55b12;outline-offset:4px}
|
||||
.block-tabitem:focus-visible{outline:3px solid var(--accent);outline-offset:4px}
|
||||
.tabs-heading{margin:0 0 12px;font-size:17px}
|
||||
|
||||
/* Forms. Server rendered, no JavaScript; every message sits at its field. */
|
||||
@@ -84,7 +95,7 @@ h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
|
||||
.field-error{margin:0;font-size:13px;font-weight:600;color:#8b2f22}
|
||||
.field-note{margin:0;font-size:13px;opacity:.8}
|
||||
.form-error-summary{margin:0;padding:14px 18px;border-radius:8px;background:#fff0eb;color:#8b2f22;border:1px solid #e2b2a7;font-weight:600}
|
||||
.form-success{margin:0;padding:16px 20px;border-radius:8px;background:#edf3dc;color:#315b49;border:1px solid #c5d89a;font-weight:600}
|
||||
.form-success{margin:0;padding:16px 20px;border-radius:8px;background:color-mix(in srgb,var(--accent) 10%,var(--paper));color:var(--accent);border:1px solid color-mix(in srgb,var(--accent) 35%,var(--paper));font-weight:600}
|
||||
.form-missing{margin:0;padding:14px 18px;border:1px dashed currentColor;border-radius:8px}
|
||||
.check-row{display:flex;align-items:flex-start;gap:11px}
|
||||
.check-row input{width:20px;min-height:20px;flex-shrink:0;margin-top:3px}
|
||||
@@ -102,28 +113,27 @@ h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
|
||||
for light backgrounds. Inside a dark tone they won on specificity, so text in a dark column was
|
||||
unreadable - 1.87:1 in the seeded footer. These two selectors match that specificity and come
|
||||
later, so the dark tone wins where it has to. */
|
||||
.tone-ink .block-column .block-text,
|
||||
.tone-ink .block-column>.block-text:first-child{color:#dae3da}
|
||||
.tone-ink .block-column .block-text{color:#dae3da}
|
||||
|
||||
/* The consent question. Fixed to the bottom, but it never covers the whole page: the content
|
||||
stays readable while the decision is open, because a wall would make "accept" the only way
|
||||
forward. Accepting and refusing are the same button in the same row. */
|
||||
.consent-bar{position:fixed;inset-inline:12px;bottom:12px;z-index:50;max-width:620px;margin-inline:auto;background:#fff;color:#203b34;border:1px solid #cfd8d1;border-radius:14px;padding:20px 22px;box-shadow:0 18px 50px #1b2a2426;font-size:14px;line-height:1.6}
|
||||
.consent-bar h2{font-size:16px;margin:0 0 8px}
|
||||
.consent-bar{position:fixed;inset-inline:12px;bottom:12px;z-index:50;max-width:620px;margin-inline:auto;background:var(--paper);color:var(--ink);border:1px solid var(--line);border-radius:14px;padding:20px 22px;box-shadow:0 18px 50px color-mix(in srgb,var(--ink) 15%,transparent);font-size:14px;line-height:1.6}
|
||||
.kant .consent-bar h2{font-size:16px;margin:0 0 8px}
|
||||
.consent-bar p{margin:0 0 14px}
|
||||
.consent-actions{display:flex;flex-wrap:wrap;gap:10px}
|
||||
.consent-button{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:11px 18px;border-radius:8px;border:1px solid #2c6b4f;background:#2c6b4f;color:#fff;font:inherit;font-weight:600;cursor:pointer}
|
||||
.consent-actions .consent-button:last-child{background:#fff;color:#1b2a24;border-color:#b3c0b7}
|
||||
.consent-button{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:11px 18px;border-radius:8px;border:1px solid var(--k-consent,var(--accent));background:var(--k-consent,var(--accent));color:#fff;font:inherit;font-weight:600;cursor:pointer}
|
||||
.consent-actions .consent-button:last-child{background:var(--paper);color:var(--ink);border-color:var(--line)}
|
||||
.consent-button:hover{filter:brightness(.94)}
|
||||
.consent-button:focus-visible,.consent-bar a:focus-visible,.consent-bar summary:focus-visible,.consent-bar input:focus-visible{outline:3px solid #b55b12;outline-offset:2px}
|
||||
.consent-button:focus-visible,.consent-bar a:focus-visible,.consent-bar summary:focus-visible,.consent-bar input:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
|
||||
.consent-details{margin-top:14px}
|
||||
.consent-details>summary{cursor:pointer;font-size:13px;font-weight:600;padding:4px 0}
|
||||
.consent-groups{list-style:none;margin:10px 0 0;padding:0;display:grid;gap:10px}
|
||||
.consent-groups label{display:flex;gap:10px;align-items:flex-start;font-size:13px;line-height:1.5}
|
||||
.consent-groups input{margin-top:3px;width:18px;height:18px;flex-shrink:0}
|
||||
.consent-choice{margin-top:14px;background:#fff;color:#1b2a24;border-color:#b3c0b7}
|
||||
.consent-choice{margin-top:14px;background:var(--paper);color:var(--ink);border-color:var(--line)}
|
||||
.consent-links{margin:14px 0 0;font-size:12px}
|
||||
.consent-links a{color:#a4510f}
|
||||
.consent-links a{color:var(--accent)}
|
||||
/* While the question is open the bar is fixed at the bottom, so the page has to end above it -
|
||||
otherwise it covers the last element, and an embed's own consent button sat right underneath.
|
||||
Found by the browser test, which could not click it. */
|
||||
@@ -134,11 +144,11 @@ body.consent-open{padding-bottom:260px}
|
||||
@media (max-width:620px){.consent-bar{inset-inline:0;bottom:0;border-radius:14px 14px 0 0;padding:16px}.consent-actions .consent-button{flex:1 1 140px}}
|
||||
|
||||
/* An embedded third party, and what stands in its place until it may load. */
|
||||
.block-embed iframe{display:block;width:100%;border:0;border-radius:10px;background:#eef1ed}
|
||||
.embed-blocked{border:1px dashed #b3c0b7;border-radius:10px;padding:24px;background:#f6f7f4;text-align:center}
|
||||
.block-embed iframe{display:block;width:100%;border:0;border-radius:10px;background:color-mix(in srgb,var(--ink) 8%,var(--paper))}
|
||||
.embed-blocked{border:1px dashed var(--line);border-radius:10px;padding:24px;background:color-mix(in srgb,var(--ink) 4%,var(--paper));text-align:center}
|
||||
.embed-title{margin:0 0 8px;font-weight:600}
|
||||
.embed-hint{margin:0 0 10px;font-size:14px;line-height:1.6;color:#4a5751}
|
||||
.embed-provider{margin:0 0 16px;font-size:13px;color:#4a5751}
|
||||
.embed-hint{margin:0 0 10px;font-size:14px;line-height:1.6;color:var(--muted)}
|
||||
.embed-provider{margin:0 0 16px;font-size:13px;color:var(--muted)}
|
||||
|
||||
/* Header and footer hold block documents of their own (ADR 026). They are therefore ordinary
|
||||
block containers - as flex containers they shrank the section inside them until a single word
|
||||
@@ -152,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);
|
||||
})();
|
||||
@@ -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";
|
||||
@@ -75,6 +75,42 @@ try {
|
||||
await page.getByRole('button', { name: 'Wiederholen', exact: true }).click();
|
||||
await frame.getByRole('heading', { name: 'Meine bearbeitete Website', exact: true }).waitFor();
|
||||
checks.push({ name: 'preview selection, property edit, undo and redo', passed: true });
|
||||
|
||||
// Die Vorschau darf nie sichtbar sein, solange ihre Gestaltung fehlt. Vorher hing das Anwenden
|
||||
// am Ladeereignis des Rahmens - und das feuert erst, wenn das letzte Bild geladen ist. Auf der
|
||||
// nachgebauten Startseite von kant-it.de stand die Vorschau dadurch 1440 ms lang mit falschen
|
||||
// Farben da: schwarze Knöpfe statt roter.
|
||||
const beobachtung = page.evaluate(async () => {
|
||||
const rahmen = document.getElementById('preview');
|
||||
const verlauf = [];
|
||||
let ungestylt = 0;
|
||||
// Zweierlei wird festgehalten: dass der Rahmen ueberhaupt verdeckt wird, waehrend das neue
|
||||
// Dokument entsteht - und ob er je sichtbar war, ohne seine Gestaltung zu haben.
|
||||
new MutationObserver(() => verlauf.push(rahmen.style.visibility || 'sichtbar'))
|
||||
.observe(rahmen, { attributes: true, attributeFilter: ['style'] });
|
||||
const bis = performance.now() + 5000;
|
||||
while (performance.now() < bis) {
|
||||
const doc = rahmen.contentDocument;
|
||||
if ('visible' === getComputedStyle(rahmen).visibility
|
||||
&& doc?.getElementById('kant-styles')
|
||||
&& 0 === (doc.adoptedStyleSheets ?? []).length) {
|
||||
ungestylt++;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 30));
|
||||
}
|
||||
return { verlauf, ungestylt };
|
||||
});
|
||||
await page.click('#duplicate');
|
||||
const { verlauf, ungestylt } = await beobachtung;
|
||||
assert.equal(ungestylt, 0, 'Die Vorschau war sichtbar, bevor ihre Gestaltung angewandt war.');
|
||||
// Der Rahmen muss verdeckt worden und danach wieder sichtbar geworden sein. Ohne diese Prüfung
|
||||
// hinge der Nachweis am Zufall: auf einer kleinen Seite ist das Fenster zu kurz, um es zu
|
||||
// treffen - auf der nachgebauten Startseite von kant-it.de war es 1440 ms lang.
|
||||
assert.ok(verlauf.includes('hidden'), 'Die Vorschau wird beim Aufbau nicht verdeckt.');
|
||||
assert.equal(verlauf[verlauf.length - 1], 'sichtbar', 'Die Vorschau bleibt verdeckt.');
|
||||
await page.click('#undo');
|
||||
await page.locator('#preview-status').filter({ hasText: 'Zum Auswählen' }).waitFor();
|
||||
checks.push({ name: 'preview never shows unstyled content', passed: true });
|
||||
console.log('Preview edits and history passed.');
|
||||
|
||||
await page.getByRole('button', { name: 'Einklappen', exact: true }).click();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,9 @@ 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];
|
||||
|
||||
public function __construct(private Connection $db, private string $directory)
|
||||
{
|
||||
}
|
||||
@@ -57,6 +60,33 @@ final class MediaLibrary
|
||||
return $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
|
||||
{
|
||||
$row['variants'] = json_decode($row['variants'], true, 512, JSON_THROW_ON_ERROR);
|
||||
@@ -109,7 +139,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 {
|
||||
@@ -157,6 +190,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,23 +101,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 +139,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 +174,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 +204,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 +224,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 = $this->variantFor($item['variants'], $device);
|
||||
// The URL comes from the media library, never from the document.
|
||||
$layers[] = sprintf('url("%s")', $largest['src']);
|
||||
}
|
||||
@@ -314,6 +317,58 @@ 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;
|
||||
}
|
||||
|
||||
private 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;
|
||||
}
|
||||
}
|
||||
@@ -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')),
|
||||
|
||||
@@ -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,54 @@ 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)]),
|
||||
'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 +158,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 +185,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 +210,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 +257,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 +352,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 +375,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 +440,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 +464,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 +483,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 +496,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'] ?? []);
|
||||
}
|
||||
|
||||
@@ -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,6 +4,7 @@ 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;
|
||||
@@ -15,11 +16,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)
|
||||
{
|
||||
}
|
||||
|
||||
#[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 +39,12 @@ 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'],
|
||||
]);
|
||||
}
|
||||
|
||||
#[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 +60,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 +92,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');
|
||||
|
||||
@@ -19,11 +19,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 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 +38,11 @@ final class SystemController extends AbstractController
|
||||
'asked' => $request->query->getBoolean('pruefen'),
|
||||
'steps' => $request->getSession()->remove('update_steps') ?: [],
|
||||
'backups' => $this->backup->all(),
|
||||
'adminPath' => $this->adminPath,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/system/update', name: 'admin_system_update', methods: ['POST'])]
|
||||
#[Route('/%kant.admin_path%/system/update', name: 'admin_system_update', methods: ['POST'])]
|
||||
public function update(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
@@ -69,7 +70,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');
|
||||
@@ -94,7 +95,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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -8,6 +8,10 @@ use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
final class AdminHeadersSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private string $adminPath)
|
||||
{
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [KernelEvents::RESPONSE => ['onResponse', -300]];
|
||||
@@ -15,7 +19,7 @@ final class AdminHeadersSubscriber implements EventSubscriberInterface
|
||||
|
||||
public function onResponse(ResponseEvent $event): void
|
||||
{
|
||||
if (!preg_match('#^/admin(?:/|$)#', $event->getRequest()->getPathInfo())) {
|
||||
if (!preg_match('#^/'.preg_quote($this->adminPath, '#').'(?:/|$)#', $event->getRequest()->getPathInfo())) {
|
||||
return;
|
||||
}
|
||||
$headers = $event->getResponse()->headers;
|
||||
@@ -23,6 +27,7 @@ final class AdminHeadersSubscriber implements EventSubscriberInterface
|
||||
$headers->set('X-Robots-Tag', 'noindex, nofollow');
|
||||
$headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$headers->set('Referrer-Policy', 'same-origin');
|
||||
$headers->set('Permissions-Policy', 'accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()');
|
||||
// The preview must show the same typeface as the published page, so the font hosts are
|
||||
// allowed here as well. Everything else stays as narrow as on the public side.
|
||||
$headers->set('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -12,6 +12,9 @@ namespace App\Ops;
|
||||
*/
|
||||
final class CommandRunner
|
||||
{
|
||||
/** @var array<string, list<string>|null> */
|
||||
private array $resolved = [];
|
||||
|
||||
public function available(): bool
|
||||
{
|
||||
if (!function_exists('proc_open')) {
|
||||
@@ -79,9 +82,41 @@ final class CommandRunner
|
||||
return ['ok' => 0 === $exit, 'exit' => $exit, 'output' => trim($output)];
|
||||
}
|
||||
|
||||
/** Whether an executable can be found at all, so a missing one is reported, not guessed at. */
|
||||
/**
|
||||
* Whether an executable can be found at all, so a missing one is reported, not guessed at.
|
||||
*
|
||||
* On Windows several of these are batch files, and proc_open without a shell cannot start a
|
||||
* .bat - which reported "composer is not available" on a machine that has it. The variants are
|
||||
* tried in order, and the one that answers is remembered for the next call.
|
||||
*/
|
||||
public function has(string $executable, string $cwd): bool
|
||||
{
|
||||
return $this->run([$executable, '--version'], $cwd, 30)['ok'];
|
||||
return null !== $this->resolve($executable, $cwd);
|
||||
}
|
||||
|
||||
/** The spelling of a command that actually starts here, or null. */
|
||||
public function resolve(string $executable, string $cwd): ?array
|
||||
{
|
||||
if (array_key_exists($executable, $this->resolved)) {
|
||||
return $this->resolved[$executable];
|
||||
}
|
||||
$candidates = [[$executable]];
|
||||
if ('\\' === DIRECTORY_SEPARATOR) {
|
||||
// On Windows composer is a .bat, and CreateProcess cannot start a batch file on its
|
||||
// own - it needs the command interpreter. The argument list stays fixed, nothing from
|
||||
// a request ever reaches it.
|
||||
$candidates[] = [getenv('COMSPEC') ?: 'cmd.exe', '/c', $executable];
|
||||
}
|
||||
// A phar in the project is the way on hosting where nothing is installed globally.
|
||||
if (is_file($cwd.'/'.$executable.'.phar')) {
|
||||
$candidates[] = [PHP_BINARY, $cwd.'/'.$executable.'.phar'];
|
||||
}
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($this->run([...$candidate, '--version'], $cwd, 30)['ok']) {
|
||||
return $this->resolved[$executable] = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->resolved[$executable] = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops;
|
||||
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Domain\CmsUser;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\Migrations\Configuration\Connection\ExistingConnection;
|
||||
use Doctrine\Migrations\Configuration\Migration\PhpFile;
|
||||
use Doctrine\Migrations\DependencyFactory;
|
||||
use Doctrine\Migrations\Metadata\MigrationPlanList;
|
||||
use Doctrine\Migrations\MigratorConfiguration;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* Everything the browser installer does, without a browser.
|
||||
*
|
||||
* The order is the order of the screens, and every step writes its result immediately instead of
|
||||
* collecting answers in a session: after the database step there is a `.env.local`, after the
|
||||
* migration step there are tables, after the website step there is content. That way a reload, a
|
||||
* timeout or a closed tab never loses a half filled form - the installer simply sees what is
|
||||
* already there and continues at the next missing thing.
|
||||
*
|
||||
* The end of the installation is the first account: `isInstalled()` asks the database, not a file,
|
||||
* because a file can be deleted and would open the installer again on a running website.
|
||||
*/
|
||||
final class Installation
|
||||
{
|
||||
/** Longer than a person needs for a form, short enough that a stale tab stops working. */
|
||||
public const TOKEN_LIFETIME = 7200;
|
||||
|
||||
public function __construct(
|
||||
private Connection $db,
|
||||
private PageRepository $pages,
|
||||
private PartialRepository $partials,
|
||||
private MenuRepository $menus,
|
||||
private SiteSettings $settings,
|
||||
private UserPasswordHasherInterface $hasher,
|
||||
private string $root,
|
||||
private string $mediaDirectory,
|
||||
private string $secret,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Is there an installation? An account is the criterion.
|
||||
*
|
||||
* Everything before that is repeatable: the environment file can be rewritten, migrations are
|
||||
* idempotent, the starter content is only created when it is missing. An account is the point
|
||||
* of no return, so it is also the point at which the installer stops answering.
|
||||
*/
|
||||
public function isInstalled(): bool
|
||||
{
|
||||
try {
|
||||
return (bool) $this->db->fetchOne('SELECT id FROM cms_user LIMIT 1');
|
||||
} catch (\Throwable) {
|
||||
// No database, no table, no installation - all three mean the same here.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The system check, as a list of findings rather than a yes or no.
|
||||
*
|
||||
* Every failing check names the file or path that has to change. A check that is merely
|
||||
* advisable is marked as such, so a missing `proc_open` does not look like a reason to stop.
|
||||
*/
|
||||
/** Ab dieser Fassung kennt SQLite die Tabellenfunktionen, mit denen Doctrine das Schema liest. */
|
||||
public const MIN_SQLITE = '3.16.0';
|
||||
|
||||
/** Die Fassung der eingebauten SQLite-Bibliothek, oder null wenn es keine gibt. */
|
||||
public function sqliteVersion(): ?string
|
||||
{
|
||||
if (!extension_loaded('pdo_sqlite')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$version = (new \PDO('sqlite::memory:'))->query('SELECT sqlite_version()')?->fetchColumn();
|
||||
} catch (\PDOException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_string($version) ? $version : null;
|
||||
}
|
||||
|
||||
public function checks(): array
|
||||
{
|
||||
$checks = [];
|
||||
$required = PHP_VERSION_ID >= 80200;
|
||||
$checks[] = $this->check('PHP-Fassung', $required, sprintf('Gefunden: %s. Gebraucht wird mindestens 8.2 – beim Hoster umstellen, meistens im Kundenmenü unter „PHP-Version“.', PHP_VERSION), true, PHP_VERSION);
|
||||
|
||||
foreach (['pdo' => 'Datenbankzugriff', 'ctype' => 'Zeichenprüfungen', 'iconv' => 'Zeichensätze', 'mbstring' => 'Mehrbytetexte', 'json' => 'Dokumente', 'dom' => 'HTML-Verarbeitung', 'session' => 'Anmeldung'] as $extension => $purpose) {
|
||||
$checks[] = $this->check('Erweiterung '.$extension, extension_loaded($extension), sprintf('%s braucht diese Erweiterung. Beim Hoster aktivieren oder in der php.ini eintragen.', $purpose));
|
||||
}
|
||||
$drivers = array_values(array_intersect(['pdo_sqlite', 'pdo_mysql'], get_loaded_extensions()));
|
||||
$checks[] = $this->check('Datenbanktreiber', [] !== $drivers, 'Weder pdo_sqlite noch pdo_mysql ist vorhanden. Ohne einen von beiden gibt es keine Datenbank.', true, implode(', ', $drivers));
|
||||
|
||||
// Die Fassung von SQLite, und zwar vor der Wahl der Datenbank. Doctrine liest das Schema
|
||||
// mit Tabellenfunktionen wie `pragma_table_info(...)`; die gibt es erst seit SQLite 3.16
|
||||
// (Januar 2017). Auf einem Hosting mit aelterem SQLite bricht sonst mitten in der
|
||||
// Installation eine Abfrage mit `near "(": syntax error` ab - eine Meldung, die niemandem
|
||||
// sagt, was zu tun ist. Gefunden auf einem echten Hosting, nicht hier.
|
||||
$sqlite = $this->sqliteVersion();
|
||||
if (null !== $sqlite) {
|
||||
$checks[] = $this->check(
|
||||
'SQLite-Fassung',
|
||||
version_compare($sqlite, self::MIN_SQLITE, '>='),
|
||||
sprintf('Gefunden: %s. Für eine SQLite-Datenbank wird mindestens %s gebraucht. Diese Installation läuft trotzdem – aber mit MySQL statt SQLite; eine Datenbank legt man beim Hoster im Kundenmenü an.', $sqlite, self::MIN_SQLITE),
|
||||
false,
|
||||
$sqlite,
|
||||
);
|
||||
}
|
||||
|
||||
$webp = extension_loaded('gd') && (gd_info()['WebP Support'] ?? false);
|
||||
$checks[] = $this->check('GD mit WebP', $webp, 'Bilder werden beim Hochladen in WebP umgewandelt. Ohne GD mit WebP-Unterstützung schlägt jeder Upload fehl. Der Rest der Website funktioniert.', false);
|
||||
$checks[] = $this->check('ZIP-Erweiterung', class_exists(\ZipArchive::class), 'Wird für Sicherungen als Archiv gebraucht. Ohne sie bleibt die Sicherung ein Verzeichnis.', false);
|
||||
$checks[] = $this->check('proc_open', \function_exists('proc_open') && !\in_array('proc_open', array_map('trim', explode(',', (string) ini_get('disable_functions'))), true), 'Ohne proc_open kann das Update im Browser nicht laufen. Ein Update über das Releasearchiv geht trotzdem.', false);
|
||||
|
||||
foreach ([$this->root => 'Projektstamm (für .env.local)', $this->root.'/var' => 'var/ (Datenbank, Cache, Sicherungen)', $this->mediaDirectory => 'Medienordner'] as $path => $label) {
|
||||
$checks[] = $this->check('Schreibrecht: '.$label, $this->writable($path), sprintf('%s ist nicht beschreibbar. Im FTP-Programm die Rechte auf 0775 setzen (oder 0755, wenn PHP als Eigentümer läuft).', $path), true, $path);
|
||||
}
|
||||
|
||||
// The one check that is about the web server rather than PHP: if the document root is the
|
||||
// project directory, then var/ and .env are reachable over the network.
|
||||
$documentRoot = realpath((string) ($_SERVER['DOCUMENT_ROOT'] ?? ''));
|
||||
$public = realpath($this->root.'/public');
|
||||
$checks[] = $this->check(
|
||||
'Dokumentenstamm zeigt auf public/',
|
||||
false !== $documentRoot && false !== $public && str_replace('\\', '/', $documentRoot) === str_replace('\\', '/', $public),
|
||||
sprintf('Der Dokumentenstamm ist %s, erwartet wird %s. Sonst sind .env und var/ über das Netz erreichbar. Beim Hoster die Domain auf public/ zeigen lassen.', $documentRoot ?: 'unbekannt', $public ?: $this->root.'/public'),
|
||||
false,
|
||||
$documentRoot ?: null,
|
||||
);
|
||||
$checks[] = $this->check('Geheimnis (APP_SECRET)', '' !== $this->secret && !str_contains($this->secret, 'nicht-gespeichert'), 'Das Geheimnis konnte nicht in .env.local geschrieben werden. Ohne Schreibrecht im Projektstamm bleibt jede Anmeldung nach einem Neustart ungültig.');
|
||||
|
||||
return $checks;
|
||||
}
|
||||
|
||||
/** True when every check that blocks an installation passed. */
|
||||
public function ready(array $checks): bool
|
||||
{
|
||||
return [] === array_filter($checks, static fn (array $check): bool => $check['required'] && !$check['ok']);
|
||||
}
|
||||
|
||||
public function environmentWritten(): bool
|
||||
{
|
||||
return is_file($this->root.'/.env.local') && str_contains((string) file_get_contents($this->root.'/.env.local'), 'DATABASE_URL=');
|
||||
}
|
||||
|
||||
public function schemaReady(): bool
|
||||
{
|
||||
try {
|
||||
$this->db->executeQuery('SELECT 1 FROM cms_user LIMIT 1');
|
||||
|
||||
return true;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function siteReady(): bool
|
||||
{
|
||||
try {
|
||||
return (bool) $this->db->fetchOne('SELECT id FROM website WHERE id = ?', ['default']);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a DSN from what the form asked and proves it works before it is written anywhere.
|
||||
*
|
||||
* Returns the error in plain German, or null when the connection stands. A wrong password has
|
||||
* to show up here and not two screens later, when the file is already written.
|
||||
*/
|
||||
public function testDatabase(string $driver, array $input): array
|
||||
{
|
||||
$url = 'sqlite' === $driver
|
||||
? 'sqlite:///%kernel.project_dir%/var/'.$this->sqliteName($input['name'] ?? 'cms')
|
||||
: sprintf(
|
||||
'mysql://%s:%s@%s:%d/%s?charset=utf8mb4',
|
||||
rawurlencode(trim((string) ($input['user'] ?? ''))),
|
||||
rawurlencode((string) ($input['password'] ?? '')),
|
||||
trim((string) ($input['host'] ?? '127.0.0.1')),
|
||||
(int) ($input['port'] ?? 3306) ?: 3306,
|
||||
rawurlencode(trim((string) ($input['name'] ?? ''))),
|
||||
);
|
||||
|
||||
try {
|
||||
$connection = ConnectionFactory::create($this->resolve($url));
|
||||
$connection->executeQuery('SELECT 1');
|
||||
$connection->close();
|
||||
} catch (\Throwable $e) {
|
||||
return ['url' => $url, 'error' => 'mysql' === $driver
|
||||
? sprintf('Die Datenbank antwortet nicht: %s. Zugangsdaten stehen im Kundenmenü des Hosters; oft ist der Host nicht localhost, sondern ein eigener Name.', $e->getMessage())
|
||||
: sprintf('Die SQLite-Datei ließ sich nicht anlegen: %s. Meistens fehlt das Schreibrecht auf var/.', $e->getMessage())];
|
||||
}
|
||||
|
||||
return ['url' => $url, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes `.env.local`, never `.env`.
|
||||
*
|
||||
* `.env` belongs to the release and is replaced by the next update; `.env.local` belongs to
|
||||
* this installation and survives. Values that are already there stay unless they are given
|
||||
* again, so the website step can add the address without losing the database.
|
||||
*/
|
||||
public function writeEnvironment(array $values): void
|
||||
{
|
||||
$file = $this->root.'/.env.local';
|
||||
$lines = [];
|
||||
foreach (is_file($file) ? (preg_split('/\R/', (string) file_get_contents($file)) ?: []) : [] as $line) {
|
||||
if ('' !== trim($line) && 1 === preg_match('/^([A-Z_]+)=/', $line, $match) && !\array_key_exists($match[1], $values)) {
|
||||
$lines[$match[1]] = $line;
|
||||
}
|
||||
}
|
||||
foreach ($values as $key => $value) {
|
||||
$lines[$key] = $key.'="'.str_replace(['\\', '"', "\n"], ['\\\\', '\"', ''], (string) $value).'"';
|
||||
}
|
||||
$content = "# Diese Datei gehört zu dieser Installation und wird von keinem Update überschrieben.\n".implode("\n", $lines)."\n";
|
||||
$temporary = $file.'.'.bin2hex(random_bytes(4));
|
||||
if (false === file_put_contents($temporary, $content) || !rename($temporary, $file)) {
|
||||
@unlink($temporary);
|
||||
throw new \RuntimeException('.env.local ließ sich nicht schreiben. Bitte das Schreibrecht im Projektstamm prüfen.');
|
||||
}
|
||||
@chmod($file, 0o600);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the migrations in this process.
|
||||
*
|
||||
* Not over the console: on shared hosting there is often no shell, and `proc_open` is a common
|
||||
* casualty of a hardened php.ini. The installer therefore does what the console command does,
|
||||
* with the same configuration file, and returns the versions it applied.
|
||||
*/
|
||||
public function migrate(): array
|
||||
{
|
||||
$factory = DependencyFactory::fromConnection(
|
||||
new PhpFile($this->root.'/migrations.php'),
|
||||
new ExistingConnection($this->db),
|
||||
);
|
||||
$factory->getMetadataStorage()->ensureInitialized();
|
||||
$version = $factory->getVersionAliasResolver()->resolveVersionAlias('latest');
|
||||
$plan = $factory->getMigrationPlanCalculator()->getPlanUntilVersion($version);
|
||||
$configuration = (new MigratorConfiguration())->setAllOrNothing(false)->setTimeAllQueries(false);
|
||||
$factory->getMigrator()->migrate($plan, $configuration);
|
||||
|
||||
return $this->planNames($plan);
|
||||
}
|
||||
|
||||
/** Which migrations would run - the same list the migration screen shows before the button. */
|
||||
public function pendingMigrations(): array
|
||||
{
|
||||
$factory = DependencyFactory::fromConnection(
|
||||
new PhpFile($this->root.'/migrations.php'),
|
||||
new ExistingConnection($this->db),
|
||||
);
|
||||
$factory->getMetadataStorage()->ensureInitialized();
|
||||
|
||||
return $this->planNames($factory->getMigrationPlanCalculator()->getPlanUntilVersion(
|
||||
$factory->getVersionAliasResolver()->resolveVersionAlias('latest'),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* The website: name, address, language - and the content a new site cannot do without.
|
||||
*
|
||||
* A start page, a header and a footer with a menu, and the two legal pages as a skeleton with
|
||||
* every gap marked. The last part is not a courtesy: a German business website without an
|
||||
* imprint and a privacy statement is a problem from its first day, and the installer is the
|
||||
* only moment at which somebody is certainly looking.
|
||||
*/
|
||||
public function createSite(string $name, string $origin, string $locale): array
|
||||
{
|
||||
$name = trim($name);
|
||||
if ('' === $name || mb_strlen($name) > 120) {
|
||||
throw new InvalidPage('Bitte gib einen Namen für die Website an (höchstens 120 Zeichen).');
|
||||
}
|
||||
$origin = rtrim(trim($origin), '/');
|
||||
if (!str_starts_with($origin, 'https://') && !str_starts_with($origin, 'http://')) {
|
||||
throw new InvalidPage('Die Adresse beginnt mit https:// – zum Beispiel https://meine-firma.de');
|
||||
}
|
||||
if (false === filter_var($origin, FILTER_VALIDATE_URL)) {
|
||||
throw new InvalidPage('Diese Adresse ist keine gültige URL.');
|
||||
}
|
||||
if (!\in_array($locale, ['de', 'en'], true)) {
|
||||
throw new InvalidPage('Als Sprache stehen Deutsch und Englisch zur Wahl.');
|
||||
}
|
||||
|
||||
$created = [];
|
||||
$this->db->transactional(function () use ($name, $locale, &$created): void {
|
||||
if (!$this->db->fetchOne('SELECT id FROM website WHERE id = ?', ['default'])) {
|
||||
$this->db->insert('website', [
|
||||
'id' => 'default',
|
||||
'name' => mb_substr($name, 0, 120),
|
||||
'default_locale' => $locale,
|
||||
'theme' => json_encode(['template' => 'kant-starter', 'version' => 1], JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
} else {
|
||||
$this->db->update('website', ['name' => mb_substr($name, 0, 120), 'default_locale' => $locale], ['id' => 'default']);
|
||||
}
|
||||
|
||||
if (null === $this->pages->findPublished('home', $locale)) {
|
||||
$id = $this->pages->createPage('home', null, 'page', DemoContent::starterPage($name), $locale);
|
||||
$page = $this->pages->findForEditor($id);
|
||||
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
|
||||
$created[] = 'Startseite';
|
||||
}
|
||||
foreach (DemoContent::LEGAL as $slug => $label) {
|
||||
if (null === $this->pages->findPublished($slug, $locale)) {
|
||||
$id = $this->pages->createPage($slug, null, 'page', DemoContent::legalPage($slug), $locale);
|
||||
$page = $this->pages->findForEditor($id);
|
||||
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
|
||||
$created[] = $label;
|
||||
}
|
||||
}
|
||||
foreach (DemoContent::starterAreas($name) as $kind => $area) {
|
||||
if (null === $this->partials->findByKind($kind, 'default', $locale)) {
|
||||
$this->partials->create($kind, 'default', $area['name'], $area['document'], $locale);
|
||||
$created[] = $area['name'];
|
||||
}
|
||||
}
|
||||
$this->createMenu($locale, $created);
|
||||
});
|
||||
|
||||
$this->writeEnvironment(['SITE_ORIGIN' => $origin]);
|
||||
$settings = $this->settings->load();
|
||||
$this->settings->save([
|
||||
...$settings,
|
||||
'legalName' => $name,
|
||||
'privacyPath' => 'datenschutz',
|
||||
'imprintPath' => 'impressum',
|
||||
'sameAs' => '',
|
||||
'consentGroups' => $settings['consentGroups'],
|
||||
]);
|
||||
|
||||
return $created;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first account, and with it the end of the installer.
|
||||
*
|
||||
* The rules are the ones the user administration uses, because an account created here is not
|
||||
* a lesser account - it is the one that can do everything.
|
||||
*/
|
||||
public function createAdmin(string $username, string $password, string $repeated): void
|
||||
{
|
||||
$username = strtolower(trim($username));
|
||||
if (1 !== preg_match('/^[a-z0-9][a-z0-9._-]{2,99}$/D', $username)) {
|
||||
throw new InvalidPage('Der Benutzername braucht 3–100 Zeichen: Kleinbuchstaben, Ziffern, Punkt, Unterstrich oder Bindestrich.');
|
||||
}
|
||||
if (\strlen($password) < 14 || \strlen($password) > 72 || str_contains($password, "\0")) {
|
||||
throw new InvalidPage('Das Passwort braucht mindestens 14 Zeichen (höchstens 72 Bytes).');
|
||||
}
|
||||
if (!hash_equals($password, $repeated)) {
|
||||
throw new InvalidPage('Die beiden Passwörter stimmen nicht überein.');
|
||||
}
|
||||
if ($this->isInstalled()) {
|
||||
throw new InvalidPage('Es gibt bereits ein Konto. Die Installation ist damit abgeschlossen.');
|
||||
}
|
||||
$user = new CmsUser(bin2hex(random_bytes(16)), $username, '', ['ROLE_ADMIN']);
|
||||
$this->db->insert('cms_user', [
|
||||
'id' => $user->id,
|
||||
'username' => $username,
|
||||
'password_hash' => $this->hasher->hashPassword($user, $password),
|
||||
'roles' => json_encode($user->getRoles(), JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A signature instead of a session token.
|
||||
*
|
||||
* The session cookie of this application is scoped to `/admin`, so the installer has no
|
||||
* session - and it should not get one for six screens. The signature ties a form to a step and
|
||||
* to a point in time. It is not protection against somebody who can reach the installer: that
|
||||
* protection is that the installer stops existing after the first account, which is why the
|
||||
* last screen says to install right after uploading.
|
||||
*/
|
||||
public function sign(string $step, int $issued): string
|
||||
{
|
||||
return hash_hmac('sha256', $step.'|'.$issued, $this->secret);
|
||||
}
|
||||
|
||||
public function verify(string $step, int $issued, string $signature): bool
|
||||
{
|
||||
return abs(time() - $issued) <= self::TOKEN_LIFETIME && hash_equals($this->sign($step, $issued), $signature);
|
||||
}
|
||||
|
||||
private function createMenu(string $locale, array &$created): void
|
||||
{
|
||||
if ([] !== $this->menus->all($locale)) {
|
||||
return;
|
||||
}
|
||||
$items = [];
|
||||
foreach ($this->db->fetchAllAssociative('SELECT id, path FROM page WHERE website_id = ? AND locale = ?', ['default', $locale]) as $page) {
|
||||
if (\in_array($page['path'], ['home', 'datenschutz', 'impressum'], true)) {
|
||||
$items['home' === $page['path'] ? 'header' : 'footer'][] = [
|
||||
'label' => ['home' => 'Start', 'datenschutz' => 'Datenschutz', 'impressum' => 'Impressum'][$page['path']],
|
||||
'type' => 'page',
|
||||
'pageId' => $page['id'],
|
||||
'anchor' => '',
|
||||
'children' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
foreach (['header', 'footer'] as $slot) {
|
||||
if (isset($items[$slot])) {
|
||||
$this->menus->save($slot, MenuRepository::SLOTS[$slot], $items[$slot], 0, $locale);
|
||||
$created[] = 'header' === $slot ? 'Hauptmenü' : 'Fußmenü';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function planNames(MigrationPlanList $plan): array
|
||||
{
|
||||
$names = [];
|
||||
foreach ($plan->getItems() as $item) {
|
||||
$names[] = (string) $item->getVersion();
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
private function check(string $label, bool $ok, string $hint, bool $required = true, ?string $found = null): array
|
||||
{
|
||||
return ['label' => $label, 'ok' => $ok, 'required' => $required, 'hint' => $hint, 'found' => $found];
|
||||
}
|
||||
|
||||
/** A directory that does not exist yet counts as writable when its parent is. */
|
||||
private function writable(string $path): bool
|
||||
{
|
||||
return is_dir($path) ? is_writable($path) : (is_dir(\dirname($path)) && is_writable(\dirname($path)));
|
||||
}
|
||||
|
||||
private function sqliteName(string $name): string
|
||||
{
|
||||
$name = preg_replace('/[^a-z0-9_-]/', '', strtolower(trim($name))) ?: 'cms';
|
||||
|
||||
return mb_substr($name, 0, 40).'.db';
|
||||
}
|
||||
|
||||
private function resolve(string $url): string
|
||||
{
|
||||
return str_replace('%kernel.project_dir%', str_replace('\\', '/', $this->root), $url);
|
||||
}
|
||||
}
|
||||
+24
-5
@@ -195,6 +195,9 @@ final class Updater
|
||||
return ['ok' => false, 'steps' => $steps, 'target' => null];
|
||||
}
|
||||
$from = $this->git(['rev-parse', 'HEAD'])['output'];
|
||||
// Remembered before the checkout: if the dependencies change and Composer is missing,
|
||||
// this is what tells us - vendor/ is outside Git and would silently stay old.
|
||||
$lockBefore = $this->lockHash();
|
||||
$step($steps, 'Ziel bestimmen', true, sprintf('%s (von %s, Fassung %s)', $target, substr($from, 0, 8), $this->version()));
|
||||
|
||||
if ($target === 'v'.$this->version()) {
|
||||
@@ -229,13 +232,20 @@ final class Updater
|
||||
throw new \RuntimeException('version');
|
||||
}
|
||||
|
||||
if ($this->runner->has('composer', $this->root)) {
|
||||
$install = $this->runner->run(['composer', 'install', '--no-dev', '--optimize-autoloader', '--no-interaction'], $this->root, 600);
|
||||
$composer = $this->runner->resolve('composer', $this->root);
|
||||
if (null !== $composer) {
|
||||
$install = $this->runner->run([...$composer, 'install', '--no-dev', '--optimize-autoloader', '--no-interaction'], $this->root, 600);
|
||||
if (!$step($steps, 'Abhängigkeiten', $install['ok'], $install['ok'] ? 'composer install ausgeführt.' : $install['output'])) {
|
||||
throw new \RuntimeException('composer');
|
||||
}
|
||||
} elseif ($lockBefore !== $this->lockHash()) {
|
||||
// Without Composer the dependencies cannot be brought up to date, and this update
|
||||
// needs different ones. Going on would leave an installation whose code and
|
||||
// vendor/ do not match - which fails somewhere else, later, for no visible reason.
|
||||
$step($steps, 'Abhängigkeiten', false, 'Dieses Update ändert die Abhängigkeiten (composer.lock), und auf diesem Server ist Composer nicht aufrufbar. Spiele stattdessen das Releasearchiv ein, das vendor/ mitbringt.');
|
||||
throw new \RuntimeException('lock');
|
||||
} else {
|
||||
$step($steps, 'Abhängigkeiten', true, 'Composer ist nicht vorhanden; vendor/ kommt aus dem Tag.');
|
||||
$step($steps, 'Abhängigkeiten', true, 'Composer ist nicht vorhanden; composer.lock ist unverändert, vendor/ bleibt gültig.');
|
||||
}
|
||||
|
||||
$migrated = true;
|
||||
@@ -312,8 +322,9 @@ final class Updater
|
||||
: 'Die Sicherung ließ sich nicht zurückschreiben. Die Datenbank liegt unter '.dirname((string) $backup).'.');
|
||||
}
|
||||
|
||||
if ($this->runner->has('composer', $this->root)) {
|
||||
$this->runner->run(['composer', 'install', '--no-dev', '--optimize-autoloader', '--no-interaction'], $this->root, 600);
|
||||
$composer = $this->runner->resolve('composer', $this->root);
|
||||
if (null !== $composer) {
|
||||
$this->runner->run([...$composer, 'install', '--no-dev', '--optimize-autoloader', '--no-interaction'], $this->root, 600);
|
||||
}
|
||||
$this->console(['bin/console', 'cache:clear', '--env=prod'], 300);
|
||||
$health = $this->console(['bin/console', 'cms:health', '--env=prod'], 120);
|
||||
@@ -352,6 +363,14 @@ final class Updater
|
||||
);
|
||||
}
|
||||
|
||||
/** The checksum of composer.lock, or an empty string when there is none. */
|
||||
private function lockHash(): string
|
||||
{
|
||||
$file = $this->root.'/composer.lock';
|
||||
|
||||
return is_readable($file) ? (string) hash_file('sha256', $file) : '';
|
||||
}
|
||||
|
||||
/** @return array{ok: bool, exit: int, output: string} */
|
||||
private function git(array $arguments, int $timeout = 60): array
|
||||
{
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>{% block title %}Verwaltung{% endblock %} · KantSites</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/assets/admin.css">
|
||||
{% include 'favicon.html.twig' %}
|
||||
<link rel="stylesheet" href="/assets/admin.css?v={{ kant_version() }}">
|
||||
{% block styles %}{% endblock %}
|
||||
{% block scripts %}{% endblock %}
|
||||
</head>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
{% set subject = area ? area.document : page.document %}
|
||||
{% block title %}{{ subject.title }} bearbeiten{% endblock %}
|
||||
{% block body_class %}editor-body{% endblock %}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/editor.css">{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/editor.js"></script>{% endblock %}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/editor.css?v={{ kant_version() }}">{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/editor.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block body %}
|
||||
{# The API base decides what is edited. An area is shared, so it has no draft and no
|
||||
publication: saving is live, and the publish button is left out. #}
|
||||
@@ -27,7 +27,7 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div id="library" class="library">
|
||||
{% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie', postlist:'Beitragsliste', iconcard:'Symbolkarte', stat:'Kennzahl', quote:'Kundenstimme', logos:'Logoleiste', cta:'Aufruf-Band', contact:'Kontaktdaten', accordion:'Akkordeon', accordionitem:'Eintrag', tabs:'Tabs', tabitem:'Tab', form:'Formular', breadcrumb:'Brotkrumen', menu:'Navigation', partialref:'Verkn. Sektion', embed:'Externer Inhalt', icon:'Symbol', progress:'Fortschritt', slider:'Slider', divider:'Trennlinie', spacer:'Abstand'} %}<button type="button" data-add="{{ type }}" disabled><span aria-hidden="true">{{ {section:'▣',row:'☰',column:'▥',heading:'H',text:'T',image:'▧',button:'↗',gallery:'▦',postlist:'☷',iconcard:'◈',stat:'№',quote:'❝',logos:'⬚',cta:'◉',contact:'✆',accordion:'⌵',accordionitem:'·',tabs:'▭',tabitem:'▫',form:'✉',breadcrumb:'⟩',menu:'☰',partialref:'⧉',embed:'⬓',icon:'◆',progress:'▰',slider:'⇄',divider:'—',spacer:'⇳'}[type] }}</span>{{ label }}</button>{% endfor %}
|
||||
{% for type, label in {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', accordion:'Akkordeon', accordionitem:'Eintrag', tabs:'Tabs', tabitem:'Tab', form:'Formular', breadcrumb:'Brotkrumen', menu:'Navigation', legal:'Rechtszeile', partialref:'Verkn. Sektion', embed:'Externer Inhalt', icon:'Symbol', progress:'Fortschritt', slider:'Slider', divider:'Trennlinie', spacer:'Abstand'} %}<button type="button" data-add="{{ type }}" disabled><span aria-hidden="true">{{ {section:'▣',row:'☰',column:'▥',heading:'H',text:'T',image:'▧',button:'↗',gallery:'▦',postlist:'☷',postnav:'⇄',iconcard:'◈',stat:'№',quote:'❝',logos:'⬚',cta:'◉',contact:'✆',accordion:'⌵',accordionitem:'·',tabs:'▭',tabitem:'▫',form:'✉',breadcrumb:'⟩',menu:'☰',legal:'§',partialref:'⧉',embed:'⬓',icon:'◆',progress:'▰',slider:'⇄',divider:'—',spacer:'⇳'}[type] }}</span>{{ label }}</button>{% endfor %}
|
||||
</div>
|
||||
<button id="open-media-library" class="secondary full-width media-launch" disabled>Medienverwaltung öffnen</button>
|
||||
<h3 class="preset-heading">Fertige Abschnitte</h3><p class="panel-hint">Wird nach dem aktuellen Seitenabschnitt eingefügt.</p>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends 'admin/shell.html.twig' %}
|
||||
{% set nav = 'media' %}
|
||||
{% block title %}Mediathek{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/media-drop.js"></script>{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/media-drop.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block heading %}Mediathek{% endblock %}
|
||||
{% block lead %}Alle Bilder, neueste zuerst. Beim Hochladen wird jedes Bild in WebP umgewandelt und in mehreren Breiten abgelegt; die Seite liefert dann die passende aus.{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
@@ -29,6 +29,13 @@
|
||||
</select>
|
||||
<p class="field-help">Beiträge sind für Blogartikel gedacht und haben keine Unterseiten.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-layout">Aufbau (nur für Beiträge)</label>
|
||||
<select id="new-layout" name="layout">
|
||||
{% for key, layout in postLayouts %}<option value="{{ key }}"{{ key == 'bildkopf' ? ' selected' : '' }}>{{ layout.name }}</option>{% endfor %}
|
||||
</select>
|
||||
<p class="field-help">{% for key, layout in postLayouts %}<strong>{{ layout.name }}:</strong> {{ layout.description }}{% if not loop.last %} <br>{% endif %}{% endfor %} Jeder Block lässt sich danach ändern.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-parent">Übergeordnete Seite</label>
|
||||
<select id="new-parent" name="parent_id">
|
||||
@@ -81,6 +88,11 @@
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="seotitle-{{ page.id }}">Titel für Suchergebnisse</label>
|
||||
<input id="seotitle-{{ page.id }}" name="seoTitle" maxlength="160" value="{{ page.seo_title }}" placeholder="{{ page.document.title|default(page.path) }}">
|
||||
<p class="field-help">Leer lassen, wenn der Titel der Seite auch in der Suche stehen soll. Google zeigt etwa 60 Zeichen und schneidet danach ab; was dahinter steht, liest niemand.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="robots-{{ page.id }}">Suchmaschinen</label>
|
||||
<select id="robots-{{ page.id }}" name="robots">
|
||||
|
||||
@@ -69,10 +69,72 @@
|
||||
<p class="field-help">Braucht mindestens Straße und Ort. Sinnvoll bei einem Standort mit Kundenverkehr.</p>
|
||||
<label class="check-label" for="llmsTxt"><input id="llmsTxt" name="llmsTxt" type="checkbox" value="1"{{ settings.llmsTxt ? ' checked' : '' }}> <code>llms.txt</code> ausliefern</label>
|
||||
<p class="field-help">Eine maschinenlesbare Liste der veröffentlichten, indexierbaren Seiten. Optional. Kein Rankingfaktor und keine Voraussetzung für irgendeine Suchfunktion; ohne Aktivierung antwortet die Adresse mit 404.</p>
|
||||
<label for="faviconMediaId">Symbol der Website</label>
|
||||
<div class="favicon-field">
|
||||
{% if settings.faviconMediaId %}<img class="favicon-preview" src="/favicon.png?v={{ settings.faviconMediaId|slice(0, 8) }}" alt="" width="32" height="32">{% endif %}
|
||||
<select id="faviconMediaId" name="faviconMediaId">
|
||||
<option value="">Mitgeliefertes Zeichen</option>
|
||||
{% for image in images %}<option value="{{ image.id }}"{{ settings.faviconMediaId == image.id ? ' selected' : '' }}>{{ image.title }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<p class="field-help">Das Bild im Browser-Reiter und auf dem Startbildschirm. Aus dem gewählten Bild entsteht beim Speichern ein quadratischer Ausschnitt aus der Mitte in 32 und 180 Pixeln – ein Logo mit viel Rand wird darin klein, ein quadratisches Zeichen funktioniert am besten.</p>
|
||||
<div class="section-head"><h2>Knopf „nach oben"</h2><span class="muted">Erscheint beim Scrollen, in der Ecke</span></div>
|
||||
<label class="check-label" for="scrollTop"><input id="scrollTop" name="scrollTop" type="checkbox" value="1"{{ settings.scrollTop ? ' checked' : '' }}> Knopf „nach oben" anzeigen</label>
|
||||
<p class="field-help">Springt an den Seitenanfang. Er braucht kein JavaScript: In Browsern mit Scroll-Zeitachse blendet er sich beim Scrollen ein, in den übrigen steht er von Anfang an da.</p>
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="scrollTopPosition">Seite</label>
|
||||
<select id="scrollTopPosition" name="scrollTopPosition">
|
||||
<option value="rechts"{{ settings.scrollTopPosition == 'rechts' ? ' selected' : '' }}>Rechts unten</option>
|
||||
<option value="links"{{ settings.scrollTopPosition == 'links' ? ' selected' : '' }}>Links unten</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="scrollTopShape">Form</label>
|
||||
<select id="scrollTopShape" name="scrollTopShape">
|
||||
<option value="rund"{{ settings.scrollTopShape == 'rund' ? ' selected' : '' }}>Rund</option>
|
||||
<option value="weich"{{ settings.scrollTopShape == 'weich' ? ' selected' : '' }}>Abgerundet</option>
|
||||
<option value="eckig"{{ settings.scrollTopShape == 'eckig' ? ' selected' : '' }}>Eckig</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="scrollTopSize">Größe</label>
|
||||
<input id="scrollTopSize" name="scrollTopSize" type="number" min="36" max="72" value="{{ settings.scrollTopSize }}">
|
||||
<p class="field-help">36 bis 72 Pixel. Unter 44 wird er auf dem Telefon schwer zu treffen.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="scrollTopAfter">Erscheint nach</label>
|
||||
<input id="scrollTopAfter" name="scrollTopAfter" type="number" min="0" max="4000" step="50" value="{{ settings.scrollTopAfter }}">
|
||||
<p class="field-help">Pixel Scrollweg. 0 heißt: sofort.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="scrollTopColor">Farbe</label>
|
||||
<input id="scrollTopColor" name="scrollTopColor" value="{{ settings.scrollTopColor }}" placeholder="@accent">
|
||||
</div>
|
||||
<div>
|
||||
<label for="scrollTopInk">Farbe des Pfeils</label>
|
||||
<input id="scrollTopInk" name="scrollTopInk" value="{{ settings.scrollTopInk }}" placeholder="#ffffff">
|
||||
</div>
|
||||
<div>
|
||||
<label for="scrollTopLabel">Beschriftung für Vorlesesoftware</label>
|
||||
<input id="scrollTopLabel" name="scrollTopLabel" maxlength="60" value="{{ settings.scrollTopLabel }}">
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help">Farben entweder als <code>@schlüssel</code> aus der Palette oder als Wert wie <code>#1a2b3c</code>. Leer bedeutet: Akzentfarbe des Designs auf Weiß.</p>
|
||||
<div class="section-head"><h2>Spamschutz für Formulare</h2><span class="muted">Optional, von Cloudflare</span></div>
|
||||
<p class="field-help">Eingebaut sind bereits drei Prüfungen ohne Dritte: ein verstecktes Feld, ein signierter Zeitstempel und höchstens fünf Absendungen je Absender und Stunde. Das hält automatisch ausgefüllte Formulare ab. Gegen jemanden, der einen echten Browser fernsteuert, hilft <strong>Cloudflare Turnstile</strong> – die Prüfung ohne Bilderrätsel. Der Preis: das Widget braucht JavaScript (ohne Skript ist das Formular dann nicht absendbar), und die IP-Adresse des Absenders geht an Cloudflare, was in die Datenschutzerklärung gehört. Schlüssel gibt es kostenlos im Cloudflare-Konto unter „Turnstile“.</p>
|
||||
<label class="check-label" for="turnstile"><input id="turnstile" name="turnstile" type="checkbox" value="1"{{ settings.turnstile ? ' checked' : '' }}> Turnstile-Prüfung in Formularen anzeigen</label>
|
||||
<label for="turnstileSiteKey">Öffentlicher Schlüssel (Site Key)</label>
|
||||
<input id="turnstileSiteKey" name="turnstileSiteKey" maxlength="100" value="{{ settings.turnstileSiteKey }}" placeholder="0x4AAAAAAA…">
|
||||
<label for="turnstileSecret">Geheimer Schlüssel (Secret Key)</label>
|
||||
<input id="turnstileSecret" name="turnstileSecret" maxlength="100" value="{{ settings.turnstileSecret }}" placeholder="0x4AAAAAAA…" autocomplete="off">
|
||||
<p class="field-help">Antwortet Cloudflare einmal nicht, wird die Anfrage trotzdem angenommen: ein Ausfall dort darf keine Kundenanfrage verschlucken.</p>
|
||||
<label for="robotsExtra">Zusätzliche Zeilen für robots.txt</label>
|
||||
<textarea id="robotsExtra" name="robotsExtra" rows="3">{{ settings.robotsExtra }}</textarea>
|
||||
<p class="field-help">Nur Kommentarzeilen und einfache Anweisungen wie <code>Disallow: /pfad</code>. Höchstens dreißig Zeilen.</p>
|
||||
<textarea id="robotsExtra" name="robotsExtra" rows="8">{{ settings.robotsExtra }}</textarea>
|
||||
<p class="field-help">Nur Kommentarzeilen und einfache Anweisungen wie <code>Disallow: /pfad</code>, <code>Allow:</code>, <code>User-agent:</code> und <code>Crawl-delay:</code>. Leerzeilen trennen Blöcke, höchstens 200 Zeilen. Hier stehen zum Beispiel eigene Regeln je Bot – etwa welche KI-Crawler die Website lesen dürfen und welche nicht.</p>
|
||||
<div class="section-head"><h2>Einwilligung</h2><span class="muted">Nur nötig, wenn die Website etwas von Dritten lädt</span></div>
|
||||
<label class="check-label" for="stickyHeader"><input id="stickyHeader" name="stickyHeader" type="checkbox" value="1"{{ settings.stickyHeader ? ' checked' : '' }}> Kopfbereich beim Scrollen oben halten</label>
|
||||
<label class="check-label" for="postMetaHeader"><input id="postMetaHeader" name="postMetaHeader" type="checkbox" value="1"{{ settings.postMetaHeader ? ' checked' : '' }}> Datum, Autor und Kategorie über einem Beitrag zeigen</label>
|
||||
<label class="check-label" for="consentBanner"><input id="consentBanner" name="consentBanner" type="checkbox" value="1"{{ settings.consentBanner ? ' checked' : '' }}> Einwilligungshinweis anzeigen</label>
|
||||
<p class="field-help">Ohne eingebettete Inhalte anderer Anbieter braucht diese Website keine Einwilligung – dann ist ein Banner nur im Weg und sollte aus bleiben. Eingebettete Inhalte laden ohnehin erst nach Zustimmung, unabhängig von diesem Schalter.</p>
|
||||
<fieldset class="field-set">
|
||||
|
||||
@@ -13,6 +13,15 @@
|
||||
<div class="card-body">
|
||||
<dl class="facts">
|
||||
<dt>Fassung</dt><dd><strong>{{ state.version }}</strong></dd>
|
||||
<dt>Adresse der Verwaltung</dt>
|
||||
<dd>
|
||||
<code>/{{ adminPath }}</code>
|
||||
{% if adminPath == 'admin' %}
|
||||
<span class="muted">– die Adresse, die jeder Scanner zuerst probiert. Ändern mit <code>php bin/console cms:admin:path <neu></code>; sie steht danach in <code>.env.local</code> und in keiner öffentlichen Datei.</span>
|
||||
{% else %}
|
||||
<span class="muted">– geändert. Sie wird in der <code>robots.txt</code> nicht genannt; notiere sie dir an einem sicheren Ort. Zurück mit <code>php bin/console cms:admin:path admin</code>.</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt>Stand</dt><dd><code>{{ state.describe }}</code>{% if state.commit != 'unbekannt' %} · Commit <code>{{ state.commit }}</code>{% endif %}</dd>
|
||||
<dt>Verfügbar</dt>
|
||||
<dd>
|
||||
|
||||
@@ -85,6 +85,10 @@
|
||||
<input id="base-scale" name="base[headingScale]" type="number" step="0.01" min="1" max="2" value="{{ theme.base.headingScale }}">
|
||||
<p class="field-help">1,25 ergibt eine ruhige Staffelung, 1,5 eine sehr kontrastreiche.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="check-label" for="base-underline"><input id="base-underline" name="base[linkUnderline]" type="checkbox" value="1"{{ (theme.base.linkUnderline ?? true) ? ' checked' : '' }}> Links im Fließtext unterstreichen</label>
|
||||
<p class="field-help">Aus sieht ruhiger aus, aber dann erkennt man einen Link im Text nur an der Farbe – für Menschen mit Farbsehschwäche also gar nicht. Beim Überfahren erscheint der Strich in beiden Fällen. Menüs, Karten und Schaltflächen sind davon nicht betroffen.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-container">Inhaltsbreite</label>
|
||||
<input id="base-container" name="base[containerWidth]" value="{{ theme.base.containerWidth }}" placeholder="1240px">
|
||||
@@ -106,5 +110,23 @@
|
||||
<p class="field-help">Farben entweder als <code>@schlüssel</code> aus der Palette oder als Wert wie <code>#1a2b3c</code>.</p>
|
||||
<button class="primary" type="submit">Design speichern</button>
|
||||
</form>
|
||||
|
||||
<div class="section-head"><h2>Schriften ausliefern</h2><span class="muted">Von Google oder vom eigenen Server</span></div>
|
||||
{% if fontsMissing is empty and not fontsLocal %}
|
||||
<p class="field-help">Dieses Design nutzt nur Systemschriften – es lädt ohnehin nichts von Google.</p>
|
||||
{% else %}
|
||||
<p class="field-help">
|
||||
{% if fontsLocal %}
|
||||
<strong>Die Schriften liegen auf diesem Server.</strong> Besucher laden nichts von Google, und die Seite baut sich schneller auf, weil eine Kette aus zwei fremden Verbindungen entfällt.
|
||||
{% else %}
|
||||
<strong>Die Schriften kommen zurzeit von Google.</strong> Damit erfährt Google die IP-Adresse jedes Besuchers – in Deutschland ein abmahnfähiger Mangel (LG München I, 3 O 17493/20) –, und der Seitenaufbau wartet auf zwei fremde Verbindungen nacheinander. Mit dem Knopf werden die Dateien einmalig hierher geholt; die Lizenz der Schriften erlaubt das ausdrücklich.
|
||||
{% endif %}
|
||||
{% if fontsMissing %}<br>Noch nicht hier: {{ fontsMissing|join(', ') }}.{% endif %}
|
||||
</p>
|
||||
<form method="post" action="{{ path('admin_theme_fonts') }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('theme') }}">
|
||||
<button type="submit">{{ fontsLocal ? 'Schriften erneut holen' : 'Schriften auf diesen Server holen' }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<p class="footnote">KantSites · Arbeitsstand</p>
|
||||
{% endblock %}
|
||||
@@ -6,19 +6,25 @@
|
||||
<title>{{ heading }}{% if result.page > 1 %} · Seite {{ result.page }}{% endif %} · Beiträge</title>
|
||||
<meta name="description" content="{{ term ? 'Beiträge zu ' ~ term.name : 'Alle Beiträge' }}.">
|
||||
<link rel="canonical" href="{{ canonical }}">
|
||||
{# Auch ein geteiltes Archiv soll einen Namen haben statt einer nackten Adresse. #}
|
||||
<meta property="og:title" content="{{ heading }}{% if result.page > 1 %} · Seite {{ result.page }}{% endif %}">
|
||||
<meta property="og:description" content="{{ term ? 'Beiträge zu ' ~ term.name : 'Alle Beiträge' }}.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{{ canonical }}">
|
||||
<meta name="twitter:card" content="summary">
|
||||
{% if result.page > 1 %}<link rel="prev" href="{{ base }}{{ result.page > 2 ? '?seite=' ~ (result.page - 1) : '' }}">{% endif %}
|
||||
{% if result.page < result.pages %}<link rel="next" href="{{ base }}?seite={{ result.page + 1 }}">{% endif %}
|
||||
<link rel="alternate" type="application/rss+xml" title="Beiträge" href="/feed.xml">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/assets/site.css">
|
||||
<link rel="stylesheet" href="/assets/design.css">
|
||||
{% include 'favicon.html.twig' %}
|
||||
<link rel="stylesheet" href="/assets/site.css?v={{ kant_version() }}">
|
||||
<link rel="stylesheet" href="/assets/design.css?v={{ kant_version() }}">
|
||||
</head>
|
||||
{% set consentOpen = not editing|default(false) and consentGate.needsBanner(app.request) %}
|
||||
<body class="kant{{ consentOpen ? ' consent-open' : '' }}">
|
||||
<a class="skip-link" href="#main">Zum Inhalt springen</a>
|
||||
{% if areas.header|default(null) %}
|
||||
<header class="site-header">
|
||||
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true, eagerImage: null} only %}{% endfor %}
|
||||
<header class="site-header{{ siteSettings.load().stickyHeader ? ' is-sticky' : '' }}">
|
||||
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true, richHeadings: areas.header.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %}
|
||||
</header>
|
||||
{% endif %}
|
||||
<main id="main" tabindex="-1">
|
||||
@@ -44,7 +50,7 @@
|
||||
</main>
|
||||
{% if areas.footer|default(null) %}
|
||||
<footer class="site-footer">
|
||||
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true, eagerImage: null} only %}{% endfor %}
|
||||
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true, richHeadings: areas.footer.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %}
|
||||
</footer>
|
||||
{% endif %}
|
||||
{# Privacy statement, imprint and the way back to the consent decision. All three have to be
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
{% set tag = type == 'section' ? 'section' : 'div' %}
|
||||
<{{ tag }} id="{{ block.id }}" class="block-{{ type }} tone-{{ layout.tone|default('paper') }} width-{{ layout.width|default('normal') }}{{ designClasses }}">
|
||||
{% if type == 'section' %}<div class="shell section-inner">{% endif %}
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}{% endfor %}
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
|
||||
{% if type == 'section' %}</div>{% endif %}
|
||||
</{{ tag }}>
|
||||
{% elseif type == 'heading' %}
|
||||
<h{{ props.level }} id="{{ block.id }}" class="{{ designClasses|trim }}">{{ props.text }}</h{{ props.level }}>
|
||||
<h{{ props.level }} id="{{ block.id }}" class="{{ designClasses|trim }}">{% if richHeadings|default(false) %}{{ props.text|raw }}{% else %}{{ props.text }}{% endif %}</h{{ props.level }}>
|
||||
{% elseif type == 'text' %}
|
||||
{% if richText|default(false) %}
|
||||
{# Already filtered by RichText when it was stored, so printing it unescaped is safe.
|
||||
@@ -26,7 +26,27 @@
|
||||
{% elseif type == 'button' %}
|
||||
<a id="{{ block.id }}" class="button{{ designClasses }}" href="{{ props.href }}">{{ props.text }} <span aria-hidden="true">↗</span></a>
|
||||
{% elseif type == 'image' %}
|
||||
<figure id="{{ block.id }}" class="block-image{{ designClasses }}">{% set eager = eagerImage|default(null) == block.id %}<img src="{{ props.src }}" alt="{{ props.alt }}" width="{{ props.width }}" height="{{ props.height }}" loading="{{ eager ? 'eager' : 'lazy' }}"{{ eager ? ' fetchpriority="high"' : '' }} decoding="async"{% if props.sources|default([]) %} srcset="{% for source in props.sources %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px"{% endif %}>{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure>
|
||||
{# Wie breit das Bild tatsaechlich erscheint. Steht im Block eine feste Breite - ein Logo mit
|
||||
190px, ein Symbol mit 72px -, dann ist das die Antwort, und der Browser laedt die kleine
|
||||
Variante statt der grossen. Ohne feste Breite bleibt es bei der Annahme nach Spaltenbreite. #}
|
||||
{% set festeBreite = block.style.desktop.maxWidth|default('') %}
|
||||
{% set eager = eagerImage|default(null) == block.id %}
|
||||
{# „auto“ heißt: der Browser misst selbst nach, wie breit das Bild tatsächlich erscheint, und
|
||||
wählt genau dazu die Variante. Das geht nur bei verzögert geladenen Bildern - beim
|
||||
vorgezogenen Bild steht die Größe noch nicht fest. Die Angaben dahinter sind der Rückfall
|
||||
für Browser, die „auto“ noch nicht kennen (heute Safari). #}
|
||||
{% set annahme = '(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px' %}
|
||||
{% set imageSizes = festeBreite ends with 'px' ? festeBreite : (eager or inHeader|default(false) ? annahme : 'auto, ' ~ annahme) %}
|
||||
{% set klick = props.click|default(props.href|default('') ? 'link' : 'keine') %}
|
||||
{% set grossesBild = props.sources|default([]) ? props.sources|last.src : props.src %}
|
||||
<figure id="{{ block.id }}" class="block-image{{ designClasses }}">
|
||||
{%- if klick == 'vergroessern' -%}
|
||||
{# Ohne Skript öffnet dieser Verweis schlicht das große Bild. Mit Skript fängt der
|
||||
Betrachter den Klick ab - die Seite bleibt also in jedem Fall benutzbar. #}
|
||||
<a class="image-link image-zoom" href="{{ grossesBild }}" data-zoom data-caption="{{ props.caption|default('') }}" aria-label="{{ props.alt|default('Bild') }} vergrößern">
|
||||
{%- elseif props.href|default('') -%}
|
||||
<a class="image-link" href="{{ props.href }}">
|
||||
{%- endif -%}<img src="{{ props.src }}" alt="{{ props.alt }}" width="{{ props.width }}" height="{{ props.height }}" loading="{{ eager or inHeader|default(false) ? 'eager' : 'lazy' }}"{{ eager ? ' fetchpriority="high"' : '' }} decoding="async"{% if props.sources|default([]) %} srcset="{% for source in props.sources %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="{{ imageSizes }}"{% endif %}>{% if klick == 'vergroessern' or props.href|default('') %}</a>{% endif %}{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure>
|
||||
{% elseif type == 'iconcard' %}
|
||||
<div id="{{ block.id }}" class="block-iconcard{{ designClasses }}">
|
||||
{% include 'blocks/icon.html.twig' with {name: props.icon} only %}
|
||||
@@ -45,7 +65,7 @@
|
||||
</figure>
|
||||
{% elseif type == 'logos' %}
|
||||
<ul id="{{ block.id }}" class="block-logos{{ designClasses }}">
|
||||
{% for child in block.children|default([]) %}<li>{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}</li>{% endfor %}
|
||||
{% for child in block.children|default([]) %}<li>{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}</li>{% endfor %}
|
||||
</ul>
|
||||
{% elseif type == 'cta' %}
|
||||
<div id="{{ block.id }}" class="block-cta{{ designClasses }}">
|
||||
@@ -88,7 +108,7 @@
|
||||
{% set section = sections[props.partial]|default(null) %}
|
||||
{% if section %}
|
||||
<div id="{{ block.id }}" class="block-partialref{{ designClasses }}">
|
||||
{% for child in section.blocks %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}{% endfor %}
|
||||
{% for child in section.blocks %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
|
||||
</div>
|
||||
{% elseif editing|default(false) %}
|
||||
<p id="{{ block.id }}" class="block-unavailable" role="note">Die verknüpfte Sektion „{{ props.partial }}“ gibt es nicht mehr. Der Verweis bleibt gespeichert.</p>
|
||||
@@ -117,22 +137,22 @@
|
||||
<ul class="tabs-nav">
|
||||
{% for child in block.children|default([]) %}<li><a href="#{{ child.id }}">{{ child.props.title }}</a></li>{% endfor %}
|
||||
</ul>
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}{% endfor %}
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
|
||||
</div>
|
||||
{% elseif type == 'tabitem' %}
|
||||
<section id="{{ block.id }}" class="block-tabitem{{ designClasses }}">
|
||||
<h3 class="tabs-heading">{{ props.title }}</h3>
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}{% endfor %}
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
|
||||
</section>
|
||||
{% elseif type == 'accordion' %}
|
||||
<div id="{{ block.id }}" class="block-accordion{{ designClasses }}">
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}{% endfor %}
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
|
||||
</div>
|
||||
{% elseif type == 'accordionitem' %}
|
||||
<details id="{{ block.id }}" class="block-accordionitem{{ designClasses }}"{% if props.open|default(false) %} open{% endif %}>
|
||||
<summary>{{ props.title }}</summary>
|
||||
<div class="accordion-body">
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}{% endfor %}
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% elseif type == 'icon' %}
|
||||
@@ -158,10 +178,10 @@
|
||||
a trackpad, a mouse wheel and the keyboard, and it degrades to a scrollable strip on a
|
||||
browser that ignores snapping. No autoplay - a moving carousel is something people ask to
|
||||
stop, not something a page should start on its own. #}
|
||||
<div id="{{ block.id }}" class="block-slider{{ designClasses }} slide-desktop-{{ props.desktopVisible }} slide-tablet-{{ props.tabletVisible }} slide-mobile-{{ props.mobileVisible }}{{ props.snap ? ' slider-snap' : '' }}"
|
||||
<div id="{{ block.id }}" class="block-slider{{ designClasses }} slide-desktop-{{ props.desktopVisible }} slide-tablet-{{ props.tabletVisible }} slide-mobile-{{ props.mobileVisible }}{{ props.snap ? ' slider-snap' : '' }}{{ props.dots ?? false ? ' slider-dots' : '' }}"{% if props.autoplay|default(0) >= 3 %} data-autoplay="{{ props.autoplay }}"{% endif %}
|
||||
tabindex="0" role="group" aria-label="Zum Blättern seitwärts scrollen">
|
||||
{% for child in block.children|default([]) %}
|
||||
<div class="slider-slide">{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}</div>
|
||||
<div class="slider-slide">{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elseif type == 'embed' %}
|
||||
@@ -195,11 +215,26 @@
|
||||
{% endif %}
|
||||
{% elseif type == 'postlist' %}
|
||||
<div id="{{ block.id }}" class="block-postlist{{ designClasses }}">
|
||||
{% include 'blocks/post-list.html.twig' with {posts: postLists[block.id]|default([])} only %}
|
||||
{% set feed = postLists[block.id]|default([]) %}
|
||||
{% include 'blocks/post-list.html.twig' with {editing: editing|default(false), posts: feed.posts|default(feed), pages: feed.pages|default(1), page: feed.page|default(1), total: feed.total|default(null), layout: block.props.layout|default('karten'), showMeta: block.props.showMeta ?? true, showExcerpt: block.props.showExcerpt ?? true, showTerms: block.props.showTerms ?? true} only %}
|
||||
</div>
|
||||
{% elseif type == 'postnav' %}
|
||||
<div id="{{ block.id }}" class="block-postnav{{ designClasses }}">
|
||||
{% include 'blocks/post-nav.html.twig' with {previous: neighbours.previous|default(null), next: neighbours.next|default(null), previousLabel: props.previousLabel|default('Vorheriger Beitrag'), nextLabel: props.nextLabel|default('Nächster Beitrag')} only %}
|
||||
</div>
|
||||
{% elseif type == 'legal' %}
|
||||
{# Impressum, Datenschutz und - sofern schon gefragt wurde - der Weg zurück zur Einwilligung.
|
||||
Als Baustein, damit die Zeile dort steht, wo sie hingehört, und die Farben ihres Bereichs
|
||||
erbt statt als heller Streifen unter einem dunklen Fuß zu kleben. #}
|
||||
{% set rechtliches = consentGate.legalLinks() %}
|
||||
{% set widerruflich = not editing|default(false) and consentGate.askedGroups is not empty and not consentGate.needsBanner(app.request) %}
|
||||
<nav id="{{ block.id }}" class="block-legal{{ designClasses }}" aria-label="{{ props.label|default('Rechtliches') }}">
|
||||
{% for path, label in rechtliches %}{% if not loop.first %}<span aria-hidden="true"> · </span>{% endif %}<a href="/{{ path }}">{{ label }}</a>{% endfor %}
|
||||
{% if widerruflich %}{% if rechtliches is not empty %}<span aria-hidden="true"> · </span>{% endif %}<a href="/einwilligung">Einwilligung ändern</a>{% endif %}
|
||||
</nav>
|
||||
{% elseif type == 'gallery' %}
|
||||
<div id="{{ block.id }}" class="block-gallery{{ designClasses }}" role="group" aria-label="Bildergalerie">
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), eagerImage: eagerImage|default(null)} only %}{% endfor %}
|
||||
<div id="{{ block.id }}" class="block-gallery{{ designClasses }}" role="group" aria-label="Bildergalerie"{% if props.lightbox|default(false) %} data-zoom-group{% endif %}>
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
{#
|
||||
|
||||
@@ -51,6 +51,13 @@
|
||||
{% if error %}<p class="field-error" id="{{ id }}-error">{{ error }}</p>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if turnstileKey|default('') %}
|
||||
{# Cloudflare Turnstile. Braucht JavaScript; ohne Skript bleibt das Formular unabsendbar, und
|
||||
genau deshalb ist die Prüfung eine Entscheidung und keine Voreinstellung. #}
|
||||
<div class="cf-turnstile" data-sitekey="{{ turnstileKey }}" data-language="de"></div>
|
||||
<noscript><p class="field-error">Zum Absenden dieses Formulars wird JavaScript benötigt. Schreib uns sonst gern direkt eine E-Mail.</p></noscript>
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
{% endif %}
|
||||
<button class="button" type="submit">Absenden</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -1,26 +1,36 @@
|
||||
{# Posts are supplied by the controller, never read from stored content. #}
|
||||
{% if posts is empty %}
|
||||
<p class="post-empty">Hier erscheinen die Beiträge, sobald welche veröffentlicht sind.</p>
|
||||
{# Auf der öffentlichen Seite bleibt die Stelle leer: eine Website ohne Blog soll nirgends
|
||||
von Beiträgen reden. Im Editor steht der Hinweis, sonst sucht jemand den Fehler. #}
|
||||
{% if editing|default(false) %}<p class="post-empty">Hier erscheinen die Beiträge, sobald welche veröffentlicht sind.</p>{% endif %}
|
||||
{% else %}
|
||||
<ul class="post-list">
|
||||
{% set art = layout|default('karten') %}
|
||||
<ul class="post-list post-list-{{ art }}">
|
||||
{% for post in posts %}
|
||||
<li class="post-card">
|
||||
{% if post.image %}
|
||||
{% if post.image and art != 'kompakt' %}
|
||||
<a class="post-image" href="{{ post.href }}" tabindex="-1" aria-hidden="true">
|
||||
<img src="{{ post.image.variants|last.src }}" alt="" width="{{ post.image.variants|last.width }}" height="{{ post.image.variants|last.height }}" loading="lazy" decoding="async"
|
||||
srcset="{% for source in post.image.variants %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="(max-width: 650px) 100vw, 380px">
|
||||
{# Der Hinweis liegt über dem Bild und erscheint beim Zeigen. Für Vorlesesoftware ist
|
||||
er nichts Neues - der Titel darunter ist derselbe Verweis -, deshalb ausgeblendet. #}
|
||||
<span class="post-view" aria-hidden="true">Ansehen</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="post-body">
|
||||
<h3><a href="{{ post.href }}">{{ post.title }}</a></h3>
|
||||
{% if post.publishedAt or post.author %}
|
||||
{% if (showMeta ?? true) and (post.publishedAt or post.author) %}
|
||||
<p class="post-meta">
|
||||
{% if post.publishedAt %}<time datetime="{{ post.publishedAt|date('Y-m-d') }}">{{ post.publishedAt|date('d.m.Y') }}</time>{% endif %}
|
||||
{% if post.author %}<span>von {{ post.author }}</span>{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if post.excerpt %}<p class="post-excerpt">{{ post.excerpt }}</p>{% endif %}
|
||||
{% if post.terms.category is not empty %}
|
||||
{% if (showExcerpt ?? true) and post.excerpt %}<p class="post-excerpt">{{ post.excerpt }}</p>{% endif %}
|
||||
{# Ein Element, das aussieht wie ein Knopf, muss eines sein. Vorher stand hier eine
|
||||
reine Beschriftung: sichtbar, aber nicht anklickbar. Der Titel steckt für
|
||||
Vorlesesoftware mit drin, damit "Weiterlesen" nicht zwölfmal gleich heißt. #}
|
||||
{% if art == 'wechsel' %}<a class="post-more" href="{{ post.href }}">Weiterlesen<span class="visually-hidden">: {{ post.title }}</span></a>{% endif %}
|
||||
{% if (showTerms ?? true) and post.terms.category is not empty %}
|
||||
<p class="post-terms">{% for term in post.terms.category %}<a href="{{ term.href }}">{{ term.name }}</a>{% endfor %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -28,3 +38,11 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if pages|default(1) > 1 %}
|
||||
{# Dieselbe Blätterleiste wie im Archiv: eine Liste, die mehr hat als sie zeigt, sagt es. #}
|
||||
<nav class="post-pager" aria-label="Weitere Beiträge">
|
||||
{% if page > 1 %}<a rel="prev" href="?seite={{ page - 1 }}">← Neuere</a>{% endif %}
|
||||
<span>Seite {{ page }} von {{ pages }}{% if total %} · {{ total }} Beiträge{% endif %}</span>
|
||||
{% if page < pages %}<a rel="next" href="?seite={{ page + 1 }}">Ältere →</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,18 @@
|
||||
{# Weiter zum nächsten Beitrag. Die Beiträge kommen vom Renderer, nie aus gespeichertem Inhalt.
|
||||
Fehlt ein Nachbar, bleibt seine Seite leer statt im Kreis zu führen. #}
|
||||
{% if previous or next %}
|
||||
<nav class="post-nav" aria-label="Weitere Beiträge">
|
||||
{% if previous %}
|
||||
<a class="post-nav-link post-nav-previous" href="{{ previous.href }}">
|
||||
<span class="post-nav-label"><span aria-hidden="true">←</span> {{ previousLabel|default('Vorheriger Beitrag') }}</span>
|
||||
<span class="post-nav-title">{{ previous.title }}</span>
|
||||
</a>
|
||||
{% else %}<span></span>{% endif %}
|
||||
{% if next %}
|
||||
<a class="post-nav-link post-nav-next" href="{{ next.href }}">
|
||||
<span class="post-nav-label">{{ nextLabel|default('Nächster Beitrag') }} <span aria-hidden="true">→</span></span>
|
||||
<span class="post-nav-title">{{ next.title }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Einwilligung{{ settings.legalName ? ' · ' ~ settings.legalName : '' }}</title>
|
||||
<meta name="robots" content="noindex, follow">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/assets/site.css">
|
||||
{% include 'favicon.html.twig' %}
|
||||
<link rel="stylesheet" href="/assets/site.css?v={{ kant_version() }}">
|
||||
</head>
|
||||
<body class="kant consent-page">
|
||||
<main id="main" tabindex="-1">
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{# Das Symbol der Website. Eigenes Bild aus den Einstellungen, sonst das mitgelieferte Zeichen.
|
||||
Die Kennung hängt als Version am Verweis, damit ein Wechsel im Browser auch ankommt. #}
|
||||
{% set favicon = siteSettings.load().faviconMediaId|default('') %}
|
||||
{% if favicon %}
|
||||
<link rel="icon" href="/favicon.png?v={{ favicon|slice(0, 8) }}" type="image/png" sizes="32x32">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v={{ favicon|slice(0, 8) }}">
|
||||
{% else %}
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
{% endif %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends 'install/base.html.twig' %}
|
||||
{% block heading %}Dein Zugang{% endblock %}
|
||||
{% block lead %}
|
||||
<p class="muted">Das erste Konto ist ein Administratorkonto: es darf alles, auch weitere Konten anlegen. Mit ihm endet die Installation – danach antwortet <code>/install</code> nicht mehr.</p>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<form method="post" action="{{ path('install_account') }}">
|
||||
<input type="hidden" name="issued" value="{{ issued }}">
|
||||
<input type="hidden" name="signature" value="{{ signature }}">
|
||||
|
||||
<label for="username">Benutzername</label>
|
||||
<input id="username" name="username" value="{{ username }}" required maxlength="100" autocomplete="username" autofocus>
|
||||
<p class="muted">3–100 Zeichen: Kleinbuchstaben, Ziffern, Punkt, Unterstrich, Bindestrich.</p>
|
||||
|
||||
<label for="password">Passwort</label>
|
||||
<input id="password" name="password" type="password" required minlength="14" maxlength="72" autocomplete="new-password">
|
||||
<label for="repeat">Passwort wiederholen</label>
|
||||
<input id="repeat" name="repeat" type="password" required minlength="14" maxlength="72" autocomplete="new-password">
|
||||
<p class="muted">Mindestens 14 Zeichen. Länge hilft mehr als Sonderzeichen – drei, vier zufällige Wörter sind ein gutes Passwort. Es wird nirgends angezeigt und lässt sich nicht wiederherstellen, nur ersetzen.</p>
|
||||
|
||||
<div class="sheet-actions">
|
||||
<button class="primary" type="submit">Konto anlegen und fertigstellen <span aria-hidden="true">→</span></button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends 'admin/base.html.twig' %}
|
||||
{% set steps = ['Systemprüfung', 'Datenbank', 'Migration', 'Website', 'Konto', 'Fertig'] %}
|
||||
{% block title %}{{ steps[step - 1] }} · Installation{% endblock %}
|
||||
{% block body_class %}install-body{% endblock %}
|
||||
{% block body %}
|
||||
<main id="admin-main" class="install-shell" tabindex="-1">
|
||||
<p class="eyebrow">KANTSITES INSTALLIEREN</p>
|
||||
<ol class="install-steps">
|
||||
{% for index, name in steps %}
|
||||
<li class="{{ index + 1 < step ? 'done' : (index + 1 == step ? 'current' : 'todo') }}">
|
||||
<span class="install-step-number" aria-hidden="true">{{ index + 1 < step ? '✓' : index + 1 }}</span>
|
||||
<span>{{ name }}{% if index + 1 == step %} <span class="visually-hidden">– aktueller Schritt</span>{% endif %}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
<div class="card">
|
||||
<div class="card-head"><h1>{% block heading %}{% endblock %}</h1></div>
|
||||
<div class="card-body">
|
||||
{% block lead %}{% endblock %}
|
||||
{% if error is defined and error %}<p class="notice error" role="alert">{{ error }}</p>{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user