Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86da83f754 | ||
|
|
8b6b53fe0f | ||
|
|
732b86897c | ||
|
|
cecb0771db | ||
|
|
b08862b139 | ||
|
|
5e46c98825 | ||
|
|
4c663d8087 | ||
|
|
a3fc768b5d | ||
|
|
e3672d0b82 | ||
|
|
7d0369bada | ||
|
|
dca2665138 |
No files matched your search
@@ -18,6 +18,8 @@
|
||||
# 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/
|
||||
# Das Abbild der Bildvarianten fuer den Webserver; es fuellt sich im Betrieb von selbst.
|
||||
/public/media/
|
||||
|
||||
# Zentrale (sites.kant-it.de): Konfiguration und Daten gehoeren dem Server.
|
||||
/zentrale/config.php
|
||||
|
||||
@@ -9,6 +9,17 @@
|
||||
* what public/.htaccess does. This script reproduces that behaviour for local development.
|
||||
*/
|
||||
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||
// Wie die Regel in public/.htaccess: Ein Bild der Mediathek, das im Abbild public/media/ liegt,
|
||||
// geht ohne die Anwendung raus (ADR 129). Ohne das maesse man hier weiter den Weg ueber PHP.
|
||||
if (1 === preg_match('#^/media/[a-f0-9]{32}/[0-9]{1,4}$#D', $path) && is_file(__DIR__.'/../public'.$path.'.webp')) {
|
||||
header('Content-Type: image/webp');
|
||||
header('Cache-Control: public, max-age=31536000, immutable');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Content-Length: '.filesize(__DIR__.'/../public'.$path.'.webp'));
|
||||
readfile(__DIR__.'/../public'.$path.'.webp');
|
||||
|
||||
exit;
|
||||
}
|
||||
$file = __DIR__.'/../public'.$path;
|
||||
if ('/' !== $path && !str_contains($path, '..') && is_file($file)) {
|
||||
// Dieselben Antwortkoepfe wie public/.htaccess sie auf einem Webserver setzt: Stylesheets,
|
||||
|
||||
@@ -10,6 +10,9 @@ parameters:
|
||||
# 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'
|
||||
# Das Abbild der Bildvarianten, das der Webserver ohne PHP ausliefert (App\Content\MediaMirror,
|
||||
# ADR 129). MEDIA_MIRROR_DIRECTORY stellt es um; in Tests ist es aus.
|
||||
kant.media_mirror_dir: '%kernel.project_dir%/public/media'
|
||||
kant.mail_log: '%kernel.project_dir%/var/log/mail.log'
|
||||
kant.mailer_dsn: 'sendmail://default'
|
||||
# Die Einstellung, die die Verwaltung anzeigt und ausprobiert; Tests setzen sie fest.
|
||||
@@ -69,6 +72,10 @@ services:
|
||||
App\Content\StyleBundle:
|
||||
arguments:
|
||||
$assetDirectory: '%env(default:kant.asset_dir:ASSET_DIRECTORY)%'
|
||||
App\Content\MediaMirror:
|
||||
arguments:
|
||||
$directory: '%env(default:kant.media_mirror_dir:MEDIA_MIRROR_DIRECTORY)%'
|
||||
$mediaDirectory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
# Versand und Protokoll (ADR 121). MAIL_LOG nur fuer Tests, damit sie nicht ins echte var/log
|
||||
# schreiben.
|
||||
App\Ops\MailCheck:
|
||||
@@ -209,5 +216,7 @@ services:
|
||||
# Frameworks nimmt. Hier und nicht in config/packages/test, weil diese Datei danach geladen wird.
|
||||
when@test:
|
||||
parameters:
|
||||
# Tests schreiben nicht nach public/. Wer das Abbild prüft, setzt MEDIA_MIRROR_DIRECTORY.
|
||||
kant.media_mirror_dir: ''
|
||||
kant.mail_log: '%kernel.cache_dir%/mail.log'
|
||||
kant.mail_dsn: 'smtp://cms.test:587'
|
||||
@@ -2,6 +2,22 @@
|
||||
|
||||
Stand: 10.11.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## ADR 130 – Editor: lokale Sicherung statt Autosave auf dem Server (10.11.2026)
|
||||
|
||||
**Kontext:** Der Entwicklungsplan nennt „Autosave mit sichtbarem Speicherstatus“. Gespeichert wurde nur per Knopf oder Strg+S; nach Absturz, geschlossenem Tab oder abgelaufener Sitzung war die Arbeit weg, und „Als JSON sichern“ hatte kein Gegenstück zum Einlesen.
|
||||
|
||||
**Entscheidung:** Ungespeicherte Änderungen liegen zusätzlich im `localStorage` des Browsers (Schlüssel `kant-editor:<API-Adresse>`, je Seite und Bereich), geschrieben 0,6 s nach der letzten Änderung und beim Verlassen. Beim Öffnen bietet der Editor eine abweichende Sicherung zum Wiederherstellen an – als ein Schritt im Verlauf – oder zum Verwerfen. Nach dem Speichern verschwindet sie. Kein Speichern auf dem Server im Takt.
|
||||
|
||||
**Folgen:** Jedes Speichern legt eine vollständige Fassung in `revision` an, und die wird nie gekürzt (ADR 009 ff.); ein Autosave alle paar Sekunden hätte die Fassungsliste entwertet und die Datenbank wachsen lassen. Das wäre erst mit zusammengefassten Entwurfsfassungen vertretbar – eine Schemaänderung, die hier nicht ansteht. Die Sicherung gilt nur für dieses Gerät und diesen Browser und ersetzt kein Speichern. Ist der Serverstand inzwischen neuer, sagt das Angebot das dazu; das Speichern danach überschreibt ihn nicht still, sondern legt eine neue Fassung über die fremde. Ist der Speicher des Browsers voll oder gesperrt, arbeitet der Editor ohne Sicherung weiter.
|
||||
|
||||
## ADR 129 – Bilder: Stufen 720 und 1280, Abbild für den Webserver (10.11.2026)
|
||||
|
||||
**Kontext:** Die Komplettanalyse maß auf kant-it.de rund 250 ms bis zum ersten Byte eines Bildes gegen rund 100 ms für eine statische Datei, weil jedes Bild durch die Anwendung lief (ADR 009). Zwischen den Stufen 480 und 960 lag außerdem genau der Bedarf eines Telefons (412 px × 1,75 = 721 px); Becker kam damit mobil auf einen LCP von 2,6 s.
|
||||
|
||||
**Entscheidung:** (1) `MediaLibrary::WIDTHS` ist `[480, 720, 960, 1280, 1600, 2400]`; eine Stufe knapp unter der eigenen Breite entfällt (`steps()`). Vorhandene Bilder bekommen die neuen Stufen über die Aufgabe „Bildgrößen ergänzen“ (`image-variants`, alle 15 Minuten, portionsweise) aus ihrer größten Variante – das Original wird nicht aufbewahrt. Bestehende Varianten und ihre Adressen bleiben. (2) Die Seite löst `props.sources` eines Bildes beim Ausliefern aus der Mediathek auf (`PageController::currentImageSources`), statt die im Dokument gespeicherte Liste zu drucken: Nachgereichte Stufen erreichen jede gespeicherte Seite ohne Dokumentmigration. (3) Hintergrundbilder nehmen auf dem Telefon die Stufe 720 statt 960; die Vorabladung löst das Bild je Gerät mit derselben Vererbung und Sichtbarkeit auf wie die Regel. (4) `MediaMirror` legt eine Variante beim ersten Abruf über PHP unter `public/media/<kennung>/<breite>.webp` ab (harter Verweis, sonst Kopie); eine Regel in `public/.htaccess` liefert sie danach ohne PHP aus. `var/media` bleibt die Quelle für Sicherung, Paket und Wiederherstellung; „Aufräumen“ und der Paketimport entfernen aus dem Abbild, was es dort nicht mehr gibt.
|
||||
|
||||
**Folgen:** Keine Migration, kein Schemawechsel, die Adressen der Bilder bleiben. Das hebt den Teil von ADR 009 auf, nach dem Bilder nur über die Anwendung ausgeliefert werden: Was im Abbild liegt, war auch vorher für jeden abrufbar, der die Adresse kennt (die Auslieferung prüft keine Anmeldung). Lässt sich `public/media` nicht schreiben oder greift die Regel bei einem Hoster nicht, bleibt alles beim Weg über PHP. Der eingebaute PHP-Server **ohne** Router antwortet mit 404, sobald zu einer Kennung ein Ordner im Abbild liegt; `bin/dev-router.php` bildet die Regel nach, und die Prüfskripte nutzen ein eigenes Abbild (`MEDIA_MIRROR_DIRECTORY`). Die WebP-Qualität bleibt 82. Nachgereichte Stufen sind aus einer WebP-Variante gerechnet, nicht aus dem Original.
|
||||
|
||||
## ADR 128 – Lesen ist nachsichtig, Schreiben streng; Kennung statt Versionsspalte (10.11.2026)
|
||||
|
||||
**Kontext:** Die Komplettanalyse vom 10.11.2026 fand drei Stellen, an denen ein gespeicherter Stand zur Falle wurde: Seiten wurden bei jedem Lesen voll geprüft, samt eigenem CSS – eine später verschärfte Regel ließ sie mit 500 antworten und im Editor nicht mehr öffnen; Einstellungen und Design überschrieb der zweite von zwei Admins still; Sicherungen galten als unversehrt, auch wenn Mediendateien fehlten.
|
||||
|
||||
+56
-1
@@ -1,5 +1,58 @@
|
||||
# Projektstatus
|
||||
|
||||
## Fassung 1.43.0: Oberfläche – lokale Sicherung im Editor, Einstellungen gegliedert, Archive barrierefrei (10.11.2026)
|
||||
|
||||
**Anlass:** Umut: „Ja, weiter“ – das vierte Paket aus der Komplettanalyse. ADR 130.
|
||||
|
||||
**Erledigt (keine Migration, kein Schemawechsel):**
|
||||
- E3: Der Editor sichert ungespeicherte Änderungen im Browser und bietet sie nach Absturz, geschlossenem Tab oder abgelaufener Sitzung zum Wiederherstellen an. Bewusst kein Autosave auf dem Server (ADR 130).
|
||||
- E6: Der Speicherstatus bleibt unter 1100 px Fensterbreite sichtbar (Kurzform „● Ungespeichert“, „Speichert …“, „● Konflikt“).
|
||||
- Einstellungen: Sprungleiste zu den acht Abschnitten, die beim Scrollen stehen bleibt; „Soziale Profile“ und „Öffnungszeiten“ zugeklappt mit Zusammenfassung, solange nichts eingetragen ist; die Speicherleiste steht am unteren Rand. Ein ungültiges Feld in einem zugeklappten Bereich klappt ihn beim Absenden auf.
|
||||
- Beitragsliste der Verwaltung: Kategorien, Schlagwörter und Bilder in je einer Abfrage statt je Beitrag.
|
||||
- B1: Im Archiv ist ein Beitragstitel eine Überschrift zweiter Ebene (in Seiten weiter dritter).
|
||||
- B2: Der Text im Archivkopf erbt die volle Textfarbe (vorher 4,48:1 auf dem getönten Grund).
|
||||
- B3: Der Sprunglink liegt über einem klebenden Kopf.
|
||||
- B4: Ein verlinktes Bild ohne Alternativtext gibt dem Link einen Namen.
|
||||
- `test:editor` startet wie alle anderen Prüfungen über `bin/dev-router.php`.
|
||||
|
||||
**Geprüft:** PHPUnit 701 grün (PHP 8.4, zwei Tests erweitert), `npm test` 41, php-cs-fixer, Twig-Lint, `test:editor` grün. Im Browser (Playwright, Pilotkopie): Titel ändern, 1 s warten – Sicherung liegt im Browser; neu laden – Angebot mit Uhrzeit; Wiederherstellen – Änderung im Baum, „Rückgängig“ aktiv; Speichern – auf dem Server und Sicherung weg; Verwerfen – Sicherung weg. Status bei 1000 px Breite sichtbar. Einstellungen: Sprung zu „Spamschutz“ landet unter der Leiste, Speicherleiste am unteren Rand sichtbar, 390 px ohne Querscrollen, ungültige Adresse klappt den Bereich auf. Aufnahmen `reports/lokale-sicherung-1.43.png`, `reports/einstellungen-1.43.png`. Nicht geprüft: PHP 8.2, `test:browser`, Safari, Lighthouse für `/beitraege` nach der Änderung (live erst nach dem Update messbar), die Abfragezahl der Beitragsliste als Zahl (im Produktionsmodus fehlt die Kopfzeile; Antwort lokal 119 ms).
|
||||
|
||||
**Bekannte Fehler / offen:** Aus der Analyse offen: E7 Ziehen im Strukturbaum, E9 Design-Panel verliert Suche und Fokus, E10 Trägheit bei großen Seiten, Symbole als SVG (rund 120 Stellen), Farb- und Maß-Tokens, Tabellen auf dem Telefon, B5–B7, P3/P4/P8/P9, F8.
|
||||
|
||||
**Release:** Tag `v1.43.0` auf `main`, Releaseeintrag mit Zip (3708 Dateien, 6,1 MB), `.sha256` und `install.php`, gebaut aus sauberem Checkout des Tags (ohne ImmoScout-Plugin); anonymer Download stimmt mit der Prüfsumme überein (5a7a83a1…), `releases/latest` meldet v1.43.0.
|
||||
|
||||
**Live nachgemessen (kant-it.de auf 1.43.0, 10.11.2026):** Lighthouse 13.4.1 (`audit.mjs`, je drei Läufe): `/`, `/projekte` und `/beitraege` mobil und Desktop überall 100 in allen fünf Bereichen – `/beitraege` vorher 95 bei der Barrierefreiheit (Überschriftenfolge und Kontrast im Archivkopf bestehen jetzt). Die Beitragsseite `/worga-baustellen-app-handwerk` bleibt bei 96: der Knopf „Zu worga.net“ hat weiße Schrift auf der Akzentfarbe #e63946 bei 14 px, Kontrast 4,16:1 – Inhalt und Design der Seite, kein Kernfehler. Im Archiv zehn Beitragstitel als `h2`, keiner als `h3`. „Bildgrößen ergänzen“ ist bei 4 von 18 Bildern der Startseite.
|
||||
|
||||
**Nächster Schritt:** Die übrigen Installationen in der Zentrale auf 1.43.0 bringen. Für kant-it.de entscheiden, ob der rote Knopf auf den Beitragsseiten eine dunklere Fläche (#c1121f, die Linkfarbe der Website, 5,9:1) oder größere fette Schrift bekommt. Früher notiert: live `/beitraege` und eine Beitragsseite mit Lighthouse nachmessen (Barrierefreiheit vorher 95 und 96).
|
||||
|
||||
## Fassung 1.42.0: Geschwindigkeit – kleinere Bilder fürs Telefon, Bilder ohne PHP (10.11.2026)
|
||||
|
||||
**Anlass:** Umut: „Ja, 1.42 angehen“ – das dritte Paket aus der Komplettanalyse. ADR 129.
|
||||
|
||||
**Erledigt (keine Migration, kein Schemawechsel):**
|
||||
- P2: Bildstufen 720 und 1280. Neue Uploads sofort; vorhandene Bilder über die neue Aufgabe „Bildgrößen ergänzen“ (`image-variants`). Gespeicherte Seiten nennen die neuen Größen von selbst. Telefon-Hintergründe nehmen 720 statt 960.
|
||||
- P1: Abbild `public/media/` (`MediaMirror`) und Regel in `public/.htaccess`: nach dem ersten Abruf liefert der Webserver ein Bild ohne PHP.
|
||||
- P7: Die Vorabladung des Hero-Hintergrunds richtet sich je Gerät nach dem Bild, das dort wirklich gezeigt wird (eigenes Bild, „keins“, ausgeblendet).
|
||||
- P6: Die Beitragsnavigation liest erst die Reihenfolge und dann nur die zwei Nachbarn, statt bis zu 500 Beiträge mit Dokument zu laden; die Grenze von 500 entfällt.
|
||||
|
||||
**Geprüft:** PHPUnit 701 grün (PHP 8.4, zwei neue Tests, einer erweitert), `npm test` 41, php-cs-fixer, Twig-Lint, `test:editor` grün. Die Regel in `.htaccess` gegen einen echten Apache 2.4 (XAMPP, eigene Instanz auf Port 8199): Variante im Abbild kommt statisch mit `image/webp`, `immutable` und `nosniff`; eine fehlende Variante, eine andere Kennung, Seiten und Videos gehen weiter an `index.php`. Lighthouse 13.4.1 mobil mit angewandter Drosselung (`throttlingMethod: devtools`), je fünf Läufe, Becker-Daten, 1.41.0 gegen den neuen Stand:
|
||||
|
||||
| Seite | LCP 1.41.0 | LCP neu | Leistung | Bilder |
|
||||
|---|---|---|---|---|
|
||||
| `/` | 2088 ms | 1932 ms | 98 → 99 | 488 → 302 KB |
|
||||
| `/gartenbau` | 3790 ms | 2283 ms | 88 → 97 | 685 → 408 KB |
|
||||
| `/kontakt` | 2855 ms | 2284 ms | 95 → 97 | 211 → 147 KB |
|
||||
|
||||
Das ist ein lokaler Vergleich mit dem PHP-Entwicklungsserver; der Gewinn kommt hier fast ganz aus den kleineren Bildern. Was das Ausliefern ohne PHP bringt, zeigt sich erst auf dem Hosting (lokal 1 ms statt 70 ms je Bild). Der gewohnte Lauf mit simulierter Drosselung (`audit.mjs`) schwankt bei so schnellen Bildern zwischen 2,1 und 2,9 s für dieselbe Seite und taugt für diesen Vergleich nicht. Nicht geprüft: ein Hosting (IONOS, nginx vor Apache), PHP 8.2, `test:browser`, Safari, die Aufgabe im echten Takt, Felddaten.
|
||||
|
||||
**Bekannte Fehler / offen:** PHPs eingebauter Server ohne Router antwortet mit 404 auf ein Bild, zu dessen Kennung ein Ordner im Abbild liegt (lokal immer mit `bin/dev-router.php` starten). Aus der Analyse offen: P3 OPcache-Anzeige, P4 `no-store`, P5-Rest (Brotkrumen), P8 Layoutsprünge im Schrittformular und beim Video, P9 Stylesheet teilen; dazu Oberfläche und Barrierefreiheit der Archive.
|
||||
|
||||
**Release:** Tag `v1.42.0` auf `main`, Releaseeintrag mit Zip (3708 Dateien, 6,1 MB), `.sha256` und `install.php`, gebaut aus sauberem Checkout des Tags (ohne ImmoScout-Plugin); anonymer Download stimmt mit der Prüfsumme überein (8fa187f9…), `releases/latest` meldet v1.42.0. Die Git-Anmeldung war wieder abgelaufen, Umut hat selbst gepusht.
|
||||
|
||||
**Live nachgemessen (kant-it.de, 10.11.2026, nach Umuts Update von 1.37.0 auf 1.42.0):** Die Regel greift beim Hoster. Erster Abruf eines Bildes über PHP (268 ms, mit CSP-Kopf der Anwendung), ab dem zweiten vom Webserver (ETag von Apache, 111–132 ms). Alle 59 Bildadressen der Startseite kommen danach statisch, im Mittel nach 112 ms statt vorher rund 250 ms – so schnell wie eine Skriptdatei. Eine unbekannte Breite antwortet weiter mit 404. „Bildgrößen ergänzen“ läuft: zum Messzeitpunkt trugen 2 von 18 Bildern der Startseite schon die Stufe 720. Lighthouse 13.4.1 (`audit.mjs`, simuliert, je drei Läufe) gegen die Messung vom Vormittag auf 1.37.0: LCP Desktop 642 → 425 ms (`/`), 534 → 398 ms (`/projekte`), 612 → 420 ms (Beitrag), 585 → 436 ms (`/beitraege`); mobil 1668 → 1690 ms (`/`), 1488 → 1499, 1711 → 1579, 1779 → 1697 ms. Übertragene Daten der Startseite mobil 1020 → 444 KB. Leistung überall 100 (Startseite Desktop vorher 99). Barrierefreiheit unverändert 95 auf `/beitraege` und 96 auf dem Beitrag (B1, B2 offen). Der Vergleich liegt Stunden und fünf Fassungen auseinander; mobil ist der LCP im Rahmen der Schwankung gleich.
|
||||
|
||||
**Nächster Schritt:** Die übrigen Installationen in der Zentrale auf 1.42.0 bringen. Dort jeweils prüfen: ein Bild zweimal abrufen, der zweite Abruf muss ohne `Set-Cookie`/PHP-Kopfzeilen und spürbar schneller kommen; unter Aufgaben läuft „Bildgrößen ergänzen“.
|
||||
|
||||
## Fassung 1.41.0: Stabilität – Altbestand lesbar, Konflikte, Sicherungen, Rückfragen (10.11.2026)
|
||||
|
||||
**Anlass:** Umut nach 1.40.1: „ja weiter bitte“ – das zweite Paket aus der Komplettanalyse. ADR 128.
|
||||
@@ -19,7 +72,9 @@
|
||||
|
||||
**Bekannte Fehler / offen:** F8 (MySQL-Rückweg lässt neue Tabellen stehen) nicht umgesetzt, da keine MySQL-Installation existiert. `test:accordion` weiter veraltet. Aus der Analyse offen: Geschwindigkeit (Bilder statisch, Bildstufen), Autosave, Einstellungen aufteilen, Symbole als SVG, Barrierefreiheit der Archive.
|
||||
|
||||
**Nächster Schritt:** Zweig `stabil-1.41` nach `main`, Tag `v1.41.0`, Releaseeintrag – nach Umuts Freigabe.
|
||||
**Release:** Tag `v1.41.0` auf `main`, Releaseeintrag mit Zip (3706 Dateien, 6,1 MB), `.sha256` und `install.php`, gebaut aus sauberem Checkout des Tags (ohne ImmoScout-Plugin); anonymer Download stimmt mit der Prüfsumme überein (d5ff3d7e…), `releases/latest` meldet v1.41.0.
|
||||
|
||||
**Nächster Schritt:** In der Zentrale „Alle aktualisieren“ auf 1.41.0 (kant-it.de steht noch auf 1.37.0). Danach laut Bericht 1.42 (Geschwindigkeit: Bilder statisch ausliefern, Bildstufen 720/1280 mit Migrationsweg, Beitragsnavigation, Hero-Preload je Gerät).
|
||||
|
||||
## Fassung 1.40.1: Symfony-Sicherheitsupdate und acht Korrekturen aus der Analyse (10.11.2026)
|
||||
|
||||
|
||||
@@ -24,6 +24,16 @@ RewriteBase /
|
||||
#RewriteCond %{HTTP:X-Forwarded-Proto} !=https
|
||||
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
|
||||
|
||||
# Bilder der Mediathek ohne PHP ausliefern (ADR 129).
|
||||
#
|
||||
# Ein Bild heisst /media/<kennung>/<breite>. Liegt die Variante im Abbild public/media/ - dort legt
|
||||
# die Anwendung sie beim ersten Abruf ab -, beantwortet der Webserver die Anfrage selbst: kein
|
||||
# PHP-Prozess, keine Datenbank. Fehlt sie, greift die Regel nicht, und alles laeuft wie bisher
|
||||
# ueber index.php. Die Pruefung haengt an REQUEST_FILENAME wie die Regel darunter; nach dem
|
||||
# Umschreiben endet die Adresse auf .webp und passt nicht noch einmal.
|
||||
RewriteCond %{REQUEST_FILENAME}.webp -f
|
||||
RewriteRule ^media/[a-f0-9]{32}/[0-9]{1,4}$ %{REQUEST_URI}.webp [L]
|
||||
|
||||
RewriteRule ^index\.php$ - [L]
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
@@ -49,6 +59,10 @@ RewriteRule ^ index.php [L]
|
||||
# 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.
|
||||
# Aeltere Server kennen die Endung nicht und lieferten die Bilder des Abbilds als Text aus.
|
||||
<IfModule mod_mime.c>
|
||||
AddType image/webp .webp
|
||||
</IfModule>
|
||||
<IfModule mod_expires.c>
|
||||
ExpiresActive On
|
||||
ExpiresByType image/webp "access plus 1 year"
|
||||
@@ -68,6 +82,10 @@ RewriteRule ^ index.php [L]
|
||||
<FilesMatch "\.woff2$">
|
||||
Header set Access-Control-Allow-Origin "*"
|
||||
</FilesMatch>
|
||||
# Dieselbe Zeile, die die Anwendung an ein Bild haengt, wenn sie es selbst ausliefert.
|
||||
<FilesMatch "\.webp$">
|
||||
Header set X-Content-Type-Options "nosniff"
|
||||
</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
|
||||
|
||||
@@ -1165,3 +1165,19 @@ details.form-card > summary.form-card-summary::before, details.form-card[open] >
|
||||
.confirm-dialog .dialog-actions .primary, .confirm-dialog .dialog-actions .secondary { margin: 0 }
|
||||
.primary.danger { background: var(--danger); border-color: var(--danger) }
|
||||
.primary.danger:hover:not(:disabled) { background: #6f251b; border-color: #6f251b }
|
||||
/* ---- Lange Formulare: Sprungleiste, Klappbereiche, stehende Speicherleiste (Einstellungen) ---- */
|
||||
.jump-nav { position: sticky; top: 0; z-index: 6; display: flex; flex-wrap: wrap; gap: 6px; margin: 0 -4px 6px; padding: 10px 4px; background: color-mix(in srgb, var(--bg, #f3f5f1) 92%, transparent); backdrop-filter: blur(6px) }
|
||||
.jump-nav a { display: inline-flex; align-items: center; min-height: 32px; padding: 4px 12px; border: 1px solid var(--line); border-radius: 999px; background: var(--paper); color: var(--ink); font-size: 12.5px; font-weight: 600; text-decoration: none }
|
||||
.jump-nav a:hover { border-color: var(--accent) }
|
||||
.stack-section[id], .section-head h2[id] { scroll-margin-top: 64px }
|
||||
.field-fold > summary { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 12px 0 8px; font-weight: 700; cursor: pointer; list-style: none }
|
||||
.field-fold > summary::-webkit-details-marker { display: none }
|
||||
.field-fold > summary::before { content: ''; flex: none; width: 7px; height: 7px; margin-right: 2px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: translateY(-2px) rotate(-45deg) }
|
||||
.field-fold[open] > summary::before { transform: translateY(-3px) rotate(45deg) }
|
||||
.field-fold > summary .muted { margin-left: auto; font-size: 12px; font-weight: 500 }
|
||||
.field-fold:not([open]) { padding-bottom: 4px }
|
||||
.form-save-bar { position: sticky; bottom: 0; z-index: 5; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin: 22px -22px -24px; padding: 14px 22px; border-top: 1px solid var(--line); border-radius: 0 0 12px 12px; background: color-mix(in srgb, var(--paper) 94%, transparent); backdrop-filter: blur(6px) }
|
||||
.card-body .form-save-bar .primary { margin: 0 }
|
||||
.form-save-bar .muted { font-size: 12px }
|
||||
@media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth } }
|
||||
@media (max-width: 700px) { .jump-nav { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none } .jump-nav a { flex: none } .form-save-bar .muted { display: none } }
|
||||
@@ -48,6 +48,10 @@
|
||||
};
|
||||
window.kantConfirm = ask;
|
||||
|
||||
// Ein ungültiges Feld in einem zugeklappten Bereich kann der Browser nicht anspringen - das
|
||||
// Absenden bliebe ohne jede Meldung stehen. Der Bereich klappt deshalb auf.
|
||||
document.addEventListener('invalid', event => { event.target.closest?.('details:not([open])')?.setAttribute('open', ''); }, true);
|
||||
|
||||
document.addEventListener('submit', event => {
|
||||
const form = event.target;
|
||||
if (!(form instanceof HTMLFormElement) || !form.dataset.confirmTitle || form.dataset.confirmed) return;
|
||||
|
||||
@@ -38,6 +38,7 @@ html:has(>.editor-body){overflow:hidden;height:100%}
|
||||
.editor-header .secondary{background:transparent;color:#fff;border:1px solid #ffffff4d;font-size:12px;padding:0 13px;min-height:34px;white-space:nowrap}
|
||||
.editor-header .secondary:hover:not(:disabled){background:#ffffff1a;border-color:#ffffff99}
|
||||
.editor-header .primary{font-size:12px;padding:0 15px;min-height:34px;white-space:nowrap}
|
||||
.editor-message.editor-backup{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px 16px;background:#eef2ea;color:#1b2a24;border-bottom-color:#cfd8cc}.editor-backup-actions{display:flex;gap:8px;align-items:center}.editor-backup .secondary,.editor-backup .text-button{margin:0;min-height:30px;padding:4px 12px;font-size:12px}
|
||||
.editor-message{border-radius:0;background:#fff0e9;color:#873e24;border-bottom:1px solid #e6c3b2;flex-shrink:0;padding:9px 16px;font-size:12px;line-height:1.6}
|
||||
.recovery-bar{display:flex;gap:12px;align-items:center;padding:10px 16px;border-bottom:1px solid var(--line);background:#fff;font-size:12px;flex-wrap:wrap}
|
||||
.dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:25px}
|
||||
@@ -277,7 +278,7 @@ html:has(>.editor-body){overflow:hidden;height:100%}
|
||||
@media(prefers-reduced-motion:reduce){.ov-insert{transition:none;opacity:1}}
|
||||
|
||||
/* ---- narrow screens ---------------------------------------------------------------------- */
|
||||
@media(max-width:1100px){.editor-layout{grid-template-columns:290px minmax(0,1fr)}.editor-page-name{width:150px}.save-actions>span{display:none}.viewport-controls button span{display:none}.viewport-controls button{padding:0 9px}}
|
||||
@media(max-width:1100px){.editor-layout{grid-template-columns:290px minmax(0,1fr)}.editor-page-name{width:150px}.save-actions>span{font-size:0;max-width:none;white-space:nowrap}.save-actions>span::before{font-size:11px;content:attr(data-short)}.save-actions>span[data-state=dirty]::before{content:'● Ungespeichert';color:#ffcf8a}.save-actions>span[data-state=saving]::before{content:'Speichert …'}.save-actions>span[data-state=conflict]::before{content:'● Konflikt';color:#ffb0a0}.save-actions>span[data-state=saved]::before{content:'Gespeichert'}.save-actions>span[data-state=loading]::before{content:'Lädt …'}.viewport-controls button span{display:none}.viewport-controls button{padding:0 9px}}
|
||||
@media(max-width:850px){
|
||||
.editor-body{overflow:auto}#editor{height:auto;min-height:100dvh}
|
||||
.editor-header{flex-wrap:wrap;height:auto;padding:8px 10px;gap:8px}.editor-page-name{flex:1;width:auto}.viewport-controls{order:6;margin:0}.save-actions{margin-left:auto}
|
||||
|
||||
@@ -88,6 +88,51 @@ function playPreview() {
|
||||
|
||||
$('#preview-motion').addEventListener('click', () => { previewPlayback = !previewPlayback; playPreview(); });
|
||||
|
||||
// ---- Lokale Sicherung --------------------------------------------------------------------------
|
||||
// Ungespeicherte Änderungen liegen zusätzlich im Browser dieses Geräts. Stürzt der Browser ab,
|
||||
// wird der Tab geschlossen oder läuft die Sitzung ab, bietet der Editor sie beim nächsten Öffnen
|
||||
// zum Wiederherstellen an. Bewusst kein Speichern auf dem Server im Takt: Jedes Speichern legt
|
||||
// dort eine vollständige Fassung an, und die Liste der Fassungen soll zeigen, was jemand
|
||||
// gespeichert hat - nicht jeden Tastendruck.
|
||||
const backupKey = `kant-editor:${root.dataset.api}`;
|
||||
let backupTimer = null, backupOffer = null;
|
||||
function readBackup() {
|
||||
try {
|
||||
const entry = JSON.parse(localStorage.getItem(backupKey) || 'null');
|
||||
return entry && Array.isArray(entry.document?.blocks) ? entry : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
function writeBackup() {
|
||||
// Solange ein Angebot offen ist, bleibt die Sicherung unangetastet: Der eben geladene Stand
|
||||
// ist unverändert und würde sie sonst löschen, bevor jemand entschieden hat.
|
||||
if (!history || backupOffer) return;
|
||||
try {
|
||||
if (history.dirty) localStorage.setItem(backupKey, JSON.stringify({ at: Date.now(), revisionId, document: history.document }));
|
||||
else localStorage.removeItem(backupKey);
|
||||
} catch { /* Speicher voll oder gesperrt: dann ohne Sicherung, der Editor arbeitet weiter. */ }
|
||||
}
|
||||
function scheduleBackup() { clearTimeout(backupTimer); backupTimer = setTimeout(writeBackup, 600); }
|
||||
function closeBackupOffer(remove) {
|
||||
backupOffer = null; $('#local-backup').hidden = true;
|
||||
if (remove) { try { localStorage.removeItem(backupKey); } catch { /* nichts zu tun */ } }
|
||||
}
|
||||
function offerBackup() {
|
||||
const entry = readBackup();
|
||||
if (!entry || JSON.stringify(entry.document) === JSON.stringify(history.document)) { closeBackupOffer(Boolean(entry)); return; }
|
||||
backupOffer = entry;
|
||||
const when = new Date(entry.at).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
$('#local-backup-text').textContent = `Auf diesem Gerät liegen ungespeicherte Änderungen vom ${when} Uhr.`
|
||||
+ (entry.revisionId && entry.revisionId !== revisionId ? ' Seitdem wurde die Seite auf dem Server gespeichert – beim Wiederherstellen ersetzt dein Stand den neueren, sobald du speicherst.' : '');
|
||||
$('#local-backup').hidden = false;
|
||||
}
|
||||
function restoreBackup() {
|
||||
const entry = backupOffer; if (!entry || !history || busy) return;
|
||||
const wanted = repairSchema(clone(entry.document));
|
||||
// Als ein Schritt im Verlauf: „Rückgängig“ führt zum geladenen Stand zurück.
|
||||
history.change(doc => { for (const key of Object.keys(doc)) delete doc[key]; Object.assign(doc, wanted); });
|
||||
closeBackupOffer(false); selected = null; changed();
|
||||
}
|
||||
|
||||
function notify(message = '', recover = false) {
|
||||
$('#message').textContent = message;
|
||||
$('#message').hidden = !message;
|
||||
@@ -118,6 +163,10 @@ async function request(path, body, signal) {
|
||||
function controls() {
|
||||
const ready = Boolean(history);
|
||||
$('#save-status').textContent = busy ? 'Wird gespeichert …' : conflict ? 'Speicherkonflikt' : !ready ? 'Wird geladen …' : history.dirty ? 'Ungespeicherte Änderungen' : editingArea ? 'Bereich gespeichert' : revisionId === publishedRevisionId ? 'Veröffentlicht' : 'Entwurf gespeichert';
|
||||
// Der Zustand als Kennung: Auf schmalen Fenstern zeigt die Kopfzeile statt des ganzen Satzes
|
||||
// nur ein kurzes Wort dazu (editor.css) - vorher verschwand die Anzeige dort ganz.
|
||||
$('#save-status').dataset.state = busy ? 'saving' : conflict ? 'conflict' : !ready ? 'loading' : history.dirty ? 'dirty' : 'saved';
|
||||
if (ready && !busy) scheduleBackup();
|
||||
$('#save').disabled = !ready || busy || conflict || !history.dirty;
|
||||
if ($('#publish')) $('#publish').disabled = !ready || busy || conflict || history.dirty || revisionId === publishedRevisionId;
|
||||
$('#undo').disabled = !ready || busy || !history.past.length;
|
||||
@@ -1451,6 +1500,7 @@ async function load() {
|
||||
registerPlugins(data.plugins || [], data.presets || []); buildLibrary(); buildPluginPresets();
|
||||
selected = null; propertyPanel = 'content'; collapsed.clear(); conflict = false; notify();
|
||||
if (language) setView('translate');
|
||||
offerBackup();
|
||||
changed(); resizePreview();
|
||||
} catch (error) { notify(error.message, true); }
|
||||
finally { busy = false; controls(); }
|
||||
@@ -1952,6 +2002,10 @@ function onKey(event) {
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
$('#local-backup-restore').addEventListener('click', restoreBackup);
|
||||
$('#local-backup-discard').addEventListener('click', () => closeBackupOffer(true));
|
||||
// Beim Verlassen sofort schreiben, nicht erst nach der Wartezeit.
|
||||
window.addEventListener('pagehide', () => { clearTimeout(backupTimer); writeBackup(); });
|
||||
window.addEventListener('beforeunload', event => { if (history?.dirty || busy) { event.preventDefault(); event.returnValue = ''; } });
|
||||
buildLibrary();
|
||||
// The workspace is a fixed frame; a scroll position restored from an earlier visit would push
|
||||
|
||||
@@ -12,7 +12,7 @@ p{hyphens:auto}
|
||||
/* Über dem Inhalt: der Kopf nimmt keinen Platz ein, die erste Sektion beginnt unter ihm. Farbe
|
||||
und Schatten kommen aus der Gestaltung des Kopfbereichs, auch die für den gescrollten Zustand. */
|
||||
.site-header.is-overlay{position:absolute;top:0;left:0;right:0;z-index:40;border-bottom:0;background:transparent;box-shadow:none}
|
||||
.site-header.is-overlay.is-sticky{position:fixed}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:none;font-weight:800}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500}h2{font-size:clamp(32px,4vw,49px);font-weight:500}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:var(--paper);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}
|
||||
.site-header.is-overlay.is-sticky{position:fixed}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:none;font-weight:800}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500}h2{font-size:clamp(32px,4vw,49px);font-weight:500}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}#archiv-kopf .block-text{color:inherit}.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:60;background:var(--ink);color:var(--paper);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:767px){.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. */
|
||||
@@ -27,15 +27,15 @@ p{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}.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-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 :is(h2,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:'';display:inline-block;width:1em;height:1em;background:currentColor;-webkit-mask:var(--kant-svg-pfeil) center/contain no-repeat;mask:var(--kant-svg-pfeil) center/contain no-repeat;vertical-align:-.12em;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:767px){.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-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:767px){.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 :is(h2,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}
|
||||
.kant .post-card :is(h2,h3){margin:0;font-size:19px;font-weight:800;line-height:1.12;color:var(--ink);letter-spacing:-.2px}
|
||||
.post-card :is(h2,h3) a{text-decoration:none}
|
||||
.post-card :is(h2,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}
|
||||
|
||||
@@ -8,7 +8,7 @@ import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -8,12 +8,12 @@ import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8' }));
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env: { ...process.env, DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory } });
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env: { ...process.env, DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' } });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], {
|
||||
windowsHide: true, stdio: 'ignore', env: { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, SITE_ORIGIN: origin },
|
||||
windowsHide: true, stdio: 'ignore', env: { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild', SITE_ORIGIN: origin },
|
||||
});
|
||||
await mkdir('reports/admin-quality', { recursive: true });
|
||||
let browser, page;
|
||||
|
||||
@@ -7,7 +7,7 @@ import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -11,7 +11,7 @@ import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -8,7 +8,7 @@ import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -14,9 +14,9 @@ await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port;
|
||||
await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'upload_max_filesize=8M', '-d', 'post_max_size=10M', '-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public'], {
|
||||
const server = spawn(php, ['-d', 'upload_max_filesize=8M', '-d', 'post_max_size=10M', '-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], {
|
||||
windowsHide: true,
|
||||
env: { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, SITE_ORIGIN: origin },
|
||||
env: { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild', SITE_ORIGIN: origin },
|
||||
stdio: 'ignore',
|
||||
});
|
||||
server.on('error', error => { throw error; });
|
||||
|
||||
@@ -8,7 +8,7 @@ import { mediaProps, upgradeDocument } from '../public/assets/editor-model.js';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -9,7 +9,7 @@ import { mediaProps } from '../public/assets/editor-model.js';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -9,7 +9,7 @@ import { mediaProps } from '../public/assets/editor-model.js';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -8,7 +8,7 @@ import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const environment = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const environment = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env: environment });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -12,7 +12,7 @@ import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -9,7 +9,7 @@ const php = process.env.PHP_BINARY || 'php';
|
||||
const hash = async () => createHash('sha256').update(await readFile('var/kantit.db')).digest('hex');
|
||||
const before = await hash();
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { env, windowsHide: true, stdio: 'ignore' });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -7,7 +7,7 @@ import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -8,7 +8,7 @@ import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -8,7 +8,7 @@ import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -11,8 +11,34 @@ 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];
|
||||
/**
|
||||
* The widths every upload is rendered to, in WebP. A source narrower than a step is skipped.
|
||||
*
|
||||
* 720 und 1280 kamen mit 1.42.0 dazu: Zwischen 480 und 960 lag genau der Bereich, den ein
|
||||
* Telefon braucht (412 px Breite mal 1,75 sind 721 px) - es lud deshalb die 960er Fassung mit
|
||||
* fast doppelt so vielen Bildpunkten. Vorhandene Bilder bekommen die neuen Stufen über
|
||||
* fillVariants() nachgereicht.
|
||||
*/
|
||||
public const WIDTHS = [480, 720, 960, 1280, 1600, 2400];
|
||||
|
||||
/** WebP-Qualität aller Varianten. */
|
||||
private const QUALITY = 82;
|
||||
|
||||
/**
|
||||
* Die Breiten, in denen ein Bild dieser Breite vorliegt: jede Stufe darunter und es selbst.
|
||||
*
|
||||
* Eine Stufe knapp unter der eigenen Breite entfällt - neben einem 760 px breiten Bild ist
|
||||
* eine Fassung mit 720 px eine zweite Datei ohne Nutzen.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function steps(int $width): array
|
||||
{
|
||||
$steps = array_filter(self::WIDTHS, static fn (int $step): bool => $step <= $width * 0.9);
|
||||
$steps[] = min($width, max(self::WIDTHS));
|
||||
|
||||
return array_values(array_unique($steps));
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Bilder, die in dieser Anfrage schon gesucht wurden.
|
||||
@@ -104,7 +130,8 @@ final class MediaLibrary
|
||||
'SELECT * FROM media WHERE id IN ('.implode(',', array_fill(0, count($ids), '?')).')',
|
||||
$ids,
|
||||
) as $row) {
|
||||
$found[$row['id']] = $this->present($row);
|
||||
// Auch für find(): Wer die Bilder einer Liste vorab holt, erspart jede Einzelabfrage danach.
|
||||
$found[$row['id']] = $this->gemerkt[$row['id']] = $this->present($row);
|
||||
}
|
||||
|
||||
return $found;
|
||||
@@ -165,7 +192,7 @@ final class MediaLibrary
|
||||
// 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) {
|
||||
foreach (self::steps($width) as $targetWidth) {
|
||||
$targetHeight = max(1, (int) round($height * $targetWidth / $width));
|
||||
$target = imagecreatetruecolor($targetWidth, $targetHeight);
|
||||
try {
|
||||
@@ -174,7 +201,7 @@ final class MediaLibrary
|
||||
imagecopyresampled($target, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height);
|
||||
$path = $this->directory.'/'.$id.'-'.$targetWidth.'.webp';
|
||||
$paths[] = $path;
|
||||
if (!imagewebp($target, $path, 82) || !is_file($path) || !filesize($path)) {
|
||||
if (!imagewebp($target, $path, self::QUALITY) || !is_file($path) || !filesize($path)) {
|
||||
throw new \RuntimeException('Bildvariante konnte nicht gespeichert werden.');
|
||||
}
|
||||
$variants[] = ['src' => '/media/'.$id.'/'.$targetWidth, 'width' => $targetWidth, 'height' => $targetHeight, 'bytes' => filesize($path)];
|
||||
@@ -201,6 +228,84 @@ final class MediaLibrary
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reicht vorhandenen Bildern die Stufen nach, die es bei ihrem Upload noch nicht gab.
|
||||
*
|
||||
* Das Original wird nicht aufbewahrt; gerechnet wird aus der größten vorhandenen Variante.
|
||||
* Bestehende Varianten bleiben unangetastet, ihre Adressen stehen in gespeicherten Seiten.
|
||||
* Die Schreibversion des Bildes ändert sich nicht - ein offenes Formular mit Titel und
|
||||
* Alternativtext soll daran nicht scheitern.
|
||||
*
|
||||
* Arbeitet portionsweise, weil ein Lauf an einem PHP-Prozess mit fremdem Zeitlimit hängt.
|
||||
*
|
||||
* @return array{done: int, left: int} ergänzte Bilder und solche, die noch warten
|
||||
*/
|
||||
public function fillVariants(int $limit = 8, float $seconds = 8.0): array
|
||||
{
|
||||
$start = microtime(true);
|
||||
$done = 0;
|
||||
$left = 0;
|
||||
foreach ($this->db->fetchAllAssociative('SELECT id, variants FROM media ORDER BY created_at DESC, id DESC') as $row) {
|
||||
$variants = json_decode($row['variants'], true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($variants) || [] === $variants) {
|
||||
continue;
|
||||
}
|
||||
usort($variants, static fn (array $a, array $b): int => $a['width'] <=> $b['width']);
|
||||
$largest = end($variants);
|
||||
$missing = array_diff(self::steps((int) $largest['width']), array_column($variants, 'width'));
|
||||
$file = $this->directory.'/'.$row['id'].'-'.$largest['width'].'.webp';
|
||||
if ([] === $missing || !is_file($file)) {
|
||||
continue;
|
||||
}
|
||||
if ($done >= $limit || microtime(true) - $start > $seconds || !function_exists('imagecreatefromwebp')) {
|
||||
++$left;
|
||||
continue;
|
||||
}
|
||||
$memoryLimit = ini_parse_quantity((string) ini_get('memory_limit'));
|
||||
if ($memoryLimit > 0 && memory_get_usage(true) + $largest['width'] * $largest['height'] * 12 + 16777216 > $memoryLimit) {
|
||||
// Passt nicht in den Arbeitsspeicher dieses Hostings: Das Bild behält seine Stufen.
|
||||
continue;
|
||||
}
|
||||
$source = @imagecreatefromwebp($file);
|
||||
if (!$source) {
|
||||
continue;
|
||||
}
|
||||
$written = [];
|
||||
try {
|
||||
foreach ($missing as $width) {
|
||||
$height = max(1, (int) round($largest['height'] * $width / $largest['width']));
|
||||
$target = imagecreatetruecolor($width, $height);
|
||||
try {
|
||||
imagealphablending($target, false);
|
||||
imagesavealpha($target, true);
|
||||
imagecopyresampled($target, $source, 0, 0, 0, 0, $width, $height, (int) $largest['width'], (int) $largest['height']);
|
||||
$path = $this->directory.'/'.$row['id'].'-'.$width.'.webp';
|
||||
$written[] = $path;
|
||||
if (!imagewebp($target, $path, self::QUALITY) || !is_file($path) || !filesize($path)) {
|
||||
throw new \RuntimeException('Bildvariante konnte nicht gespeichert werden.');
|
||||
}
|
||||
$variants[] = ['src' => '/media/'.$row['id'].'/'.$width, 'width' => $width, 'height' => $height, 'bytes' => filesize($path)];
|
||||
} finally {
|
||||
imagedestroy($target);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $error) {
|
||||
foreach ($written as $path) {
|
||||
@unlink($path);
|
||||
}
|
||||
throw $error;
|
||||
} finally {
|
||||
imagedestroy($source);
|
||||
}
|
||||
usort($variants, static fn (array $a, array $b): int => $a['width'] <=> $b['width']);
|
||||
$this->db->executeStatement('UPDATE media SET variants = ? WHERE id = ?', [json_encode($variants, JSON_THROW_ON_ERROR), $row['id']]);
|
||||
++$done;
|
||||
}
|
||||
$this->gemerkt = [];
|
||||
|
||||
return ['done' => $done, 'left' => $left];
|
||||
}
|
||||
|
||||
public function update(string $id, array $data): array
|
||||
{
|
||||
if (!is_string($data['title'] ?? null) || '' === trim($data['title']) || strlen($data['title']) > 160 || !is_string($data['alt'] ?? null) || strlen($data['alt']) > 300 || !is_bool($data['archived'] ?? null) || !is_int($data['version'] ?? null)) {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
/**
|
||||
* Legt Bildvarianten dorthin, wo der Webserver sie ohne PHP ausliefern kann.
|
||||
*
|
||||
* Jedes Bild einer Seite war eine Anfrage an die Anwendung: Kernel starten, Datenbank fragen,
|
||||
* Datei durchreichen. Gemessen auf kant-it.de (10.11.2026) rund 250 ms bis zum ersten Byte, eine
|
||||
* statische Datei daneben rund 100 ms - und jede dieser Anfragen belegt einen der wenigen
|
||||
* PHP-Prozesse, die ein geteiltes Hosting gleichzeitig hergibt.
|
||||
*
|
||||
* Der Medienordner unter var/ bleibt die Quelle; Sicherung, Website-Paket und Wiederherstellung
|
||||
* kennen nur ihn. Dieser Ordner ist ein Abbild, das sich von selbst füllt: Beim ersten Abruf über
|
||||
* PHP landet die Variante hier, alle weiteren beantwortet der Webserver (Regel in
|
||||
* public/.htaccess). Fehlt das Abbild oder lässt es sich nicht schreiben, bleibt alles beim Alten.
|
||||
*
|
||||
* Die Adressen der Bilder ändern sich nicht. Was hier liegt, war auch vorher für jeden abrufbar,
|
||||
* der die Adresse kennt - die Auslieferung über PHP prüft keine Anmeldung.
|
||||
*/
|
||||
final class MediaMirror
|
||||
{
|
||||
/** @param string $directory das Abbild, üblich public/media; leer schaltet es ab */
|
||||
public function __construct(private string $directory, private string $mediaDirectory)
|
||||
{
|
||||
}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return '' !== $this->directory;
|
||||
}
|
||||
|
||||
/** Legt eine Variante ab. Scheitert still: das Abbild ist eine Abkürzung, keine Voraussetzung. */
|
||||
public function publish(string $id, int $width, string $source): void
|
||||
{
|
||||
if (!$this->enabled() || 1 !== preg_match('/^[a-f0-9]{32}$/D', $id) || $width < 1 || $width > 9999) {
|
||||
return;
|
||||
}
|
||||
$folder = $this->directory.'/'.$id;
|
||||
$target = $folder.'/'.$width.'.webp';
|
||||
if (is_file($target)) {
|
||||
return;
|
||||
}
|
||||
if (!is_dir($folder) && !@mkdir($folder, 0o775, true) && !is_dir($folder)) {
|
||||
return;
|
||||
}
|
||||
// Ein harter Verweis kostet keinen zweiten Platz auf der Platte. Wo das Dateisystem ihn
|
||||
// nicht kennt oder der Medienordner woanders liegt, wird kopiert - über eine Zwischendatei,
|
||||
// damit der Webserver nie ein halbes Bild ausliefert.
|
||||
if (@link($source, $target)) {
|
||||
return;
|
||||
}
|
||||
$teil = $target.'.'.bin2hex(random_bytes(4)).'.tmp';
|
||||
if (@copy($source, $teil) && filesize($source) === filesize($teil) && @rename($teil, $target)) {
|
||||
return;
|
||||
}
|
||||
@unlink($teil);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt aus dem Abbild, was es im Medienordner nicht mehr gibt - nach dem Import eines
|
||||
* Website-Pakets etwa. Ohne das lieferte der Webserver Bilder weiter aus, die die Anwendung
|
||||
* selbst nicht mehr kennt.
|
||||
*
|
||||
* @return int entfernte Dateien
|
||||
*/
|
||||
public function prune(): int
|
||||
{
|
||||
if (!$this->enabled() || !is_dir($this->directory)) {
|
||||
return 0;
|
||||
}
|
||||
$removed = 0;
|
||||
foreach (scandir($this->directory) ?: [] as $id) {
|
||||
if (1 !== preg_match('/^[a-f0-9]{32}$/D', $id) || !is_dir($this->directory.'/'.$id)) {
|
||||
continue;
|
||||
}
|
||||
foreach (scandir($this->directory.'/'.$id) ?: [] as $name) {
|
||||
if ('.' === $name || '..' === $name) {
|
||||
continue;
|
||||
}
|
||||
$width = 1 === preg_match('/^(\d{1,4})\.webp$/D', $name, $treffer) ? $treffer[1] : null;
|
||||
if (null === $width || !is_file($this->mediaDirectory.'/'.$id.'-'.$width.'.webp')) {
|
||||
$removed += (int) @unlink($this->directory.'/'.$id.'/'.$name);
|
||||
}
|
||||
}
|
||||
// Gelingt nur, wenn der Ordner leer ist - genau dann soll er weg.
|
||||
@rmdir($this->directory.'/'.$id);
|
||||
}
|
||||
|
||||
return $removed;
|
||||
}
|
||||
}
|
||||
@@ -239,26 +239,37 @@ final class PostRepository
|
||||
[$pageId, 'category'],
|
||||
);
|
||||
// Dieselbe Sortierung wie im Archiv, damit „vorheriges" dort dasselbe heißt wie hier.
|
||||
$all = $this->published(1, '' === $termId ? null : $termId, 500, $locale, $language)['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;
|
||||
}
|
||||
// Geholt wird erst nur die Reihenfolge - Kennungen, keine Dokumente - und danach genau
|
||||
// das, was gezeigt wird: die zwei Nachbarn. Vorher lud jeder Aufruf eines Beitrags bis zu
|
||||
// 500 veröffentlichte Fassungen mit vollem Dokument, um zwei Titel zu lesen, und ab dem
|
||||
// 501. Beitrag einer Kategorie gab es gar keine Nachbarn mehr.
|
||||
$where = "p.website_id = ? AND p.locale = ? AND p.type = 'post' AND p.published_revision_id IS NOT NULL AND p.deleted_at IS NULL";
|
||||
$params = ['default', $locale];
|
||||
$join = '';
|
||||
if ('' !== $termId) {
|
||||
$join = ' JOIN page_term pt ON pt.page_id = p.id AND pt.term_id = ?';
|
||||
array_unshift($params, $termId);
|
||||
}
|
||||
if (null === $index) {
|
||||
$order = $this->db->fetchFirstColumn('SELECT p.id FROM page p'.$join.' WHERE '.$where.' ORDER BY p.published_at IS NULL, p.published_at DESC, p.path ASC', $params);
|
||||
$index = array_search($pageId, $order, true);
|
||||
if (false === $index) {
|
||||
return ['previous' => null, 'next' => null];
|
||||
}
|
||||
// „Vorheriges" ist im Archiv der neuere Beitrag, weil dort das Neueste oben steht.
|
||||
$wanted = array_filter(['previous' => $order[$index - 1] ?? null, 'next' => $order[$index + 1] ?? null]);
|
||||
if ([] === $wanted) {
|
||||
return ['previous' => null, 'next' => null];
|
||||
}
|
||||
$rows = $this->db->fetchAllAssociativeIndexed(
|
||||
'SELECT p.id AS kennung, p.id, p.path, p.excerpt, p.author, p.featured_media_id, p.published_at, r.document FROM page p'
|
||||
.' JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.id IN ('.implode(',', array_fill(0, count($wanted), '?')).')',
|
||||
array_values($wanted),
|
||||
);
|
||||
$images = $this->media->findMany(array_map(static fn (array $row) => (string) $row['featured_media_id'], $rows));
|
||||
$terms = $this->termsForMany(array_keys($rows), $language);
|
||||
$post = fn (?string $id): ?array => null !== $id && isset($rows[$id]) ? $this->present($rows[$id], $images, $terms[$id] ?? ['category' => [], 'tag' => []], $language) : 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,
|
||||
];
|
||||
return ['previous' => $post($wanted['previous'] ?? null), 'next' => $post($wanted['next'] ?? null)];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -302,6 +313,40 @@ final class PostRepository
|
||||
}
|
||||
|
||||
/** Term names of one page, as the comma separated list the metadata form edits. */
|
||||
/**
|
||||
* Kategorien und Schlagwörter mehrerer Beiträge in einer Abfrage, als Text je Beitrag.
|
||||
*
|
||||
* Die Liste der Verwaltung fragte vorher für jeden Beitrag einzeln nach: zwanzig Beiträge,
|
||||
* vierzig Abfragen.
|
||||
*
|
||||
* @param list<string> $pageIds
|
||||
*
|
||||
* @return array<string, array{category: string, tag: string}>
|
||||
*/
|
||||
public function namesForMany(array $pageIds): array
|
||||
{
|
||||
$names = [];
|
||||
foreach ($this->termsForMany($pageIds) as $id => $terms) {
|
||||
$names[$id] = [
|
||||
'category' => implode(', ', array_column($terms['category'], 'name')),
|
||||
'tag' => implode(', ', array_column($terms['tag'], 'name')),
|
||||
];
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt die Bilder einer Liste in einer Abfrage, damit die Vorlage sie danach einzeln findet,
|
||||
* ohne je Zeile die Datenbank zu fragen.
|
||||
*
|
||||
* @param list<string|null> $mediaIds
|
||||
*/
|
||||
public function preloadImages(array $mediaIds): void
|
||||
{
|
||||
$this->media->findMany(array_map('strval', array_filter($mediaIds)));
|
||||
}
|
||||
|
||||
public function namesFor(string $pageId): array
|
||||
{
|
||||
$terms = $this->termsFor($pageId);
|
||||
|
||||
@@ -488,7 +488,11 @@ final class StyleCompiler
|
||||
*/
|
||||
public static function variantFor(array $variants, string $device): array
|
||||
{
|
||||
$wanted = ['mobile' => 960, 'tablet' => 1600, 'desktop' => 4000][$device] ?? 4000;
|
||||
// 720 für das Telefon seit 1.42.0: 412 px Breite mal 1,75 sind 721 px. Vorher 960 - mit
|
||||
// fast doppelt so vielen Bildpunkten war der Hintergrund des Heros das, worauf das größte
|
||||
// Element der Seite am längsten wartete. Ein Bild, dem diese Stufe noch fehlt,
|
||||
// bekommt die nächstgrößere, also wie bisher 960.
|
||||
$wanted = ['mobile' => 720, 'tablet' => 1600, 'desktop' => 4000][$device] ?? 4000;
|
||||
$chosen = $variants[0];
|
||||
foreach ($variants as $variant) {
|
||||
if ($variant['width'] <= $wanted || $chosen['width'] < $wanted) {
|
||||
|
||||
@@ -49,11 +49,13 @@ final class EditorController extends AbstractController
|
||||
$result = $this->pages->editorPage($type, $request->query->getInt('seite', 1), $request->query->getInt('limit', 20), $query, $status);
|
||||
$pages = $result['items'];
|
||||
$parentChoices = [];
|
||||
$terms = 'post' === $type ? $this->posts->namesForMany(array_column($pages, 'id')) : [];
|
||||
$this->posts->preloadImages([...array_column($pages, 'featured_media_id'), ...array_column($pages, 'og_media_id')]);
|
||||
foreach ($pages as &$page) {
|
||||
$page['document'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR);
|
||||
$page['title'] = $page['document']['title'] ?? $page['slug'];
|
||||
$page['depth'] = substr_count((string) $page['path'], '/');
|
||||
$page['terms'] = 'post' === $page['type'] ? $this->posts->namesFor($page['id']) : ['category' => '', 'tag' => ''];
|
||||
$page['terms'] = $terms[$page['id']] ?? ['category' => '', 'tag' => ''];
|
||||
if ($page['parent_id'] && !isset($parentChoices[$page['parent_id']])) {
|
||||
$parentChoices[$page['parent_id']] = $this->pages->choices(id: $page['parent_id'])[0] ?? null;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Content\DocumentLibrary;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MediaMirror;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\VideoLibrary;
|
||||
use App\Domain\StaleWrite;
|
||||
@@ -18,7 +19,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
|
||||
final class MediaController extends AbstractController
|
||||
{
|
||||
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings, private DocumentLibrary $documents, private VideoLibrary $videos)
|
||||
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings, private DocumentLibrary $documents, private VideoLibrary $videos, private MediaMirror $mirror)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -380,6 +381,9 @@ final class MediaController extends AbstractController
|
||||
public function serve(string $id, int $width): Response
|
||||
{
|
||||
$path = $this->media->file($id, $width) ?? throw $this->createNotFoundException();
|
||||
// Diese Anfrage kam noch bei PHP an; die nächste nach demselben Bild beantwortet der
|
||||
// Webserver selbst.
|
||||
$this->mirror->publish($id, $width, $path);
|
||||
|
||||
return new BinaryFileResponse($path, headers: ['Content-Type' => 'image/webp', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=31536000, immutable', 'Content-Security-Policy' => "default-src 'none'"]);
|
||||
}
|
||||
|
||||
@@ -323,6 +323,10 @@ final class PageController extends AbstractController
|
||||
$areas['footer'] = $this->partials->expandRepeatedLinks($areas['footer'], $sections, $seen, $pristine);
|
||||
}
|
||||
}
|
||||
// Bilder nennen die Größen, die die Mediathek heute hat - nicht die vom Tag, an dem das
|
||||
// Bild in die Seite kam. So erreichen nachgereichte Stufen jede gespeicherte Seite, ohne
|
||||
// dass ein Dokument umgeschrieben wird.
|
||||
[$page['document'], $areas, $sections] = $this->currentImageSources($page['document'], $areas, $sections);
|
||||
$documents = [$page['document'], ...array_values($areas), ...array_values($sections)];
|
||||
$needsSlider = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'slider'));
|
||||
$needsMotion = (bool) array_filter($documents, fn (array $doc): bool => $this->hasEntrance($doc));
|
||||
@@ -956,7 +960,7 @@ final class PageController extends AbstractController
|
||||
'blocks' => [
|
||||
['id' => 'archiv-kopf', 'type' => 'section', 'layout' => ['tone' => 'mint', 'width' => 'normal'], 'children' => $kopf],
|
||||
['id' => 'archiv', 'type' => 'section', 'layout' => ['tone' => 'paper', 'width' => 'normal'], 'children' => [
|
||||
['id' => 'archiv-liste', 'type' => 'postlist', 'props' => ['count' => PostRepository::PER_PAGE, 'category' => 'category' === $kind && null !== $term ? $term['slug'] : '', 'tag' => 'tag' === $kind && null !== $term ? $term['slug'] : '', 'layout' => 'karten', 'paginate' => true]],
|
||||
['id' => 'archiv-liste', 'type' => 'postlist', 'props' => ['count' => PostRepository::PER_PAGE, 'category' => 'category' === $kind && null !== $term ? $term['slug'] : '', 'tag' => 'tag' === $kind && null !== $term ? $term['slug'] : '', 'layout' => 'karten', 'paginate' => true, 'headingLevel' => 2]],
|
||||
]],
|
||||
],
|
||||
];
|
||||
@@ -1042,6 +1046,59 @@ final class PageController extends AbstractController
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt bei jedem Bild aus der Mediathek die Liste der Größen auf ihren heutigen Stand.
|
||||
*
|
||||
* Angefasst wird nur ein Bild, das seine Kennung trägt und dessen Adresse zu ihr gehört; die
|
||||
* Adresse und die Maße des Bildes selbst bleiben, wie sie gespeichert sind.
|
||||
*
|
||||
* @param array<string, array> $areas
|
||||
* @param array<string, array> $sections
|
||||
*
|
||||
* @return array{0: array, 1: array<string, array>, 2: array<string, array>}
|
||||
*/
|
||||
private function currentImageSources(array $document, array $areas, array $sections): array
|
||||
{
|
||||
$ids = [];
|
||||
$collect = static function (array $blocks) use (&$collect, &$ids): void {
|
||||
foreach ($blocks as $block) {
|
||||
$id = $block['props']['mediaId'] ?? null;
|
||||
if ('image' === ($block['type'] ?? null) && is_string($id) && isset($block['props']['sources'])) {
|
||||
$ids[$id] = true;
|
||||
}
|
||||
$collect($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
foreach ([$document, ...array_values($areas), ...array_values($sections)] as $one) {
|
||||
$collect($one['blocks'] ?? []);
|
||||
}
|
||||
if ([] === $ids) {
|
||||
return [$document, $areas, $sections];
|
||||
}
|
||||
$media = $this->media->findMany(array_keys($ids));
|
||||
$apply = static function (array $blocks) use (&$apply, $media): array {
|
||||
foreach ($blocks as $index => $block) {
|
||||
$id = $block['props']['mediaId'] ?? null;
|
||||
if ('image' === ($block['type'] ?? null) && is_string($id) && isset($block['props']['sources'], $media[$id])
|
||||
&& str_starts_with((string) ($block['props']['src'] ?? ''), '/media/'.$id.'/') && [] !== $media[$id]['variants']) {
|
||||
$blocks[$index]['props']['sources'] = array_map(static fn (array $v): array => ['src' => $v['src'], 'width' => $v['width']], $media[$id]['variants']);
|
||||
}
|
||||
if (isset($block['children'])) {
|
||||
$blocks[$index]['children'] = $apply($block['children']);
|
||||
}
|
||||
}
|
||||
|
||||
return $blocks;
|
||||
};
|
||||
$one = static function (array $doc) use ($apply): array {
|
||||
$doc['blocks'] = $apply($doc['blocks'] ?? []);
|
||||
|
||||
return $doc;
|
||||
};
|
||||
|
||||
return [$one($document), array_map($one, $areas), array_map($one, $sections)];
|
||||
}
|
||||
|
||||
/**
|
||||
* The first image of the page, in the order it is rendered.
|
||||
*
|
||||
@@ -1069,23 +1126,32 @@ final class PageController extends AbstractController
|
||||
// obere. Sonst der erste Abschnitt des Dokuments.
|
||||
foreach ([$header, $document] as $kandidat) {
|
||||
$block = ($kandidat['blocks'] ?? [])[0] ?? null;
|
||||
$id = $block['style']['desktop']['backgroundImage'] ?? null;
|
||||
if (!is_string($id) || '' === $id) {
|
||||
continue;
|
||||
}
|
||||
$item = $this->media->find($id);
|
||||
if (null === $item || [] === ($item['variants'] ?? [])) {
|
||||
continue;
|
||||
}
|
||||
// Je Gerät das Bild, das die Regel dort wirklich nimmt: Tablet erbt vom Desktop, das
|
||||
// Telefon vom Tablet - dieselbe Vererbung wie im Compiler. Vorher zählte nur der
|
||||
// Desktop-Wert: Ein Telefon mit eigenem oder abgeschaltetem Hintergrund lud dann mit
|
||||
// hoher Priorität ein Bild, das es nie zeigt, gegen sein eigentliches größtes Element.
|
||||
$sichtbar = ['all' => [], 'desktop' => ['tablet', 'mobile'], 'tablet' => ['desktop', 'mobile'], 'mobile' => ['desktop', 'tablet'], 'hide-desktop' => ['desktop'], 'hide-tablet' => ['tablet'], 'hide-mobile' => ['mobile'], 'hidden' => ['desktop', 'tablet', 'mobile']];
|
||||
$aus = $sichtbar[$block['layout']['visibility'] ?? 'all'] ?? [];
|
||||
$verweise = [];
|
||||
$geerbt = null;
|
||||
foreach (StyleCompiler::PRELOAD_DEVICES as $device => $media) {
|
||||
$id = $block['style'][$device]['backgroundImage'] ?? $geerbt;
|
||||
$geerbt = $id;
|
||||
if (!is_string($id) || '' === $id || 'none' === $id || in_array($device, $aus, true)) {
|
||||
continue;
|
||||
}
|
||||
$item = $this->media->find($id);
|
||||
if (null === $item || [] === ($item['variants'] ?? [])) {
|
||||
continue;
|
||||
}
|
||||
$verweise[] = [
|
||||
'src' => StyleCompiler::variantFor($item['variants'], $device)['src'],
|
||||
'media' => $media,
|
||||
];
|
||||
}
|
||||
|
||||
return $verweise;
|
||||
if ([] !== $verweise) {
|
||||
return $verweise;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Ops;
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\MediaMirror;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidPage;
|
||||
use Doctrine\DBAL\Connection;
|
||||
@@ -49,6 +50,7 @@ final class SitePackage
|
||||
private Updater $updater,
|
||||
private string $root,
|
||||
private string $mediaDirectory,
|
||||
private ?MediaMirror $mirror = null,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -231,6 +233,8 @@ final class SitePackage
|
||||
return $fail();
|
||||
}
|
||||
$step('Medien ersetzen', true, sprintf('%d Datei(en) übernommen.', $moved));
|
||||
// Was der Webserver noch aus dem Abbild lieferte, gehört zur Website von vorher.
|
||||
$this->mirror?->prune();
|
||||
} finally {
|
||||
if (!$inWartung) {
|
||||
$this->updater->maintenance(false);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Ops\Tasks;
|
||||
|
||||
use App\Content\MediaMirror;
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Scheduler;
|
||||
use App\Ops\Updater;
|
||||
@@ -26,7 +27,7 @@ final class CleanupTask implements Task
|
||||
/** So viele Zeilen bleiben in den Protokollen stehen. */
|
||||
private const KEEP_LINES = 200;
|
||||
|
||||
public function __construct(private string $projectDir)
|
||||
public function __construct(private string $projectDir, private ?MediaMirror $mirror = null)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -57,12 +58,16 @@ final class CleanupTask implements Task
|
||||
{
|
||||
$limit = time() - self::KEEP_DAYS * 86400;
|
||||
|
||||
return sprintf(
|
||||
$bericht = sprintf(
|
||||
'%d Sitzungsdatei(en), %d Update-Ordner entfernt, %d Protokoll(e) gekürzt.',
|
||||
$this->sessions($limit),
|
||||
$this->updateFolders($limit),
|
||||
$this->logs(),
|
||||
);
|
||||
// Bildvarianten im öffentlichen Abbild, deren Bild es nicht mehr gibt.
|
||||
$verwaist = $this->mirror?->prune() ?? 0;
|
||||
|
||||
return 0 === $verwaist ? $bericht : $bericht.sprintf(' %d verwaiste Bilddatei(en) aus public/media entfernt.', $verwaist);
|
||||
}
|
||||
|
||||
/** Sitzungsdateien. Der Garbage Collector von PHP ist auf vielen Hostings abgeschaltet. */
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops\Tasks;
|
||||
|
||||
use App\Content\MediaLibrary;
|
||||
|
||||
/**
|
||||
* Reicht vorhandenen Bildern Größen nach, die es bei ihrem Upload noch nicht gab.
|
||||
*
|
||||
* Mit 1.42.0 kamen die Stufen 720 und 1280 dazu. Neue Uploads bekommen sie sofort; für alles,
|
||||
* was schon in der Mediathek liegt, rechnet diese Aufgabe sie portionsweise aus der größten
|
||||
* vorhandenen Variante. Ist nichts mehr offen, kostet ein Lauf eine Abfrage.
|
||||
*/
|
||||
final class ImageVariantsTask implements Task
|
||||
{
|
||||
public function __construct(private MediaLibrary $media)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'image-variants';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Bildgrößen ergänzen';
|
||||
}
|
||||
|
||||
public function explanation(): string
|
||||
{
|
||||
return 'Ohne sie laden Telefone bei älteren Bildern eine größere Fassung als nötig. Angezeigt wird trotzdem alles.';
|
||||
}
|
||||
|
||||
public function interval(): int
|
||||
{
|
||||
return 900;
|
||||
}
|
||||
|
||||
public function run(): string
|
||||
{
|
||||
$ergebnis = $this->media->fillVariants();
|
||||
if (0 === $ergebnis['done'] && 0 === $ergebnis['left']) {
|
||||
return 'Alle Bilder haben alle Größen.';
|
||||
}
|
||||
|
||||
return sprintf('%d Bild(er) ergänzt, %d warten auf den nächsten Lauf.', $ergebnis['done'], $ergebnis['left']);
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@
|
||||
</div>
|
||||
</header>
|
||||
<div id="message" class="editor-message" role="alert" hidden></div>
|
||||
<div id="local-backup" class="editor-message editor-backup" role="status" hidden><span id="local-backup-text"></span><span class="editor-backup-actions"><button type="button" class="secondary" id="local-backup-restore">Wiederherstellen</button><button type="button" class="text-button" id="local-backup-discard">Verwerfen</button></span></div>
|
||||
<div id="recovery" class="recovery-bar" hidden><button id="download" class="secondary">Änderungen als JSON sichern</button><button id="reload" class="secondary">Serverstand laden</button><a href="{{ path('admin_login') }}" target="_blank" rel="noopener">Erneut anmelden ↗</a></div>
|
||||
<main id="admin-main" class="editor-layout" tabindex="-1">
|
||||
<aside class="sidebar" aria-label="Bearbeitung" data-view="elements">
|
||||
|
||||
@@ -6,6 +6,19 @@
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/page-picker.js?v={{ kant_version() }}"></script><script type="module" src="/assets/favicon-field.js?v={{ kant_version() }}"></script><script type="module" src="/assets/logo-field.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block content %}
|
||||
{# Die Seite ist lang. Die Leiste bleibt beim Scrollen stehen und führt zu jedem Abschnitt. #}
|
||||
<nav class="jump-nav" aria-label="Abschnitte dieser Seite">
|
||||
<a href="#not-found">404-Seite</a>
|
||||
<a href="#sprachen">Sprachen</a>
|
||||
<a href="#unternehmen">Unternehmen</a>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<a href="#favicon-title">Symbol</a>
|
||||
<a href="#nach-oben">Knopf „nach oben“</a>
|
||||
<a href="#spamschutz">Spamschutz</a>
|
||||
<a href="#einwilligung">Einwilligung</a>
|
||||
{% endif %}
|
||||
<a href="#weiterleitungen">Weiterleitungen</a>
|
||||
</nav>
|
||||
|
||||
{% if missingLegal is not empty %}
|
||||
{# Not a hint, a warning: in Germany both pages are required, and they have to be reachable from
|
||||
@@ -99,7 +112,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
<section class="stack-section">
|
||||
<section class="stack-section" id="unternehmen">
|
||||
<div class="section-head"><h2>Unternehmensdaten</h2><span class="muted">Grundlage für Organization und LocalBusiness</span></div>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<form action="{{ path('admin_settings_save') }}" method="post" class="card card-body">
|
||||
@@ -146,8 +159,9 @@
|
||||
<label for="sameAs">Profile</label>
|
||||
<textarea id="sameAs" name="sameAs" rows="3" placeholder="https://www.linkedin.com/company/…">{{ settings.sameAs|join('\n') }}</textarea>
|
||||
<p class="field-help">Eine HTTPS-Adresse je Zeile, höchstens zwanzig. Die sozialen Profile unten stehen automatisch mit in den strukturierten Daten.</p>
|
||||
<fieldset class="field-set">
|
||||
<legend>Soziale Profile</legend>
|
||||
{% set profile = settings.social|default({})|filter(v => v)|length %}
|
||||
<details class="field-set field-fold"{{ profile ? ' open' : '' }}>
|
||||
<summary>Soziale Profile <span class="muted">{{ profile ? profile ~ ' eingetragen' : 'keine eingetragen' }}</span></summary>
|
||||
<p class="field-help">Einmal hier eintragen, dann mit dem Element „Soziale Profile“ an jeder Stelle einsetzen – im Kopf, im Fuß, in einer Kontaktsektion. Größe, Form und Farbe bestimmt das Element. Leere Felder erscheinen nicht.</p>
|
||||
<div class="fields">
|
||||
{% for key, name in socialNetworks %}
|
||||
@@ -157,9 +171,10 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="field-set">
|
||||
<legend>Öffnungszeiten</legend>
|
||||
</details>
|
||||
{% set zeiten = settings.openingHours|default([])|filter(h => h.opens|default('') or h.closes|default(''))|length %}
|
||||
<details class="field-set field-fold"{{ zeiten ? ' open' : '' }}>
|
||||
<summary>Öffnungszeiten <span class="muted">{{ zeiten ? zeiten ~ ' Tag(e) eingetragen' : 'keine eingetragen' }}</span></summary>
|
||||
<p class="field-help">Als Uhrzeit wie 07:30. Ein Tag ohne Angabe gilt als geschlossen. Erscheint als <code>openingHoursSpecification</code> in den strukturierten Daten – der Text auf der Website ist davon unabhängig.</p>
|
||||
<div class="fields">
|
||||
{% for day, label in weekdays %}
|
||||
@@ -170,7 +185,7 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
</details>
|
||||
<label for="areaServed">Einsatzgebiet</label>
|
||||
<textarea id="areaServed" name="areaServed" rows="3" placeholder="Baden-Baden Rastatt Karlsruhe">{{ settings.areaServed|join('\n') }}</textarea>
|
||||
<p class="field-help">Ein Ort je Zeile, höchstens vierzig. Erscheint als <code>areaServed</code> in den strukturierten Daten.</p>
|
||||
@@ -217,7 +232,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help">Das Bild kommt aus der Mediathek, dort kann es auch gleich hochgeladen werden. Danach das Quadrat auf den gewünschten Ausschnitt ziehen und die Größe einstellen. Beim Speichern entstehen daraus PNG-Dateien in 32 und 180 Pixeln.</p>
|
||||
<div class="section-head"><h2>Knopf „nach oben"</h2><span class="muted">Erscheint beim Scrollen, in der Ecke</span></div>
|
||||
<div class="section-head"><h2 id="nach-oben">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">
|
||||
@@ -260,7 +275,7 @@
|
||||
</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>
|
||||
<div class="section-head"><h2 id="spamschutz">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>
|
||||
@@ -271,7 +286,7 @@
|
||||
<label for="robotsExtra">Zusätzliche Zeilen für robots.txt</label>
|
||||
<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>
|
||||
<div class="section-head"><h2 id="einwilligung">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="headerOverlay"><input id="headerOverlay" name="headerOverlay" type="checkbox" value="1"{{ settings.headerOverlay ? ' checked' : '' }}> Kopfbereich über dem Inhalt schweben lassen</label>
|
||||
<p class="field-help">Der Kopfbereich liegt dann über der ersten Sektion, wie der transparente Header in Flatsome. Seine Farben für den gescrollten Zustand stellst du im Editor des Kopfbereichs unter Design → Hintergrund „beim Scrollen“ ein; zusammen mit „oben halten“ bleibt er beim Scrollen sichtbar.</p>
|
||||
@@ -349,14 +364,19 @@
|
||||
</div>
|
||||
<p class="field-help">Die Entscheidung liegt in einem Cookie dieser Website, ein halbes Jahr lang, ohne Kennung und ohne Übermittlung. Unter <code>/einwilligung</code> kann sie jederzeit geändert werden; auf jeder Seite steht dafür ein Link.</p>
|
||||
|
||||
<button class="primary" type="submit">Einstellungen speichern</button>
|
||||
{# Bleibt am unteren Rand stehen, solange das Formular zu sehen ist: Der Knopf lag vorher
|
||||
mehrere Bildschirmhöhen unter dem Feld, das man gerade geändert hatte. #}
|
||||
<div class="form-save-bar">
|
||||
<button class="primary" type="submit">Einstellungen speichern</button>
|
||||
<span class="muted">Gilt für alles von „Unternehmensdaten“ bis „Einwilligung“.</span>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="card card-body muted">Unternehmensdaten, Knopf „nach oben“, Spamschutz und Einwilligung ändert nur ein Administrator.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="stack-section">
|
||||
<section class="stack-section" id="weiterleitungen">
|
||||
<div class="section-head"><h2>Weiterleitungen <span class="count">{{ redirects.total }}</span></h2><span class="muted">Werden vor dem 404 geprüft.</span></div>
|
||||
<form action="{{ path('admin_redirect_save') }}" method="post" class="card card-body">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
|
||||
|
||||
@@ -65,7 +65,8 @@
|
||||
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'|kant_t) }} {{ 'vergrößern'|kant_t }}">
|
||||
{%- elseif imageLinked -%}
|
||||
<a class="image-link" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>
|
||||
{# Ein Bild ohne Alternativtext - ein dekoratives Logo etwa - gäbe dem Link keinen Namen. #}
|
||||
<a class="image-link" href="{{ props.href }}"{% if props.alt|default('') == '' %} aria-label="{{ props.caption|default('') ?: (props.href == '/' ? 'Startseite'|kant_t : 'Link'|kant_t ~ ': ' ~ props.href) }}"{% endif %}{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>
|
||||
{%- 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 imageLinked %}</a>{% endif %}{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure>
|
||||
{% elseif type == 'iconcard' %}
|
||||
<div id="{{ block.id }}" class="block-iconcard{{ designClasses }}">
|
||||
@@ -298,7 +299,7 @@
|
||||
{% elseif type == 'postlist' %}
|
||||
<div id="{{ block.id }}" class="block-postlist{{ designClasses }}">
|
||||
{% 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 %}
|
||||
{% 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, headingLevel: block.props.headingLevel|default(3) == 2 ? 2 : 3} only %}
|
||||
</div>
|
||||
{% elseif type == 'postnav' %}
|
||||
<div id="{{ block.id }}" class="block-postnav{{ designClasses }}">
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="post-body">
|
||||
<h3><a href="{{ post.href }}">{{ post.title }}</a></h3>
|
||||
{# Im Archiv folgt die Liste direkt auf die Hauptüberschrift, dort ist ein Beitrag eine
|
||||
Überschrift zweiter Ebene. In einer Seite steht sie unter einer Abschnittsüberschrift. #}
|
||||
<h{{ headingLevel|default(3) }}><a href="{{ post.href }}">{{ post.title }}</a></h{{ headingLevel|default(3) }}>
|
||||
{% 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 %}
|
||||
|
||||
@@ -103,6 +103,10 @@ final class BacklogTest extends TestCase
|
||||
self::assertStringContainsString('<h1', $html);
|
||||
self::assertStringContainsString('Beiträge', $html);
|
||||
self::assertStringContainsString('Aus der Fußsektion', $html, 'Der Fuß samt verknüpfter Sektion steht unter dem Archiv.');
|
||||
// Auf die Hauptüberschrift folgt im Archiv die zweite Ebene, nicht die dritte - sonst
|
||||
// springt die Gliederung, und Lighthouse zieht dafür Punkte bei der Barrierefreiheit ab.
|
||||
self::assertMatchesRegularExpression('#<div class="post-body">\s*<h2><a href="/[a-z0-9-]+">#', $html);
|
||||
self::assertStringNotContainsString('<h3><a', $html);
|
||||
self::assertStringContainsString('#fuss-hinweis p{letter-spacing:0.3em}', $html, 'Eigenes CSS der Sektion kommt mit.');
|
||||
self::assertStringContainsString('scroll-top', $html, 'Der Nach-oben-Knopf ist da wie auf jeder Seite.');
|
||||
self::assertStringContainsString('<link rel="canonical" href="https://cms.test/beitraege">', $html);
|
||||
|
||||
+119
-5
@@ -1035,6 +1035,14 @@ final class CmsTest extends TestCase
|
||||
self::assertStringContainsString('<a class="image-link" href="/">', $html);
|
||||
self::assertStringContainsString('alt="Firmenlogo"', $html);
|
||||
|
||||
// Ein dekoratives Logo hat keinen Alternativtext - der Link bekäme sonst keinen Namen.
|
||||
$document['blocks'][array_key_last($document['blocks'])]['props'] = ['src' => '/media/'.str_repeat('a', 32).'/480', 'alt' => '', 'decorative' => true, 'width' => 480, 'height' => 120, 'href' => '/'];
|
||||
$document['blocks'][] = ['id' => 'siegel', 'type' => 'image', 'props' => ['src' => '/media/'.str_repeat('b', 32).'/480', 'alt' => '', 'decorative' => true, 'width' => 480, 'height' => 120, 'href' => '/kontakt']];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 2), 3);
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('<a class="image-link" href="/" aria-label="Startseite">', $html);
|
||||
self::assertStringContainsString('<a class="image-link" href="/kontakt" aria-label="Link: /kontakt">', $html);
|
||||
|
||||
// Datum, Autor und Kategorie über einem Beitrag lassen sich abschalten - ein Beitrag mit
|
||||
// eigenem Kopfbild trägt den Titel schon, und die Zeile darüber steht dann im Weg.
|
||||
$this->publishedPost('projekt', 'Ein Projekt', '2026-02-01', ['Projekte'], []);
|
||||
@@ -1757,7 +1765,7 @@ final class CmsTest extends TestCase
|
||||
preg_match('/@media \(max-width:767px\)\{#'.$id.'\{([^}]*)}/', $html, $mobil);
|
||||
preg_match('/#'.$id.'\{([^}]*)}/', $html, $desktop);
|
||||
self::assertNotEmpty($mobil, 'Für kleine Geräte muss eine eigene Regel entstehen.');
|
||||
self::assertStringContainsString('/960', $mobil[1], 'Das Telefon bekommt die kleine Variante.');
|
||||
self::assertStringContainsString('/720', $mobil[1], 'Das Telefon bekommt die kleine Variante.');
|
||||
self::assertStringContainsString('/2000', $desktop[1], 'Der Schreibtisch die große.');
|
||||
// Die Abdunkelung gehört zur selben Regel - sonst stünde heller Text plötzlich auf
|
||||
// einem ungedämpften Bild.
|
||||
@@ -1770,7 +1778,7 @@ final class CmsTest extends TestCase
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, $version), $version + 1);
|
||||
preg_match('/@media \(max-width:767px\)\{#'.$id.'\{([^}]*)}/', $this->request('/')->getContent(), $eigen);
|
||||
self::assertStringContainsString('background-color:#ffffff', $eigen[1]);
|
||||
self::assertStringContainsString('/960', $eigen[1], 'Das Bild fehlt dort nicht, nur weil eine andere Eigenschaft gesetzt ist.');
|
||||
self::assertStringContainsString('/720', $eigen[1], 'Das Bild fehlt dort nicht, nur weil eine andere Eigenschaft gesetzt ist.');
|
||||
}
|
||||
|
||||
public function testTheBigPictureLoadsFirstAndSmallOnesAskForSmallVariants(): void
|
||||
@@ -3828,6 +3836,97 @@ display: flex', '#b1{display:flex;color:red}'];
|
||||
(new DocumentValidator())->validate($document);
|
||||
}
|
||||
|
||||
public function testAnImageServedOnceLandsWhereTheWebServerDeliversItWithoutPhp(): void
|
||||
{
|
||||
$media = new MediaLibrary($this->db, $this->path.'-media');
|
||||
$file = tempnam(sys_get_temp_dir(), 'kant-upload-');
|
||||
$image = imagecreatetruecolor(600, 400);
|
||||
imagepng($image, $file);
|
||||
imagedestroy($image);
|
||||
$item = $media->upload(new UploadedFile($file, 'bild.png', test: true));
|
||||
@unlink($file);
|
||||
$abbild = $this->path.'-abbild';
|
||||
$ziel = $abbild.'/'.$item['id'].'/480.webp';
|
||||
$quelle = $this->path.'-media/'.$item['id'].'-480.webp';
|
||||
|
||||
// Ohne Abbild (so laufen alle anderen Tests) bleibt es bei der Auslieferung über PHP.
|
||||
self::assertSame(200, $this->request('/media/'.$item['id'].'/480')->getStatusCode());
|
||||
self::assertDirectoryDoesNotExist($abbild);
|
||||
|
||||
$_SERVER['MEDIA_MIRROR_DIRECTORY'] = $_ENV['MEDIA_MIRROR_DIRECTORY'] = $abbild;
|
||||
try {
|
||||
$antwort = $this->request('/media/'.$item['id'].'/480');
|
||||
self::assertSame(200, $antwort->getStatusCode());
|
||||
self::assertFileEquals($quelle, $ziel, 'Die Variante liegt unter der Adresse, die public/.htaccess prüft.');
|
||||
// Eine Breite, die es nicht gibt, legt nichts an.
|
||||
self::assertSame(404, $this->request('/media/'.$item['id'].'/999')->getStatusCode());
|
||||
self::assertFileDoesNotExist($abbild.'/'.$item['id'].'/999.webp');
|
||||
} finally {
|
||||
unset($_SERVER['MEDIA_MIRROR_DIRECTORY'], $_ENV['MEDIA_MIRROR_DIRECTORY']);
|
||||
}
|
||||
|
||||
// Verschwindet das Bild aus dem Medienordner (Paketimport), räumt das Abbild es weg -
|
||||
// sonst lieferte der Webserver weiter aus, was die Anwendung nicht mehr kennt.
|
||||
$mirror = new \App\Content\MediaMirror($abbild, $this->path.'-media');
|
||||
self::assertSame(0, $mirror->prune());
|
||||
unlink($quelle);
|
||||
file_put_contents($abbild.'/'.$item['id'].'/fremd.txt', 'gehört nicht hierher');
|
||||
self::assertSame(2, $mirror->prune());
|
||||
self::assertDirectoryDoesNotExist($abbild.'/'.$item['id']);
|
||||
@rmdir($abbild);
|
||||
self::assertSame(0, (new \App\Content\MediaMirror('', $this->path.'-media'))->prune());
|
||||
}
|
||||
|
||||
public function testAnOlderImageGetsTheNewWidthsAndStoredPagesUseThem(): void
|
||||
{
|
||||
$media = new MediaLibrary($this->db, $this->path.'-media');
|
||||
$file = tempnam(sys_get_temp_dir(), 'kant-upload-');
|
||||
$limit = ini_get('memory_limit');
|
||||
ini_set('memory_limit', '512M');
|
||||
try {
|
||||
$image = imagecreatetruecolor(1700, 1000);
|
||||
imagejpeg($image, $file, 92);
|
||||
imagedestroy($image);
|
||||
$item = $media->upload(new UploadedFile($file, 'alt.jpg', test: true));
|
||||
// So sah ein Bild vor 1.42.0 aus: ohne 720 und 1280, in der Mediathek wie in der Seite.
|
||||
$alt = array_values(array_filter($item['variants'], static fn (array $v): bool => !in_array($v['width'], [720, 1280], true)));
|
||||
foreach ([720, 1280] as $width) {
|
||||
unlink($this->path.'-media/'.$item['id'].'-'.$width.'.webp');
|
||||
}
|
||||
$this->db->update('media', ['variants' => json_encode($alt)], ['id' => $item['id']]);
|
||||
$document = DemoContent::starter('Startseite');
|
||||
$document['schemaVersion'] = 18;
|
||||
$document['blocks'] = [['id' => 'oben', 'type' => 'section', 'props' => [], 'children' => [
|
||||
['id' => 'titel', 'type' => 'heading', 'props' => ['text' => 'Startseite', 'level' => 1]],
|
||||
['id' => 'bild', 'type' => 'image', 'props' => ['mediaId' => $item['id'], 'src' => '/media/'.$item['id'].'/1700', 'alt' => 'Ein Bild', 'decorative' => false, 'width' => 1700, 'height' => 1000, 'sources' => array_map(static fn (array $v): array => ['src' => $v['src'], 'width' => $v['width']], $alt)]],
|
||||
]]];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
self::assertStringNotContainsString('720w', $this->request('/')->getContent());
|
||||
|
||||
$media = new MediaLibrary($this->db, $this->path.'-media');
|
||||
self::assertSame(['done' => 1, 'left' => 0], $media->fillVariants());
|
||||
$neu = $media->find($item['id']);
|
||||
self::assertSame([480, 720, 960, 1280, 1700], array_column($neu['variants'], 'width'));
|
||||
self::assertSame($item['version'], $neu['version'], 'Die Schreibversion bleibt: ein offenes Formular zum Bild scheitert daran nicht.');
|
||||
foreach ([720 => 424, 1280 => 753] as $width => $height) {
|
||||
$size = getimagesize($this->path.'-media/'.$item['id'].'-'.$width.'.webp');
|
||||
self::assertSame([$width, $height, 'image/webp'], [$size[0], $size[1], $size['mime']]);
|
||||
}
|
||||
// Ein zweiter Lauf hat nichts mehr zu tun.
|
||||
self::assertSame(['done' => 0, 'left' => 0], $media->fillVariants());
|
||||
|
||||
// Die gespeicherte Seite nennt die neuen Größen, ohne dass ihr Dokument angefasst wurde -
|
||||
// im Bild wie in seiner Vorabladung.
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertMatchesRegularExpression('#<link rel="preload" as="image"[^>]+imagesrcset="[^"]*/720 720w[^"]*/1280 1280w#', $html);
|
||||
self::assertMatchesRegularExpression('#<img[^>]+srcset="[^"]*/720 720w[^"]*/1280 1280w#', $html);
|
||||
self::assertStringNotContainsString('720', (string) $this->db->fetchOne('SELECT document FROM revision ORDER BY seq DESC LIMIT 1'));
|
||||
} finally {
|
||||
ini_set('memory_limit', $limit);
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
|
||||
public function testAnUploadBecomesWebpInSeveralWidths(): void
|
||||
{
|
||||
$media = new MediaLibrary($this->db, $this->path.'-media');
|
||||
@@ -3845,7 +3944,7 @@ display: flex', '#b1{display:flex;color:red}'];
|
||||
$item = $media->upload(new UploadedFile($file, 'foto.jpg', test: true));
|
||||
|
||||
// Four widths, all WebP, none larger than the source.
|
||||
self::assertSame([480, 960, 1600, 2400], array_column($item['variants'], 'width'));
|
||||
self::assertSame([480, 720, 960, 1280, 1600, 2400], array_column($item['variants'], 'width'));
|
||||
foreach ($item['variants'] as $variant) {
|
||||
$path = $this->path.'-media/'.$item['id'].'-'.$variant['width'].'.webp';
|
||||
self::assertSame('image/webp', getimagesize($path)['mime'], 'Jede Variante ist WebP.');
|
||||
@@ -3886,7 +3985,7 @@ display: flex', '#b1{display:flex;color:red}'];
|
||||
|
||||
// CSS has no srcset, but the rules are written per device - so the device decides.
|
||||
self::assertStringContainsString('#hero{background-image:url("/media/'.$item['id'].'/2000")}', $css);
|
||||
self::assertStringContainsString('@media (max-width:767px){#hero{background-image:url("/media/'.$item['id'].'/960")}}', $css);
|
||||
self::assertStringContainsString('@media (max-width:767px){#hero{background-image:url("/media/'.$item['id'].'/720")}}', $css);
|
||||
} finally {
|
||||
@unlink($file);
|
||||
}
|
||||
@@ -3983,7 +4082,7 @@ display: flex', '#b1{display:flex;color:red}'];
|
||||
array_column($treffer, 2),
|
||||
);
|
||||
self::assertSame(
|
||||
['/media/'.$item['id'].'/2400', '/media/'.$item['id'].'/1600', '/media/'.$item['id'].'/960'],
|
||||
['/media/'.$item['id'].'/2400', '/media/'.$item['id'].'/1600', '/media/'.$item['id'].'/720'],
|
||||
array_column($treffer, 1),
|
||||
'Jedes Gerät bekommt die Variante, die es auch ohne Vorabholen bekäme.',
|
||||
);
|
||||
@@ -4003,6 +4102,21 @@ display: flex', '#b1{display:flex;color:red}'];
|
||||
$mitBild,
|
||||
$sparsam,
|
||||
));
|
||||
|
||||
// Das Telefon ohne Hintergrund, das Tablet ausgeblendet: vorab geholt wird nur, was
|
||||
// die Regel des jeweiligen Geräts auch zeigt. Vorher zählte allein der Desktop-Wert.
|
||||
$mit['schemaVersion'] = max(22, $mit['schemaVersion']);
|
||||
$mit['blocks'][0]['style']['mobile']['backgroundImage'] = 'none';
|
||||
$entwurf = $this->pages->saveDraft('page-1', $mit, $version($this->db));
|
||||
$this->pages->publish('page-1', $entwurf, $version($this->db));
|
||||
preg_match_all('/<link rel="preload" as="image" href="([^"]+)" media="([^"]+)"[^>]*>/', (string) $this->request('/')->getContent(), $treffer, PREG_SET_ORDER);
|
||||
self::assertSame(['(min-width:1025px)', '(min-width:768px) and (max-width:1024px)'], array_column($treffer, 2), 'Das Telefon lädt kein Bild vor, das es nicht zeigt.');
|
||||
|
||||
$mit['blocks'][0]['layout']['visibility'] = 'hide-tablet';
|
||||
$entwurf = $this->pages->saveDraft('page-1', $mit, $version($this->db));
|
||||
$this->pages->publish('page-1', $entwurf, $version($this->db));
|
||||
preg_match_all('/<link rel="preload" as="image" href="([^"]+)" media="([^"]+)"[^>]*>/', (string) $this->request('/')->getContent(), $treffer, PREG_SET_ORDER);
|
||||
self::assertSame(['(min-width:1025px)'], array_column($treffer, 2));
|
||||
} finally {
|
||||
@unlink($datei);
|
||||
ini_set('memory_limit', (string) $limit);
|
||||
|
||||
@@ -954,7 +954,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
$item = json_decode($client->getResponse()->getContent(), true)['item'];
|
||||
// Four steps now; the last one is capped at the width of the source (1800 here), so a
|
||||
// picture is never blown up beyond what was uploaded.
|
||||
self::assertSame([480, 960, 1600, 1800], array_column($item['variants'], 'width'));
|
||||
self::assertSame([480, 720, 960, 1280, 1600, 1800], array_column($item['variants'], 'width'));
|
||||
foreach ($item['variants'] as $variant) {
|
||||
$file = $this->path.'-media/'.$item['id'].'-'.$variant['width'].'.webp';
|
||||
self::assertSame('image/webp', getimagesize($file)['mime']);
|
||||
|
||||
@@ -78,7 +78,7 @@ final class TaskScreenTest extends WebTestCase
|
||||
self::assertStringContainsString('Ohne Besuch und ohne geöffnete Verwaltung passiert nichts.', $html);
|
||||
self::assertStringContainsString('/cms-cron/'.Scheduler::cronToken(self::SECRET), $html);
|
||||
// Und der Plan steht danach in der Datenbank - eine Zeile je Aufgabe, nicht mehr.
|
||||
self::assertSame(6, (int) $this->db->fetchOne('SELECT COUNT(*) FROM scheduled_task'));
|
||||
self::assertSame(7, (int) $this->db->fetchOne('SELECT COUNT(*) FROM scheduled_task'));
|
||||
}
|
||||
|
||||
public function testStartingWorkNeedsAnAdministratorAndAValidForm(): void
|
||||
|
||||
Reference in new issue
Block a user