Eigener Suchtitel, Schriften lokal, Bilder in Anzeigegroesse
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s

Getrennter Titel fuer Suchergebnisse je Seite und Beitrag (Migration
20260913000100): auf der Seite steht "Leistungen", im Suchergebnis darf
der lange Titel stehen. Leer heisst weiterhin: der Titel der Seite.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-13 15:11:46 +02:00
1 parent 35d8270afd
commit 074ef0e7e5
34 files changed
+1223 -45

No files matched your search

+4
View File
@@ -10,3 +10,7 @@
*.log *.log
/config/reference.php /config/reference.php
/var/releases/ /var/releases/
# Schriften werden je Installation heruntergeladen, nicht mitgeliefert.
/public/assets/fonts/
/public/assets/build/
+4 -1
View File
@@ -79,6 +79,7 @@ composer build
npm.cmd ci npm.cmd ci
npx.cmd playwright install chromium npx.cmd playwright install chromium
npm.cmd run test:browser npm.cmd run test:browser
npm.cmd run test:links
npm.cmd test npm.cmd test
npm.cmd run test:editor npm.cmd run test:editor
npm.cmd run test:editor -- --audit npm.cmd run test:editor -- --audit
@@ -89,7 +90,9 @@ Unter Linux/macOS `npm` und `npx` ohne `.cmd`; in CI `npx playwright install --w
`composer format` formatiert PHP. `composer build` wärmt den Symfony-Produktionscache; öffentliche CSS-Dateien sind direkt auslieferbar, es ist kein Frontend-Bundler nötig. Bei Änderungen an `public/assets/design-tokens.json` einmal `npm.cmd run build:design` ausführen und die erzeugte CSS-Datei mitliefern; Node ist auf dem Hosting nicht erforderlich. Nach Änderungen an Produktionskonfiguration/Templates `php bin/console cache:clear --env=prod --no-debug` ausführen. Falls lokale Tests alte Templates verwenden, vor PHPUnit auch `php bin/console cache:clear --env=test --no-debug` ausführen. `composer format` formatiert PHP. `composer build` wärmt den Symfony-Produktionscache; öffentliche CSS-Dateien sind direkt auslieferbar, es ist kein Frontend-Bundler nötig. Bei Änderungen an `public/assets/design-tokens.json` einmal `npm.cmd run build:design` ausführen und die erzeugte CSS-Datei mitliefern; Node ist auf dem Hosting nicht erforderlich. Nach Änderungen an Produktionskonfiguration/Templates `php bin/console cache:clear --env=prod --no-debug` ausführen. Falls lokale Tests alte Templates verwenden, vor PHPUnit auch `php bin/console cache:clear --env=test --no-debug` ausführen.
Das Lighthouse-Skript misst die Startseite dreimal je Profil und prüft die Mediane gegen die Projektziele. `test:editor -- --audit` prüft die im isolierten Editortest gestaltete v3-Seite mit Abschnittsvorlage, Galerie, tatsächlichen Bildvarianten und Effekten; Berichte stehen unter `reports/lighthouse-editor`. Uploads und Datenbank des Tests liegen getrennt unter `var/browser-tests`. Diese kleinen Prototypseiten ersetzen nicht die spätere Referenzsuite mit Kundeninhalten, Fotos, Beitrag und Formular. Messgrenzen und Ergebnisse: `docs/QUALITY.md`. `test:links` folgt der Sitemap, sammelt jeden internen Verweis samt Bildern, Stylesheets und Hintergrundbildern und meldet jede Antwort ab 400 – gebaut, nachdem im Nachbau von kant-it.de vier Verweise ins Leere zeigten, die kein Test finden konnte.
Das Lighthouse-Skript misst dreimal je Profil und prüft die Mediane gegen die Projektziele. Welche Adressen geprüft werden, sagt `CMS_PATHS` (mit Komma getrennt, Standard `/`); in der Git-Bash davor `MSYS_NO_PATHCONV=1` setzen, sonst wird aus `/` ein Windows-Pfad. `test:editor -- --audit` prüft die im isolierten Editortest gestaltete v3-Seite mit Abschnittsvorlage, Galerie, tatsächlichen Bildvarianten und Effekten; Berichte stehen unter `reports/lighthouse-editor`. Uploads und Datenbank des Tests liegen getrennt unter `var/browser-tests`. Diese kleinen Prototypseiten ersetzen nicht die spätere Referenzsuite mit Kundeninhalten, Fotos, Beitrag und Formular. Messgrenzen und Ergebnisse: `docs/QUALITY.md`.
## Inhalte und Entwicklung ## Inhalte und Entwicklung
+32
View File
@@ -11,6 +11,38 @@
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'; $path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$file = __DIR__.'/../public'.$path; $file = __DIR__.'/../public'.$path;
if ('/' !== $path && !str_contains($path, '..') && is_file($file)) { if ('/' !== $path && !str_contains($path, '..') && is_file($file)) {
// Dieselben Antwortkoepfe wie public/.htaccess sie auf einem Webserver setzt: Stylesheets,
// Skripte, Schriften und Bilder tragen ihre Fassung oder ihre Kennung in der Adresse und
// duerfen deshalb lange im Browser bleiben. Ohne das misst jede Messung hier etwas anderes
// als das, was spaeter ausgeliefert wird - und man optimiert an der falschen Stelle.
if (1 === preg_match('/\.(css|js|woff2|webp|png|jpe?g|gif|svg)$/', $path, $treffer) && !str_starts_with($path, '/assets/seite/')) {
header('Content-Type: '.match ($treffer[1]) {
'css' => 'text/css; charset=UTF-8',
'js' => 'text/javascript; charset=UTF-8',
'woff2' => 'font/woff2',
'svg' => 'image/svg+xml; charset=UTF-8',
'jpg', 'jpeg' => 'image/jpeg',
default => 'image/'.$treffer[1],
});
header('Cache-Control: public, max-age=31536000, immutable');
// Und ebenso die Komprimierung, die mod_deflate auf einem Webserver uebernimmt: ohne sie
// misst man hier eine Stylesheetgroesse, die es im Betrieb nie gibt.
$inhalt = (string) file_get_contents($file);
if (in_array($treffer[1], ['css', 'js', 'svg'], true) && str_contains($_SERVER['HTTP_ACCEPT_ENCODING'] ?? '', 'gzip')) {
$inhalt = (string) gzencode($inhalt, 6);
header('Content-Encoding: gzip');
header('Vary: Accept-Encoding');
}
header('Content-Length: '.strlen($inhalt));
echo $inhalt;
exit;
}
return false; // Let the built-in server deliver the static file. return false; // Let the built-in server deliver the static file.
} }
// Auch die Seiten selbst gehen komprimiert raus, wie auf einem Webserver mit mod_deflate.
ini_set('zlib.output_compression', '1');
ini_set('zlib.output_compression_level', '6');
require __DIR__.'/../public/index.php'; require __DIR__.'/../public/index.php';
+15
View File
@@ -1,3 +1,10 @@
parameters:
# Wo Schriften und Stylesheets liegen. Im Normalfall im oeffentlichen Ordner; ueber
# FONT_DIRECTORY und ASSET_DIRECTORY umstellbar - die Tests nutzen das, damit sie nicht in
# das Projekt schreiben, und eine Installation mit schreibgeschuetztem public/ ebenfalls.
kant.fonts_dir: '%kernel.project_dir%/public/assets/fonts'
kant.asset_dir: '%kernel.project_dir%/public/assets'
services: services:
_defaults: _defaults:
autowire: true autowire: true
@@ -15,6 +22,14 @@ services:
App\Content\MediaLibrary: App\Content\MediaLibrary:
arguments: arguments:
$directory: '%env(resolve:MEDIA_DIRECTORY)%' $directory: '%env(resolve:MEDIA_DIRECTORY)%'
# Die Schriften liegen im oeffentlichen Ordner: sie sollen vom Webserver direkt ausgeliefert
# werden, ohne PHP dazwischen - das ist der Punkt der Uebung.
App\Content\FontLibrary:
arguments:
$directory: '%env(default:kant.fonts_dir:FONT_DIRECTORY)%'
App\Content\StyleBundle:
arguments:
$assetDirectory: '%env(default:kant.asset_dir:ASSET_DIRECTORY)%'
App\Controller\FormController: App\Controller\FormController:
arguments: arguments:
$secret: '%kernel.secret%' $secret: '%kernel.secret%'
+28
View File
@@ -450,6 +450,34 @@ Umut: „Hast du auch schon SEO und Siteindex usw. übernommen?“ Der Abgleich
**Kein Sitemap-Index.** Das Original liefert einen (drei Teilkarten von Yoast), KantSites eine einzelne Datei mit allen Adressen. Ein Index ist erst ab 50.000 Adressen oder 50 MB vorgeschrieben; bei 35 Adressen wäre er eine Datei mehr ohne jeden Unterschied für eine Suchmaschine. Aufgeteilt wird, wenn es eine Website gibt, die das braucht – und dann automatisch, nicht als Einstellung. **Kein Sitemap-Index.** Das Original liefert einen (drei Teilkarten von Yoast), KantSites eine einzelne Datei mit allen Adressen. Ein Index ist erst ab 50.000 Adressen oder 50 MB vorgeschrieben; bei 35 Adressen wäre er eine Datei mehr ohne jeden Unterschied für eine Suchmaschine. Aufgeteilt wird, wenn es eine Website gibt, die das braucht – und dann automatisch, nicht als Einstellung.
## ADR 058 – Bilder in der Größe, in der sie erscheinen (13.09.2026)
Gemessen auf der nachgebauten Startseite: ein Telefon lud das Hintergrundbild des Hero mit **1920 Pixeln und 189 KB**. Zu sehen war davon nie mehr als ein Viertel. Die Ursache ist bauartbedingt: wer im Editor ein Hintergrundbild wählt, sitzt am Schreibtisch und setzt es unter „Desktop“; kleine Geräte erben diese Regel, und CSS kennt kein `srcset`.
Die Regeln stehen aber ohnehin je Gerät da. Der Kompilierer ergänzt deshalb für Tablet und Telefon dieselbe Bildwahl, die entstünde, wenn jemand sie einzeln gesetzt hätte – samt Verlauf und Abdunkelung, denn ohne sie stünde heller Text plötzlich auf einem ungedämpften Bild. Ein ausdrücklich gesetztes Bild wird nie ersetzt. **189 KB → 40 KB auf dem Telefon.**
Dasselbe Thema bei `<img>`: jedes Bild behauptete, 800 Pixel breit zu erscheinen – auch ein Logo, das 190 Pixel breit ist. Jetzt gilt: eine im Block gesetzte feste Breite ist die Antwort; sonst steht bei verzögert geladenen Bildern `sizes="auto"`, womit der Browser selbst nachmisst (Rückfallangaben dahinter für Safari). Das vorgezogene Bild behält die Annahme, weil seine Größe zum Zeitpunkt der Anfrage noch nicht feststeht.
Und die Eile lag auf der falschen Stelle: `fetchpriority="high"` bekam das erste Bild überhaupt – das Logo im Kopfbereich. Es bekommt es jetzt das erste Bild im **ersten Abschnitt des Inhalts**; Bilder im Kopfbereich laden weiterhin sofort, aber ohne Vorrang.
**Was nicht blieb:** ein Vorabladen des Hero-Hintergrunds (`<link rel="preload" as="image">`). Der Gedanke stimmt – ein Bild im Stylesheet wird spät entdeckt –, die Messung nicht: mobil **95 → 90**. Das Bild nimmt genau die Bandbreite weg, die der Text für den größten Aufbauschritt braucht. Wieder entfernt.
## ADR 057 – Schriften vom eigenen Server (13.09.2026)
Eine Schrift von `fonts.googleapis.com` kostet zweierlei. Technisch: der Browser holt erst das Stylesheet, findet darin die Adresse der Schriftdatei und holt dann diese – zwei fremde Verbindungen nacheinander, gemessen 820 ms, in denen keine Zeile in der richtigen Schrift steht. Rechtlich: ein Dritter erfährt die IP-Adresse jedes Besuchers, und dafür gibt es in Deutschland Abmahnungen (LG München I, 3 O 17493/20).
Deshalb: **einmal holen, selbst ausliefern.** Ein Knopf im Designbereich (und `cms:fonts:fetch` auf der Konsole) lädt die gewählten Schnitte als WOFF2 nach `public/assets/fonts`, schreibt die `@font-face`-Regeln daneben und meldet, was geladen wurde. Die Lizenz erlaubt das ausdrücklich – Google Fonts stehen unter OFL oder Apache 2.0.
Drei Entscheidungen dabei: **Kein neues Paket** – geholt wird mit curl, ersatzweise über `allow_url_fopen`, genau wie der Installer es tut; eine zusätzliche Abhängigkeit würde jedes Update auf einem Hosting ohne Composer scheitern lassen. **Nur Latein** – Kyrillisch und Vietnamesisch wären zwei Drittel der Bytes für nichts. **Nicht beim Seitenaufruf** – ein Besucher wartet nie auf einen fremden Server, auch nicht mittelbar. Liegen die Dateien nicht, bleibt es beim Verweis auf Google statt einer Seite ohne Schrift.
## ADR 056 – Vier Stylesheets werden eines, und der Webserver bekommt Anweisungen (13.09.2026)
Vor dem Livegang gemessen, nicht geschätzt. Jede der vier CSS-Dateien hält das Zeichnen der Seite auf, zusammen 46 KB. Sie sind getrennt, weil sie beim Schreiben Verschiedenes tun; beim Ausliefern ist das kein Argument. Ein Bündel trägt die Prüfsumme seines Inhalts im Namen, wird beim ersten Aufruf nach einer Änderung erzeugt und ohne Kommentare geschrieben. Minifiziert wird bewusst nur das: Kommentare und Einrückung. Ein Minifier, der Selektoren umbaut, spart Zeichen und kostet einen Abend Fehlersuche – in `calc(100% - 20px)` und `.a :hover` trägt ein Leerzeichen Bedeutung. Fehlt das Schreibrecht, werden die Quellen einzeln geladen: langsamer, aber heil.
Dazu eine `public/.htaccess`, die diesen Namen verdient: Kompression (mod_deflate/mod_brotli), ein Jahr Cache für alles, was seine Fassung oder Kennung in der Adresse trägt – Stylesheets und Skripte bekommen dafür `?v=` mit der Programmfassung –, und ein Riegel vor Dateien, die niemanden angehen. HTTPS-Umleitung und HSTS stehen auskommentiert mit dem Grund dabei: eine Umleitung ohne Zertifikat ist eine Endlosschleife, und HSTS lässt sich ein halbes Jahr lang nicht zurücknehmen.
**Der Entwicklungsserver sendet jetzt dieselben Kopfzeilen und komprimiert ebenso.** Ohne das misst man hier Größen, die es im Betrieb nie gibt: dieselbe Startseite ging von 90 auf 96 Punkte, ohne dass sich an ihr etwas geändert hätte.
## ADR 055 – Das Symbol der Website entsteht aus einem Bild der Mediathek (13.09.2026) ## ADR 055 – Das Symbol der Website entsteht aus einem Bild der Mediathek (13.09.2026)
Umut: „dann brauchen wir Favicon-Möglichkeit zum Festlegen“. Ein Favicon ist der einzige Teil der Gestaltung, den ein Besucher sieht, bevor die Seite geladen ist – und bisher war es das mitgelieferte Zeichen, für jede mit KantSites gebaute Website dasselbe. Umut: „dann brauchen wir Favicon-Möglichkeit zum Festlegen“. Ein Favicon ist der einzige Teil der Gestaltung, den ein Besucher sieht, bevor die Seite geladen ist – und bisher war es das mitgelieferte Zeichen, für jede mit KantSites gebaute Website dasselbe.
+6
View File
@@ -32,6 +32,12 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
- **Ladeverhalten gemessen statt vermutet** (ADR 039, Arbeitspaket M): ein Zähler liefert die Datenbankabfragen je Anfrage im Kopf `X-Kant-Queries` (außerhalb der Produktion). Die erste Messung zeigte 18 Abfragen auf der Startseite, weil Einstellungen und Theme je Aufrufer erneut gelesen wurden; beide merken sich ihren Stand jetzt für die Dauer einer Anfrage – **18 → 8**. Die Browserprüfung misst je Seite HTML, CSS, Anfragen, Skripte und Abfragen gegen Obergrenzen: Startseite 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 0 Skripte. - **Ladeverhalten gemessen statt vermutet** (ADR 039, Arbeitspaket M): ein Zähler liefert die Datenbankabfragen je Anfrage im Kopf `X-Kant-Queries` (außerhalb der Produktion). Die erste Messung zeigte 18 Abfragen auf der Startseite, weil Einstellungen und Theme je Aufrufer erneut gelesen wurden; beide merken sich ihren Stand jetzt für die Dauer einer Anfrage – **18 → 8**. Die Browserprüfung misst je Seite HTML, CSS, Anfragen, Skripte und Abfragen gegen Obergrenzen: Startseite 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 0 Skripte.
- **Das erste Bild einer Seite lädt sofort**, alles darunter weiterhin verzögert. Es ist meistens das größte Ding über der Falz. - **Das erste Bild einer Seite lädt sofort**, alles darunter weiterhin verzögert. Es ist meistens das größte Ding über der Falz.
- **Slider mit Punkten ohne Skript** (ADR 044): `::scroll-marker` zeichnet je Folie einen Punkt, der aktive ist erkennbar, die Rollleiste entfällt; ältere Browser behalten das wischbare Schiebefeld. Beitragskarten sind echte Karten (Bild 16:10, 18 px Ecken). Überschriften dürfen ein Wort hervorheben (`<mark>`, in Akzentfarbe). Behoben: die Theme-Schriftskala überschrieb die Größen in Karten und Aufruf-Bändern. - **Slider mit Punkten ohne Skript** (ADR 044): `::scroll-marker` zeichnet je Folie einen Punkt, der aktive ist erkennbar, die Rollleiste entfällt; ältere Browser behalten das wischbare Schiebefeld. Beitragskarten sind echte Karten (Bild 16:10, 18 px Ecken). Überschriften dürfen ein Wort hervorheben (`<mark>`, in Akzentfarbe). Behoben: die Theme-Schriftskala überschrieb die Größen in Karten und Aufruf-Bändern.
- **Getrennter Titel für Suchergebnisse** (Migration `20260913000100`): jede Seite und jeder Beitrag kann einen eigenen `<title>` tragen, unabhängig von der Überschrift auf der Seite. Leer heißt weiterhin: der Titel der Seite gilt. Er zählt auch für `og:title`.
- **Schriften kommen vom eigenen Server** (ADR 057): ein Knopf im Designbereich oder `php bin/console cms:fonts:fetch` lädt die gewählten Google-Schnitte als WOFF2 nach `public/assets/fonts` und schreibt die `@font-face`-Regeln daneben. Damit lädt keine Seite mehr etwas von Google – das spart zwei fremde Verbindungen nacheinander (gemessen 820 ms) und die datenschutzrechtliche Angreifbarkeit gleich mit. Ohne Dateien bleibt es beim bisherigen Verweis.
- **Ein Stylesheet statt vier, und eine `.htaccess`, die etwas tut** (ADR 056): gebündelt, ohne Kommentare, mit Prüfsumme im Dateinamen; Kompression, ein Jahr Cache für Dateien mit Fassung oder Kennung in der Adresse, Schutz vor dem Ausliefern interner Dateien, HTTPS-Umleitung und HSTS vorbereitet und auskommentiert. Der Entwicklungsserver sendet dieselben Kopfzeilen, damit lokale Messungen etwas bedeuten.
- **Bilder in der Größe, in der sie erscheinen** (ADR 058): ein Hintergrundbild, das nur für „Desktop“ gesetzt wurde, gilt auf dem Telefon in der Telefongröße (gemessen 189 KB → 40 KB); `<img>` nennt seine feste Breite, wenn es eine hat, und sonst `sizes="auto"`; `fetchpriority="high"` liegt auf dem ersten Bild des Inhalts statt auf dem Logo im Kopfbereich.
- **Lighthouse gegen den Nachbau von kant-it.de** (`docs/quality/lighthouse-kantit.json`, vier Adressen, je drei Läufe, Median): Barrierefreiheit, Best Practices, SEO und „agentic browsing“ **100 auf allen geprüften Seiten, mobil wie am Schreibtisch**. Performance: Projekte-, Beitrags- und Impressumsseite **100/100**, Startseite **98 mobil / 96 am Schreibtisch** – dort liegen vier Sliderbilder und ein großflächiges Hintergrundbild über der Falz. Gemessen im Produktionsmodus mit denselben Kopfzeilen, die `public/.htaccess` setzt; keine Felddaten und keine Messung auf echtem Hosting.
- **Verweise werden geprüft** (`npm run test:links`): folgt der Sitemap, sammelt jeden internen Verweis samt Bildern, Stylesheets und Hintergrundbildern und meldet jede Antwort ab 400. Im Nachbau 408 Adressen, kein toter Verweis.
- **Das Vorschaubild beim Teilen erscheint jetzt tatsächlich** (ADR 056): `og:image` wird aus dem Feld der Seite aufgelöst, ersatzweise aus dem Beitragsbild – vorher speicherte das Feld und bewirkte nichts, keine KantSites-Seite hatte ein Teilen-Bild. Dazu `og:site_name`, `og:locale`, `og:image:alt`, `twitter:card` (klein ohne Bild, groß mit) und das Bild im strukturierten Datensatz eines Beitrags. Archive haben jetzt ebenfalls Open-Graph-Angaben. - **Das Vorschaubild beim Teilen erscheint jetzt tatsächlich** (ADR 056): `og:image` wird aus dem Feld der Seite aufgelöst, ersatzweise aus dem Beitragsbild – vorher speicherte das Feld und bewirkte nichts, keine KantSites-Seite hatte ein Teilen-Bild. Dazu `og:site_name`, `og:locale`, `og:image:alt`, `twitter:card` (klein ohne Bild, groß mit) und das Bild im strukturierten Datensatz eines Beitrags. Archive haben jetzt ebenfalls Open-Graph-Angaben.
- **Eigene robots.txt-Zeilen: 200 statt 30, Leerzeilen bleiben** (ADR 056), damit sich Regeln je Bot in Blöcken schreiben lassen – etwa welche KI-Crawler lesen dürfen. Ein Sitemap-Index ist bewusst nicht gebaut: nötig wird er ab 50.000 Adressen. - **Eigene robots.txt-Zeilen: 200 statt 30, Leerzeilen bleiben** (ADR 056), damit sich Regeln je Bot in Blöcken schreiben lassen – etwa welche KI-Crawler lesen dürfen. Ein Sitemap-Index ist bewusst nicht gebaut: nötig wird er ab 50.000 Adressen.
- **Beiträge sind optional** (ADR 053): ohne einen einzigen veröffentlichten Beitrag antworten `/beitraege` und `/feed.xml` mit 404, der Feedverweis fehlt im Kopf, die Sitemap kennt kein Archiv, und ein Beitragslisten-Baustein zeigt öffentlich nichts – der Hinweis „hier erscheinen Beiträge“ bleibt nur im Editor. Ein Kategorie- oder Schlagwortarchiv verschwindet ebenso, sobald es nichts mehr zu zeigen hat; der Begriff selbst bleibt. Dabei behoben: die Beitragsliste fragte je Beitrag einzeln nach Bild und Begriffen – jetzt je eine Abfrage für die ganze Liste, Startseite des Nachbaus **22 → 16 Abfragen**. - **Beiträge sind optional** (ADR 053): ohne einen einzigen veröffentlichten Beitrag antworten `/beitraege` und `/feed.xml` mit 404, der Feedverweis fehlt im Kopf, die Sitemap kennt kein Archiv, und ein Beitragslisten-Baustein zeigt öffentlich nichts – der Hinweis „hier erscheinen Beiträge“ bleibt nur im Editor. Ein Kategorie- oder Schlagwortarchiv verschwindet ebenso, sobald es nichts mehr zu zeigen hat; der Begriff selbst bleibt. Dabei behoben: die Beitragsliste fragte je Beitrag einzeln nach Bild und Begriffen – jetzt je eine Abfrage für die ganze Liste, Startseite des Nachbaus **22 → 16 Abfragen**.
+106
View File
@@ -0,0 +1,106 @@
[
{
"path": "/",
"profile": "mobile",
"scores": {
"performance": 0.98,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
},
{
"path": "/",
"profile": "desktop",
"scores": {
"performance": 0.96,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
},
{
"path": "/projekte",
"profile": "mobile",
"scores": {
"performance": 1,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
},
{
"path": "/projekte",
"profile": "desktop",
"scores": {
"performance": 1,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
},
{
"path": "/simplescan",
"profile": "mobile",
"scores": {
"performance": 1,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
},
{
"path": "/simplescan",
"profile": "desktop",
"scores": {
"performance": 1,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
},
{
"path": "/impressum",
"profile": "mobile",
"scores": {
"performance": 1,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
},
{
"path": "/impressum",
"profile": "desktop",
"scores": {
"performance": 1,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
}
]
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260913000100 extends AbstractMigration
{
public function getDescription(): string
{
return 'A separate title for search results, independent of the page title.';
}
public function isTransactional(): bool
{
return false;
}
public function up(Schema $schema): void
{
// Der Titel in der Suche muss nicht der Titel der Seite sein: auf der Seite steht
// "Leistungen", im Suchergebnis hilft "IT-Leistungen für Mittelbaden – Kant-IT". NULL
// heisst: der Titel des Dokuments gilt, wie bisher.
$schema->getTable('page')->addColumn('seo_title', 'string', ['length' => 160, 'notnull' => false]);
}
public function down(Schema $schema): void
{
$this->throwIrreversibleMigrationException('Dropping this would silently discard every title written for search results. Restore a verified backup with matching code.');
}
}
+1
View File
@@ -7,6 +7,7 @@
"test": "node --test tests/*.test.mjs", "test": "node --test tests/*.test.mjs",
"test:editor": "node scripts/editor-check.mjs", "test:editor": "node scripts/editor-check.mjs",
"test:browser": "node scripts/browser-check.mjs", "test:browser": "node scripts/browser-check.mjs",
"test:links": "node scripts/link-check.mjs",
"audit": "node scripts/audit.mjs" "audit": "node scripts/audit.mjs"
}, },
"devDependencies": { "devDependencies": {
+60
View File
@@ -1,5 +1,65 @@
DirectoryIndex index.php DirectoryIndex index.php
Options -Indexes -MultiViews Options -Indexes -MultiViews
# Alles, was keine Datei ist, beantwortet PHP.
RewriteEngine On RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L] RewriteRule ^ index.php [L]
# HTTPS erzwingen. Auskommentiert, weil diese Datei auch in Installationen ohne Zertifikat
# liegt und eine Endlosschleife schlimmer waere als fehlendes HTTPS. Vor dem Livegang mit
# Zertifikat: die drei Zeilen einschalten - und erst danach die HSTS-Zeile weiter unten,
# denn HSTS laesst sich in einem Browser ein halbes Jahr lang nicht zuruecknehmen.
#RewriteCond %{HTTPS} !=on
#RewriteCond %{HTTP:X-Forwarded-Proto} !=https
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
# Uebertragen wird komprimiert. HTML, CSS, JavaScript und XML schrumpfen dabei auf etwa ein
# Viertel; Bilder und Schriften sind bereits komprimiert und bleiben aussen vor.
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css
AddOutputFilterByType DEFLATE application/javascript application/json application/xml
AddOutputFilterByType DEFLATE application/rss+xml image/svg+xml
</IfModule>
<IfModule mod_brotli.c>
AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript image/svg+xml
</IfModule>
# Wie lange ein Browser eine Datei behalten darf.
#
# Bilder und Schriften tragen ihre Kennung im Namen: aendert sich der Inhalt, aendert sich die
# Adresse. Sie duerfen deshalb ein Jahr bleiben. Stylesheets und Skripte tragen die Fassung als
# Parameter, also ebenfalls ein Jahr. HTML wird nie zwischengespeichert - eine veroeffentlichte
# Aenderung soll beim naechsten Aufruf sichtbar sein; die Seiten senden dafuer selbst no-store.
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/webp "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/svg+xml "access plus 1 year"
ExpiresByType font/woff2 "access plus 1 year"
ExpiresByType text/css "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
</IfModule>
<IfModule mod_headers.c>
<FilesMatch "\.(webp|png|jpe?g|gif|svg|woff2|css|js)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
# Schriften werden von derselben Adresse geladen; die Regel steht hier, damit sie auch dann
# gilt, wenn eine Installation die Dateien ueber ein CDN spiegelt.
<FilesMatch "\.woff2$">
Header set Access-Control-Allow-Origin "*"
</FilesMatch>
# Erst einschalten, wenn HTTPS steht und bleiben soll (siehe oben).
#Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
</IfModule>
# Nichts ausliefern, was nur intern gebraucht wird - falls jemand das Paket versehentlich
# vollstaendig in den oeffentlichen Ordner legt statt nur public/.
<FilesMatch "^\.|\.(env|db|sqlite|lock|md|yaml|yml|dist|log)$">
Require all denied
</FilesMatch>
# Eine Ausnahme mit Grund: der Editor liest die Liste der Gestaltungswerte als JSON.
<Files "design-tokens.json">
Require all granted
</Files>
+13 -4
View File
@@ -5,6 +5,9 @@ import { mkdir, writeFile } from 'node:fs/promises';
import net from 'node:net'; import net from 'node:net';
const origin = process.env.CMS_URL || 'http://127.0.0.1:8080'; const origin = process.env.CMS_URL || 'http://127.0.0.1:8080';
// Eine Startseite ist nicht die Website. Geprüft wird, was CMS_PATHS nennt - mehrere Adressen mit
// Komma getrennt; ohne Angabe die Startseite.
const paths = (process.env.CMS_PATHS || '/').split(',').map(p => p.trim()).filter(Boolean);
const output = process.env.CMS_AUDIT_DIR || 'reports/lighthouse'; const output = process.env.CMS_AUDIT_DIR || 'reports/lighthouse';
const preflight = await fetch(origin); const preflight = await fetch(origin);
if (!preflight.ok || /noindex/i.test(preflight.headers.get('x-robots-tag') || '')) { if (!preflight.ok || /noindex/i.test(preflight.headers.get('x-robots-tag') || '')) {
@@ -18,10 +21,12 @@ await new Promise(resolve => portProbe.close(resolve));
const chrome = await chromium.launch({ args: [`--remote-debugging-port=${port}`] }); const chrome = await chromium.launch({ args: [`--remote-debugging-port=${port}`] });
const summary = []; const summary = [];
try { try {
for (const path of paths) {
const label = '/' === path ? 'home' : path.replace(/^\//, '').replace(/\//g, '-');
for (const profile of ['mobile', 'desktop']) { for (const profile of ['mobile', 'desktop']) {
const runs = []; const runs = [];
for (let run = 1; run <= 3; run++) { for (let run = 1; run <= 3; run++) {
const result = await lighthouse(origin, { const result = await lighthouse(origin + ('/' === path ? '' : path), {
port, port,
output: 'json', output: 'json',
logLevel: 'error', logLevel: 'error',
@@ -30,18 +35,22 @@ try {
if (lhr.configSettings.formFactor !== profile) { if (lhr.configSettings.formFactor !== profile) {
throw new Error(`Requested ${profile}, measured ${lhr.configSettings.formFactor}`); throw new Error(`Requested ${profile}, measured ${lhr.configSettings.formFactor}`);
} }
await writeFile(`${output}/${profile}-${run}.json`, result.report); await writeFile(`${output}/${label}-${profile}-${run}.json`, result.report);
runs.push(lhr); runs.push(lhr);
console.log(profile, run, Object.fromEntries(Object.entries(lhr.categories).map(([key, value]) => [key, value.score]))); console.log(label, profile, run, Object.fromEntries(Object.entries(lhr.categories).map(([key, value]) => [key, value.score])));
} }
const scores = {}; const scores = {};
for (const category of Object.keys(runs[0].categories)) { for (const category of Object.keys(runs[0].categories)) {
const values = runs.map(r => r.categories[category].score).filter(v => v !== null).sort((a, b) => a - b); const values = runs.map(r => r.categories[category].score).filter(v => v !== null).sort((a, b) => a - b);
scores[category] = values.length ? values[Math.floor(values.length / 2)] : null; scores[category] = values.length ? values[Math.floor(values.length / 2)] : null;
} }
summary.push({ profile, scores, lighthouseVersion: runs[0].lighthouseVersion, settings: runs[0].configSettings, environment: runs[0].environment, note: 'Local prototype only. No customer-site acceptance or field-data claim.' }); summary.push({ path, profile, scores, lighthouseVersion: runs[0].lighthouseVersion, settings: runs[0].configSettings, environment: runs[0].environment, note: 'Local prototype only. No customer-site acceptance or field-data claim.' });
}
} }
await writeFile(`${output}/summary.json`, JSON.stringify(summary, null, 2)); await writeFile(`${output}/summary.json`, JSON.stringify(summary, null, 2));
for (const row of summary) {
console.log(`${row.path} ${row.profile}: ${Object.entries(row.scores).map(([k, v]) => k + ' ' + Math.round(v * 100)).join(', ')}`);
}
const passed = summary.every(r => r.scores.performance >= .95 && ['accessibility', 'best-practices', 'seo'].every(c => r.scores[c] === 1) && (r.scores['agentic-browsing'] === undefined || r.scores['agentic-browsing'] === 1)); const passed = summary.every(r => r.scores.performance >= .95 && ['accessibility', 'best-practices', 'seo'].every(c => r.scores[c] === 1) && (r.scores['agentic-browsing'] === undefined || r.scores['agentic-browsing'] === 1));
if (!passed) process.exitCode = 1; if (!passed) process.exitCode = 1;
} finally { } finally {
+102
View File
@@ -0,0 +1,102 @@
/*
* Prüft jeden internen Verweis einer Website auf seine Antwort.
*
* Ein Link, der ins Leere zeigt, ist der Fehler, den niemand bemerkt: Tests prüfen, was der Code
* erzeugt, nicht ob die Adresse dahinter existiert. Im Nachbau von kant-it.de zeigten vier
* Verweise im Hero-Slider auf Adressen, die es nie gab - gefunden hat das kein Test, sondern ein
* Durchlauf wie dieser.
*
* Ausgangspunkt ist die Sitemap, danach wird jeder Seite gefolgt, die von dort erreichbar ist.
* Geprüft werden Seiten, Bilder, Stylesheets und Skripte; externe Adressen werden gezählt, aber
* nicht abgerufen - ob fremde Server antworten, ist nicht die Frage dieser Prüfung.
*
* CMS_URL=http://127.0.0.1:8080 npm run test:links
*/
import { chromium } from 'playwright';
import assert from 'node:assert/strict';
import { mkdir, writeFile } from 'node:fs/promises';
const origin = (process.env.CMS_URL || 'http://127.0.0.1:8080').replace(/\/$/, '');
const browser = await chromium.launch();
const geprueft = new Map();
const extern = new Set();
const kaputt = [];
const quellen = new Map();
try {
const context = await browser.newContext();
const page = await context.newPage();
const antwort = async (url) => {
if (!geprueft.has(url)) {
const response = await context.request.get(url, { maxRedirects: 5 });
geprueft.set(url, response.status());
}
return geprueft.get(url);
};
const sitemap = await context.request.get(origin + '/sitemap.xml');
assert.equal(sitemap.status(), 200, 'Ohne Sitemap fehlt der Ausgangspunkt.');
const seiten = [...(await sitemap.text()).matchAll(/<loc>([^<]+)<\/loc>/g)]
.map(treffer => new URL(treffer[1]).pathname);
console.log(`Sitemap: ${seiten.length} Adresse(n).`);
const besucht = new Set();
const warteschlange = ['/', ...seiten];
while (warteschlange.length > 0) {
const pfad = warteschlange.shift();
if (besucht.has(pfad)) continue;
besucht.add(pfad);
const response = await page.goto(origin + pfad, { waitUntil: 'domcontentloaded' });
if (200 !== response.status()) {
kaputt.push({ status: response.status(), url: pfad, von: 'Sitemap' });
continue;
}
// Verweise und eingebundene Dateien derselben Website.
const verweise = await page.evaluate(() => ({
links: [...document.querySelectorAll('a[href]')].map(a => a.getAttribute('href')),
dateien: [
...[...document.querySelectorAll('img[src]')].map(i => i.getAttribute('src')),
...[...document.querySelectorAll('img[srcset]')].flatMap(i => i.getAttribute('srcset').split(',').map(teil => teil.trim().split(/\s+/)[0])),
...[...document.querySelectorAll('link[rel="stylesheet"], link[rel="preload"]')].map(l => l.getAttribute('href')),
...[...document.querySelectorAll('script[src]')].map(s => s.getAttribute('src')),
...[...document.querySelectorAll('[style*="url("]')].flatMap(e => [...e.getAttribute('style').matchAll(/url\(["']?([^"')]+)/g)].map(m => m[1])),
],
stile: [...document.querySelectorAll('style')].flatMap(s => [...s.textContent.matchAll(/url\(["']?([^"')]+)/g)].map(m => m[1])),
}));
for (const roh of [...verweise.links, ...verweise.dateien, ...verweise.stile]) {
if (!roh || roh.startsWith('data:') || roh.startsWith('#') || roh.startsWith('mailto:') || roh.startsWith('tel:')) continue;
const ziel = new URL(roh, origin + pfad);
if (ziel.origin !== origin) {
extern.add(ziel.origin);
continue;
}
const adresse = ziel.pathname + ziel.search;
const status = await antwort(origin + adresse);
if (status >= 400) {
kaputt.push({ status, url: adresse, von: pfad });
}
// Eigenen Seiten folgen wir weiter; Dateien nicht.
if (verweise.links.includes(roh) && !/\.[a-z0-9]{2,5}(\?|$)/i.test(ziel.pathname) && !besucht.has(ziel.pathname)) {
warteschlange.push(ziel.pathname);
quellen.set(ziel.pathname, pfad);
}
}
}
await mkdir('reports', { recursive: true });
await writeFile('reports/links.json', JSON.stringify({ origin, geprueft: geprueft.size, seiten: besucht.size, extern: [...extern], kaputt }, null, 2));
console.log(`${besucht.size} Seite(n), ${geprueft.size} Adresse(n) geprüft, ${extern.size} fremde Quelle(n): ${[...extern].join(', ') || 'keine'}`);
if (kaputt.length > 0) {
for (const eintrag of kaputt) {
console.error(` ${eintrag.status} ${eintrag.url} (verlinkt auf ${eintrag.von})`);
}
throw new Error(`${kaputt.length} Verweis(e) zeigen ins Leere.`);
}
console.log('Kein toter Verweis.');
} finally {
await browser.close();
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Command;
use App\Content\FontLibrary;
use App\Content\Theme;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Holt die Schriften des Designs auf den eigenen Server.
*
* Dasselbe wie der Knopf im Designbereich, für Installationen mit Konsolenzugang - und für den
* Fall, dass eine frisch aufgesetzte Website die Dateien noch nicht hat.
*/
#[AsCommand(name: 'cms:fonts:fetch', description: 'Lädt die Google-Schriften des Designs nach public/assets/fonts.')]
final class FetchFontsCommand extends Command
{
public function __construct(private Theme $theme, private FontLibrary $fonts)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
$result = $this->fonts->fetch($this->theme->load());
} catch (\RuntimeException $error) {
$io->error($error->getMessage());
return Command::FAILURE;
}
$io->success(sprintf(
'%d Datei(en) geladen, %d KB insgesamt: %s. Die Seiten laden jetzt nichts mehr von Google.',
$result['files'],
(int) round($result['bytes'] / 1024),
implode(', ', $result['families']),
));
return Command::SUCCESS;
}
}
+272
View File
@@ -0,0 +1,272 @@
<?php
namespace App\Content;
/**
* Schriften, die die Website selbst ausliefert.
*
* Eine Schrift von fonts.googleapis.com kostet zweierlei. Technisch: der Browser muss erst das
* Stylesheet holen, darin die Adresse der Schriftdatei finden und dann diese holen - zwei fremde
* Verbindungen hintereinander, bevor die erste Zeile in der richtigen Schrift steht. Rechtlich:
* dabei erfährt ein Dritter die IP-Adresse jedes Besuchers, und dafür gibt es in Deutschland
* Abmahnungen (LG München I, 3 O 17493/20).
*
* Beides verschwindet, wenn die Dateien auf dem eigenen Server liegen. Die Lizenz erlaubt das:
* die Schriften bei Google Fonts stehen unter der SIL Open Font License oder Apache 2.0.
*
* Heruntergeladen wird einmal auf Zuruf, nicht bei einem Seitenaufruf - ein Besucher soll nie auf
* einen fremden Server warten, auch nicht mittelbar.
*/
final class FontLibrary
{
/**
* Der Browser, als den wir uns ausgeben.
*
* Google liefert je nach Browser ein anderes Stylesheet: alten Browsern TTF, neuen WOFF2. Ohne
* diese Kennung bekämen wir Dateien, die viermal so groß sind.
*/
private const AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
/** Zeichenbereiche, die eine deutschsprachige Website braucht. Kyrillisch und Vietnamesisch nicht. */
private const SUBSETS = ['latin', 'latin-ext'];
public function __construct(private string $directory)
{
}
/** Liegen für dieses Theme fertige Dateien bereit? */
public function hasLocal(array $theme): bool
{
return [] === $this->missing($theme) && is_file($this->directory.'/fonts.css');
}
/**
* Die Schriften des Themes, für die noch keine lokale Datei existiert.
*
* @return list<string> Beschriftungen wie „Lato 700“
*/
public function missing(array $theme): array
{
$missing = [];
foreach ($this->wanted($theme) as $family => $weights) {
foreach ($weights as $weight) {
if ([] === glob($this->directory.'/'.$this->slug($family).'-'.$weight.'-*.woff2')) {
$missing[] = $family.' '.$weight;
}
}
}
return $missing;
}
/**
* Holt alle Schriften des Themes und schreibt fonts.css daneben.
*
* @return array{files: int, bytes: int, families: list<string>}
*/
public function fetch(array $theme): array
{
$wanted = $this->wanted($theme);
if ([] === $wanted) {
throw new \RuntimeException('Dieses Design nutzt keine Schrift von Google Fonts.');
}
if (!is_dir($this->directory) && !mkdir($this->directory, 0775, true) && !is_dir($this->directory)) {
throw new \RuntimeException('Der Schriftenordner ist nicht beschreibbar: '.$this->directory);
}
$rules = [];
$files = 0;
$bytes = 0;
foreach ($wanted as $family => $weights) {
$css = $this->stylesheet($family, $weights);
foreach ($this->parse($css, $family) as $face) {
$name = $this->slug($family).'-'.$face['weight'].'-'.$face['subset'].'.woff2';
$path = $this->directory.'/'.$name;
if (!is_file($path)) {
file_put_contents($path, $this->download($face['url']));
++$files;
}
$bytes += (int) filesize($path);
$rules[] = sprintf(
"/* %s */\n@font-face{font-family:'%s';font-style:%s;font-weight:%d;font-display:swap;src:url(/assets/fonts/%s) format('woff2');unicode-range:%s}",
$face['subset'],
$family,
$face['style'],
$face['weight'],
$name,
$face['range'],
);
}
}
if ([] === $rules) {
throw new \RuntimeException('Google Fonts hat keine passende Schriftdatei geliefert.');
}
file_put_contents($this->directory.'/fonts.css', implode("\n", $rules)."\n");
return ['files' => $files, 'bytes' => $bytes, 'families' => array_keys($wanted)];
}
/**
* Die Dateien, die für den ersten Eindruck vorab geholt werden sollen.
*
* Nur der lateinische Grundbereich und nur die Schnitte, die im Fließtext und in
* Überschriften stehen - ein Vorabholen, das alles holt, verzögert genau das, was es
* beschleunigen soll.
*
* @return list<string>
*/
public function preload(array $theme): array
{
$preload = [];
foreach ($this->wanted($theme) as $family => $weights) {
foreach (array_slice($weights, 0, 2) as $weight) {
$name = $this->slug($family).'-'.$weight.'-latin.woff2';
if (is_file($this->directory.'/'.$name)) {
$preload[] = '/assets/fonts/'.$name;
}
}
}
return $preload;
}
/**
* Welche Familien und Schnitte das Theme von Google bezieht.
*
* @return array<string, list<int>>
*/
private function wanted(array $theme): array
{
$wanted = [];
foreach ($theme['fonts'] ?? [] as $font) {
if ('google' !== ($font['source'] ?? '') || '' === ($font['family'] ?? '')) {
continue;
}
$weights = array_map(intval(...), $font['weights'] ?? [400]);
sort($weights);
$wanted[$font['family']] = array_values(array_unique([...($wanted[$font['family']] ?? []), ...$weights]));
sort($wanted[$font['family']]);
}
return $wanted;
}
private function stylesheet(string $family, array $weights): string
{
$url = sprintf(
'https://fonts.googleapis.com/css2?family=%s:wght@%s&display=swap',
rawurlencode($family),
implode(';', $weights),
);
$css = $this->get($url);
if (!str_contains($css, '@font-face')) {
throw new \RuntimeException(sprintf('Google Fonts liefert für „%s“ keine Schrift. Schreibt sich die Familie genau so?', $family));
}
return $css;
}
private function download(string $url): string
{
if (!str_starts_with($url, 'https://fonts.gstatic.com/')) {
throw new \RuntimeException('Die Schriftdatei käme von einer fremden Adresse: '.$url);
}
$body = $this->get($url);
// Eine WOFF2-Datei beginnt mit "wOF2". Was anderes ist, wird nicht gespeichert.
if (!str_starts_with($body, 'wOF2')) {
throw new \RuntimeException('Die geladene Datei ist keine WOFF2-Schrift.');
}
return $body;
}
/**
* Liest die @font-face-Blöcke eines Google-Stylesheets.
*
* Der Zeichenbereich steht in einem Kommentar vor jedem Block („/* latin *\/“); danach wird
* ausgewählt, was eine deutschsprachige Website braucht.
*
* @return list<array{subset: string, weight: int, style: string, url: string, range: string}>
*/
public function parse(string $css, string $family): array
{
$faces = [];
preg_match_all('#/\*\s*([a-z0-9-]+)\s*\*/\s*@font-face\s*\{(.+?)\}#s', $css, $matches, PREG_SET_ORDER);
foreach ($matches as [, $subset, $block]) {
if (!in_array($subset, self::SUBSETS, true)) {
continue;
}
if (1 !== preg_match('#src:\s*url\(([^)]+)\)#', $block, $src)) {
continue;
}
preg_match('#font-weight:\s*(\d+)#', $block, $weight);
preg_match('#font-style:\s*([a-z]+)#', $block, $style);
preg_match('#unicode-range:\s*([^;}]+)#', $block, $range);
$faces[] = [
'subset' => $subset,
'weight' => (int) ($weight[1] ?? 400),
'style' => $style[1] ?? 'normal',
'url' => trim($src[1], '\'" '),
'range' => trim($range[1] ?? 'U+0000-00FF'),
'family' => $family,
];
}
return $faces;
}
/**
* Holt eine HTTPS-Adresse - erst über curl, sonst über den Dateizeiger.
*
* Dieselben zwei Wege wie im Installer, aus demselben Grund: auf einem Webhosting ist mal das
* eine da und mal das andere. Eine weitere Abhängigkeit dafür ins Projekt zu holen, würde
* jedes Update auf einem Hosting ohne Composer scheitern lassen.
*/
private function get(string $url): string
{
if (!str_starts_with($url, 'https://')) {
throw new \RuntimeException('Geladen wird nur über HTTPS.');
}
if (function_exists('curl_init')) {
$curl = curl_init($url);
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_TIMEOUT => 60,
CURLOPT_USERAGENT => self::AGENT,
]);
$body = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$error = curl_error($curl);
curl_close($curl);
if (!is_string($body) || 200 !== $status) {
throw new \RuntimeException(sprintf('Google Fonts antwortet mit %d%s.', $status, '' === $error ? '' : ' ('.$error.')'));
}
return $body;
}
if (!filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL)) {
throw new \RuntimeException('Dieser Server darf nicht nach außen verbinden (weder curl noch allow_url_fopen). Die Schriftdateien müssen dann von Hand nach public/assets/fonts/ gelegt werden.');
}
$context = stream_context_create([
'http' => ['timeout' => 60, 'follow_location' => 1, 'max_redirects' => 3, 'header' => 'User-Agent: '.self::AGENT],
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
]);
$body = @file_get_contents($url, false, $context);
if (!is_string($body)) {
throw new \RuntimeException('Google Fonts ist von diesem Server aus nicht erreichbar.');
}
return $body;
}
private function slug(string $family): string
{
return strtolower(preg_replace('/[^a-z0-9]+/i', '-', $family) ?? 'schrift');
}
}
+2 -2
View File
@@ -368,7 +368,7 @@ final class PageRepository
public function findPublished(string $path, string $locale = 'de'): ?array public function findPublished(string $path, string $locale = 'de'): ?array
{ {
$page = $this->db->fetchAssociative( $page = $this->db->fetchAssociative(
'SELECT p.id, p.slug, p.path, p.type, p.locale, p.author, p.excerpt, p.published_at, p.robots, p.og_media_id, p.featured_media_id, p.header_area, p.footer_area, r.document, r.id AS revision_id, w.name AS website_name FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id JOIN website w ON w.id = p.website_id WHERE p.path = ? AND p.locale = ? AND p.website_id = ? AND p.deleted_at IS NULL', 'SELECT p.id, p.slug, p.path, p.type, p.locale, p.author, p.excerpt, p.published_at, p.robots, p.seo_title, p.og_media_id, p.featured_media_id, p.header_area, p.footer_area, r.document, r.id AS revision_id, w.name AS website_name FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id JOIN website w ON w.id = p.website_id WHERE p.path = ? AND p.locale = ? AND p.website_id = ? AND p.deleted_at IS NULL',
[$path, $locale, 'default'], [$path, $locale, 'default'],
); );
if (false === $page) { if (false === $page) {
@@ -417,7 +417,7 @@ final class PageRepository
public function listForEditor(): array public function listForEditor(): array
{ {
return $this->db->fetchAllAssociative('SELECT p.id, p.slug, p.path, p.parent_id, p.type, p.locale, p.version, p.published_revision_id, p.draft_revision_id, p.excerpt, p.author, p.published_at, p.featured_media_id, p.robots, p.og_media_id, p.publish_at, p.header_area, p.footer_area, r.document FROM page p LEFT JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.deleted_at IS NULL ORDER BY p.path', ['default']); return $this->db->fetchAllAssociative('SELECT p.id, p.slug, p.path, p.parent_id, p.type, p.locale, p.version, p.published_revision_id, p.draft_revision_id, p.excerpt, p.author, p.published_at, p.featured_media_id, p.robots, p.seo_title, p.og_media_id, p.publish_at, p.header_area, p.footer_area, r.document FROM page p LEFT JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.deleted_at IS NULL ORDER BY p.path', ['default']);
} }
public function findForEditor(string $id): ?array public function findForEditor(string $id): ?array
+9 -2
View File
@@ -46,20 +46,27 @@ final class PostRepository
throw new InvalidPage(sprintf('Das gewählte %s gibt es nicht.', $label)); throw new InvalidPage(sprintf('Das gewählte %s gibt es nicht.', $label));
} }
} }
// Der Titel fuer die Suche. Leer heisst: der Titel der Seite gilt. 160 Zeichen sind die
// Grenze des Feldes; Google zeigt etwa 60 - der Hinweis dazu steht im Formular.
$seoTitle = trim((string) ($values['seoTitle'] ?? ''));
if (mb_strlen($seoTitle) > 160) {
throw new InvalidPage('Der Titel für Suchergebnisse darf höchstens 160 Zeichen lang sein.');
}
$robots = (string) ($values['robots'] ?? 'index'); $robots = (string) ($values['robots'] ?? 'index');
if (!in_array($robots, ['index', 'noindex'], true)) { if (!in_array($robots, ['index', 'noindex'], true)) {
throw new InvalidPage('Für die Indexierung sind nur „index“ und „noindex“ vorgesehen.'); throw new InvalidPage('Für die Indexierung sind nur „index“ und „noindex“ vorgesehen.');
} }
$this->db->transactional(function () use ($pageId, $excerpt, $author, $date, $mediaId, $ogMediaId, $robots, $categories, $tags, $expectedVersion): void { $this->db->transactional(function () use ($pageId, $excerpt, $author, $date, $mediaId, $ogMediaId, $robots, $seoTitle, $categories, $tags, $expectedVersion): void {
$changed = $this->db->executeStatement( $changed = $this->db->executeStatement(
'UPDATE page SET excerpt = ?, author = ?, featured_media_id = ?, published_at = ?, robots = ?, og_media_id = ?, version = version + 1 WHERE id = ? AND version = ?', 'UPDATE page SET excerpt = ?, author = ?, featured_media_id = ?, published_at = ?, robots = ?, seo_title = ?, og_media_id = ?, version = version + 1 WHERE id = ? AND version = ?',
[ [
'' === $excerpt ? null : $excerpt, '' === $excerpt ? null : $excerpt,
'' === $author ? null : $author, '' === $author ? null : $author,
'' === $mediaId ? null : $mediaId, '' === $mediaId ? null : $mediaId,
'' === $date ? null : str_replace('T', ' ', 10 === strlen($date) ? $date.' 00:00' : $date).':00', '' === $date ? null : str_replace('T', ' ', 10 === strlen($date) ? $date.' 00:00' : $date).':00',
$robots, $robots,
'' === $seoTitle ? null : $seoTitle,
'' === $ogMediaId ? null : $ogMediaId, '' === $ogMediaId ? null : $ogMediaId,
$pageId, $pageId,
$expectedVersion, $expectedVersion,
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace App\Content;
/**
* Die Stylesheets einer öffentlichen Seite als eine Datei.
*
* Getrennte Dateien sind beim Schreiben richtig – `site.css` das Grundgerüst, `design.css` die
* Gestaltungswerte, `enhancements.css` die Bewegung, `fonts/fonts.css` die Schriften. Beim
* Ausliefern sind sie vier Anfragen, die alle vier das Zeichnen der Seite aufhalten, und auf einer
* Mobilfunkverbindung ist jede davon eine eigene Wartezeit.
*
* Zusammengelegt wird deshalb einmal in eine Datei, deren Name die Prüfsumme des Inhalts trägt:
* ändert sich eine Quelle, ändert sich der Name, und kein Browser zeigt altes Aussehen. Entfernt
* werden dabei die Kommentare – sie erklären den Quelltext, nicht den Browser.
*
* Schlägt das Schreiben fehl (kein Schreibrecht im Ordner), bleibt es bei den Einzeldateien: eine
* Website ohne Bündel ist langsamer, eine Website ohne Stylesheet ist kaputt.
*/
final class StyleBundle
{
/** Die Reihenfolge ist die Reihenfolge im Kopf der Seite - später gewinnt. */
private const SOURCES = ['fonts/fonts.css', 'site.css', 'design.css', 'enhancements.css'];
public function __construct(private string $assetDirectory)
{
}
/**
* Die Adresse des Bündels, oder null wenn es keines gibt.
*
* Erzeugt wird beim ersten Aufruf nach einer Änderung. Das kostet einen Aufruf lang ein paar
* Millisekunden und spart danach jedem Besucher drei Anfragen.
*/
public function url(bool $withFonts = true): ?string
{
$sources = $this->sources($withFonts);
if ([] === $sources) {
return null;
}
$name = 'kant-'.substr(hash('xxh128', implode('|', array_map(
static fn (string $file): string => $file.':'.filemtime($file).':'.filesize($file),
$sources,
))), 0, 16).'.css';
$path = $this->assetDirectory.'/build/'.$name;
if (!is_file($path) && !$this->write($path, $sources)) {
return null;
}
return '/assets/build/'.$name;
}
/** @return list<string> */
private function sources(bool $withFonts): array
{
$found = [];
foreach (self::SOURCES as $source) {
if ('fonts/fonts.css' === $source && !$withFonts) {
continue;
}
$path = $this->assetDirectory.'/'.$source;
if (is_file($path)) {
$found[] = $path;
}
}
// Ohne das Grundgerüst kein Bündel: dann stimmt etwas anderes nicht.
return in_array($this->assetDirectory.'/site.css', $found, true) ? $found : [];
}
/** @param list<string> $sources */
private function write(string $path, array $sources): bool
{
$directory = dirname($path);
if (!is_dir($directory) && !@mkdir($directory, 0775, true) && !is_dir($directory)) {
return false;
}
$css = '';
foreach ($sources as $source) {
$css .= $this->minify((string) file_get_contents($source))."\n";
}
// Erst daneben schreiben, dann umbenennen: ein zweiter Aufruf im selben Moment soll nie
// eine halb geschriebene Datei ausliefern.
$temporary = $path.'.'.bin2hex(random_bytes(4));
if (false === @file_put_contents($temporary, $css) || !@rename($temporary, $path)) {
@unlink($temporary);
return false;
}
$this->clean($directory, basename($path));
return true;
}
/**
* Kommentare raus, Einrückung raus - sonst nichts.
*
* Kein Umbau von Selektoren, kein Zusammenfassen von Regeln: ein Minifier, der die Bedeutung
* ändert, spart ein paar Zeichen und kostet einen Abend Fehlersuche. Leerzeichen innerhalb
* einer Zeile bleiben deshalb stehen – in `calc(100% - 20px)` und in `.a :hover` tragen sie
* Bedeutung.
*/
private function minify(string $css): string
{
$withoutComments = preg_replace('#/\*(?!!).*?\*/#s', '', $css) ?? $css;
$lines = [];
foreach (preg_split('/\R/', $withoutComments) ?: [] as $line) {
$line = trim($line);
if ('' !== $line) {
$lines[] = $line;
}
}
return implode("\n", $lines);
}
/** Ältere Bündel wegräumen; sie werden von niemandem mehr angefragt. */
private function clean(string $directory, string $keep): void
{
foreach (glob($directory.'/kant-*.css') ?: [] as $old) {
if (basename($old) !== $keep && filemtime($old) < time() - 3600) {
@unlink($old);
}
}
}
}
+34
View File
@@ -101,6 +101,7 @@ final class StyleCompiler
private function compileBlock(string $id, array $style): string private function compileBlock(string $id, array $style): string
{ {
$css = ''; $css = '';
$style = $this->scaleBackground($style);
foreach (['desktop', 'tablet', 'mobile'] as $device) { foreach (['desktop', 'tablet', 'mobile'] as $device) {
$values = $style[$device] ?? null; $values = $style[$device] ?? null;
if (!is_array($values)) { if (!is_array($values)) {
@@ -322,6 +323,39 @@ final class StyleCompiler
* Mobile takes the smallest that is at least 960 wide, tablet the one around 1600, desktop the * Mobile takes the smallest that is at least 960 wide, tablet the one around 1600, desktop the
* largest. A picture that only has small variants simply gives what it has. * largest. A picture that only has small variants simply gives what it has.
*/ */
/**
* Ein Hintergrundbild gilt auch dann in Gerätegrößen, wenn es nur einmal gesetzt wurde.
*
* Wer im Editor ein Hintergrundbild wählt, setzt es üblicherweise am Schreibtisch - also unter
* „Desktop“. Ohne eigene Regel für die kleineren Geräte erbt ein Telefon diese Regel und lädt
* die größte Variante: gemessen 189 KB statt 40 KB für ein Bild, das nie in dieser Auflösung
* zu sehen ist. CSS kennt kein srcset, aber die Regeln stehen ohnehin je Gerät da - also
* bekommt jedes Gerät dieselbe Bildwahl, die es bekäme, wenn jemand sie einzeln gesetzt hätte.
*
* Ein ausdrücklich gesetztes Bild wird nie ersetzt; ergänzt wird nur, wo nichts steht.
*/
private function scaleBackground(array $style): array
{
$image = $style['desktop']['backgroundImage'] ?? null;
if (null === $image) {
return $style;
}
foreach (['tablet', 'mobile'] as $device) {
if (!isset($style[$device]['backgroundImage'])) {
$style[$device]['backgroundImage'] = $image;
// Verlauf und Überlagerung gehören zur selben Regel: ohne sie stünde auf dem
// Telefon plötzlich ein unabgedunkeltes Bild hinter hellem Text.
foreach (['gradientFrom', 'gradientTo', 'gradientAngle', 'overlayColor', 'overlayOpacity', 'backgroundSize', 'backgroundPosition', 'backgroundRepeat', 'backgroundAttachment'] as $begleiter) {
if (isset($style['desktop'][$begleiter]) && !isset($style[$device][$begleiter])) {
$style[$device][$begleiter] = $style['desktop'][$begleiter];
}
}
}
}
return $style;
}
private function variantFor(array $variants, string $device): array private function variantFor(array $variants, string $device): array
{ {
$wanted = ['mobile' => 960, 'tablet' => 1600, 'desktop' => 4000][$device] ?? 4000; $wanted = ['mobile' => 960, 'tablet' => 1600, 'desktop' => 4000][$device] ?? 4000;
+1
View File
@@ -145,6 +145,7 @@ final class EditorController extends AbstractController
'publishedAt' => $request->request->getString('publishedAt'), 'publishedAt' => $request->request->getString('publishedAt'),
'featuredMediaId' => $request->request->getString('featuredMediaId'), 'featuredMediaId' => $request->request->getString('featuredMediaId'),
'ogMediaId' => $request->request->getString('ogMediaId'), 'ogMediaId' => $request->request->getString('ogMediaId'),
'seoTitle' => $request->request->getString('seoTitle'),
'robots' => $request->request->getString('robots', 'index'), 'robots' => $request->request->getString('robots', 'index'),
], ],
$terms($request->request->getString('categories')), $terms($request->request->getString('categories')),
+17 -3
View File
@@ -4,6 +4,7 @@ namespace App\Controller;
use App\Content\Consent; use App\Content\Consent;
use App\Content\CustomCode; use App\Content\CustomCode;
use App\Content\FontLibrary;
use App\Content\FormRepository; use App\Content\FormRepository;
use App\Content\MediaLibrary; use App\Content\MediaLibrary;
use App\Content\MenuRepository; use App\Content\MenuRepository;
@@ -12,6 +13,7 @@ use App\Content\PartialRepository;
use App\Content\PostRepository; use App\Content\PostRepository;
use App\Content\RedirectRepository; use App\Content\RedirectRepository;
use App\Content\SiteSettings; use App\Content\SiteSettings;
use App\Content\StyleBundle;
use App\Content\StyleCompiler; use App\Content\StyleCompiler;
use App\Content\Theme; use App\Content\Theme;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -21,7 +23,7 @@ use Symfony\Component\Routing\Attribute\Route;
final class PageController extends AbstractController final class PageController extends AbstractController
{ {
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private string $secret, private string $siteOrigin) public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private string $secret, private string $siteOrigin)
{ {
} }
@@ -42,6 +44,7 @@ final class PageController extends AbstractController
$page = $this->pages->findPublished($path); $page = $this->pages->findPublished($path);
$styles = ''; $styles = '';
$googleFonts = null; $googleFonts = null;
$localFonts = [];
// The page decides which header and footer it gets - including none. // The page decides which header and footer it gets - including none.
$areas = $this->areas($page); $areas = $this->areas($page);
if (null !== $page) { if (null !== $page) {
@@ -52,7 +55,13 @@ final class PageController extends AbstractController
foreach ([...array_values($areas), ...array_values($sections)] as $extra) { foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra); $styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
} }
$googleFonts = $this->theme->googleFontsUrl($theme); // Liegen die Schriften auf dem eigenen Server, wird nichts von Google geholt - weder
// die Datei noch die IP-Adresse des Besuchers.
if ($this->fonts->hasLocal($theme)) {
$localFonts = $this->fonts->preload($theme);
} else {
$googleFonts = $this->theme->googleFontsUrl($theme);
}
} }
if (null === $page) { if (null === $page) {
// An address that moved or was deliberately removed answers before the 404 does. // An address that moved or was deliberately removed answers before the 404 does.
@@ -87,9 +96,14 @@ final class PageController extends AbstractController
'areas' => $areas, 'areas' => $areas,
'sections' => $sections, 'sections' => $sections,
// Rendered before the page content, so the header decides first. // Rendered before the page content, so the header decides first.
'eagerImage' => $this->firstImage($areas['header'] ?? [], $page['document']), // Das grosse Bild ueber der Falz - es soll zuerst geladen werden. Gesucht wird nur
// im ersten Abschnitt des Inhalts: ein Bild weiter unten waere die falsche
// Eile, und das Logo im Kopfbereich ist klein und laedt ohnehin sofort.
'eagerImage' => $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]),
'pageStyles' => $styles, 'pageStyles' => $styles,
'googleFonts' => $googleFonts, 'googleFonts' => $googleFonts,
'localFonts' => $localFonts,
'styleBundle' => $this->bundle->url([] !== $localFonts),
]); ]);
} }
+42 -2
View File
@@ -2,6 +2,7 @@
namespace App\Controller; namespace App\Controller;
use App\Content\FontLibrary;
use App\Content\Theme; use App\Content\Theme;
use App\Domain\InvalidPage; use App\Domain\InvalidPage;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -11,20 +12,59 @@ use Symfony\Component\Routing\Attribute\Route;
final class ThemeController extends AbstractController final class ThemeController extends AbstractController
{ {
public function __construct(private Theme $theme) public function __construct(private Theme $theme, private FontLibrary $fonts)
{ {
} }
#[Route('/admin/theme', name: 'admin_theme', methods: ['GET'])] #[Route('/admin/theme', name: 'admin_theme', methods: ['GET'])]
public function index(): Response public function index(): Response
{ {
$theme = $this->theme->load();
return $this->render('admin/theme.html.twig', [ return $this->render('admin/theme.html.twig', [
'theme' => $this->theme->load(), 'theme' => $theme,
'fallbacks' => Theme::FALLBACKS, 'fallbacks' => Theme::FALLBACKS,
'weights' => Theme::WEIGHTS, 'weights' => Theme::WEIGHTS,
'fontsLocal' => $this->fonts->hasLocal($theme),
'fontsMissing' => $this->fonts->missing($theme),
]); ]);
} }
/**
* Holt die gewaehlten Google-Schriften auf den eigenen Server.
*
* Auf Zuruf, nicht bei einem Seitenaufruf: das dauert Sekunden und braucht eine Verbindung
* nach aussen, die ein Besucher nie abwarten soll.
*/
#[Route('/admin/theme/fonts', name: 'admin_theme_fonts', methods: ['POST'])]
public function fonts(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('theme', $request->request->getString('_csrf_token'))) {
$this->addFlash('error', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
return $this->redirectToRoute('admin_theme');
}
try {
$result = $this->fonts->fetch($this->theme->load());
$this->addFlash('success', sprintf(
'%d Schriftdatei(en) geladen, %s insgesamt: %s. Die Website lädt jetzt nichts mehr von Google.',
$result['files'],
$this->bytes($result['bytes']),
implode(', ', $result['families']),
));
} catch (\RuntimeException $error) {
$this->addFlash('error', $error->getMessage());
}
return $this->redirectToRoute('admin_theme');
}
private function bytes(int $bytes): string
{
return $bytes < 1024 * 1024 ? round($bytes / 1024).' KB' : round($bytes / 1024 / 1024, 1).' MB';
}
#[Route('/admin/theme/save', name: 'admin_theme_save', methods: ['POST'])] #[Route('/admin/theme/save', name: 'admin_theme_save', methods: ['POST'])]
public function save(Request $request): Response public function save(Request $request): Response
{ {
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<title>{% block title %}Verwaltung{% endblock %} · KantSites</title> <title>{% block title %}Verwaltung{% endblock %} · KantSites</title>
{% include 'favicon.html.twig' %} {% include 'favicon.html.twig' %}
<link rel="stylesheet" href="/assets/admin.css"> <link rel="stylesheet" href="/assets/admin.css?v={{ kant_version() }}">
{% block styles %}{% endblock %} {% block styles %}{% endblock %}
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</head> </head>
+2 -2
View File
@@ -3,8 +3,8 @@
{% set subject = area ? area.document : page.document %} {% set subject = area ? area.document : page.document %}
{% block title %}{{ subject.title }} bearbeiten{% endblock %} {% block title %}{{ subject.title }} bearbeiten{% endblock %}
{% block body_class %}editor-body{% endblock %} {% block body_class %}editor-body{% endblock %}
{% block styles %}<link rel="stylesheet" href="/assets/editor.css">{% endblock %} {% block styles %}<link rel="stylesheet" href="/assets/editor.css?v={{ kant_version() }}">{% endblock %}
{% block scripts %}<script type="module" src="/assets/editor.js"></script>{% endblock %} {% block scripts %}<script type="module" src="/assets/editor.js?v={{ kant_version() }}"></script>{% endblock %}
{% block body %} {% block body %}
{# The API base decides what is edited. An area is shared, so it has no draft and no {# The API base decides what is edited. An area is shared, so it has no draft and no
publication: saving is live, and the publish button is left out. #} publication: saving is live, and the publish button is left out. #}
+1 -1
View File
@@ -1,7 +1,7 @@
{% extends 'admin/shell.html.twig' %} {% extends 'admin/shell.html.twig' %}
{% set nav = 'media' %} {% set nav = 'media' %}
{% block title %}Mediathek{% endblock %} {% block title %}Mediathek{% endblock %}
{% block scripts %}<script type="module" src="/assets/media-drop.js"></script>{% endblock %} {% block scripts %}<script type="module" src="/assets/media-drop.js?v={{ kant_version() }}"></script>{% endblock %}
{% block heading %}Mediathek{% endblock %} {% block heading %}Mediathek{% endblock %}
{% block lead %}Alle Bilder, neueste zuerst. Beim Hochladen wird jedes Bild in WebP umgewandelt und in mehreren Breiten abgelegt; die Seite liefert dann die passende aus.{% endblock %} {% block lead %}Alle Bilder, neueste zuerst. Beim Hochladen wird jedes Bild in WebP umgewandelt und in mehreren Breiten abgelegt; die Seite liefert dann die passende aus.{% endblock %}
{% block content %} {% block content %}
+5
View File
@@ -88,6 +88,11 @@
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
<input type="hidden" name="expectedVersion" value="{{ page.version }}"> <input type="hidden" name="expectedVersion" value="{{ page.version }}">
<div class="fields"> <div class="fields">
<div>
<label for="seotitle-{{ page.id }}">Titel für Suchergebnisse</label>
<input id="seotitle-{{ page.id }}" name="seoTitle" maxlength="160" value="{{ page.seo_title }}" placeholder="{{ page.document.title|default(page.path) }}">
<p class="field-help">Leer lassen, wenn der Titel der Seite auch in der Suche stehen soll. Google zeigt etwa 60 Zeichen und schneidet danach ab; was dahinter steht, liest niemand.</p>
</div>
<div> <div>
<label for="robots-{{ page.id }}">Suchmaschinen</label> <label for="robots-{{ page.id }}">Suchmaschinen</label>
<select id="robots-{{ page.id }}" name="robots"> <select id="robots-{{ page.id }}" name="robots">
+18
View File
@@ -110,5 +110,23 @@
<p class="field-help">Farben entweder als <code>@schlüssel</code> aus der Palette oder als Wert wie <code>#1a2b3c</code>.</p> <p class="field-help">Farben entweder als <code>@schlüssel</code> aus der Palette oder als Wert wie <code>#1a2b3c</code>.</p>
<button class="primary" type="submit">Design speichern</button> <button class="primary" type="submit">Design speichern</button>
</form> </form>
<div class="section-head"><h2>Schriften ausliefern</h2><span class="muted">Von Google oder vom eigenen Server</span></div>
{% if fontsMissing is empty and not fontsLocal %}
<p class="field-help">Dieses Design nutzt nur Systemschriften – es lädt ohnehin nichts von Google.</p>
{% else %}
<p class="field-help">
{% if fontsLocal %}
<strong>Die Schriften liegen auf diesem Server.</strong> Besucher laden nichts von Google, und die Seite baut sich schneller auf, weil eine Kette aus zwei fremden Verbindungen entfällt.
{% else %}
<strong>Die Schriften kommen zurzeit von Google.</strong> Damit erfährt Google die IP-Adresse jedes Besuchers – in Deutschland ein abmahnfähiger Mangel (LG München I, 3 O 17493/20) –, und der Seitenaufbau wartet auf zwei fremde Verbindungen nacheinander. Mit dem Knopf werden die Dateien einmalig hierher geholt; die Lizenz der Schriften erlaubt das ausdrücklich.
{% endif %}
{% if fontsMissing %}<br>Noch nicht hier: {{ fontsMissing|join(', ') }}.{% endif %}
</p>
<form method="post" action="{{ path('admin_theme_fonts') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('theme') }}">
<button type="submit">{{ fontsLocal ? 'Schriften erneut holen' : 'Schriften auf diesen Server holen' }}</button>
</form>
{% endif %}
<p class="footnote">KantSites · Arbeitsstand</p> <p class="footnote">KantSites · Arbeitsstand</p>
{% endblock %} {% endblock %}
+2 -2
View File
@@ -16,8 +16,8 @@
{% if result.page < result.pages %}<link rel="next" href="{{ base }}?seite={{ result.page + 1 }}">{% endif %} {% if result.page < result.pages %}<link rel="next" href="{{ base }}?seite={{ result.page + 1 }}">{% endif %}
<link rel="alternate" type="application/rss+xml" title="Beiträge" href="/feed.xml"> <link rel="alternate" type="application/rss+xml" title="Beiträge" href="/feed.xml">
{% include 'favicon.html.twig' %} {% include 'favicon.html.twig' %}
<link rel="stylesheet" href="/assets/site.css"> <link rel="stylesheet" href="/assets/site.css?v={{ kant_version() }}">
<link rel="stylesheet" href="/assets/design.css"> <link rel="stylesheet" href="/assets/design.css?v={{ kant_version() }}">
</head> </head>
{% set consentOpen = not editing|default(false) and consentGate.needsBanner(app.request) %} {% set consentOpen = not editing|default(false) and consentGate.needsBanner(app.request) %}
<body class="kant{{ consentOpen ? ' consent-open' : '' }}"> <body class="kant{{ consentOpen ? ' consent-open' : '' }}">
+21 -10
View File
@@ -10,7 +10,7 @@
{% set tag = type == 'section' ? 'section' : 'div' %} {% set tag = type == 'section' ? 'section' : 'div' %}
<{{ tag }} id="{{ block.id }}" class="block-{{ type }} tone-{{ layout.tone|default('paper') }} width-{{ layout.width|default('normal') }}{{ designClasses }}"> <{{ tag }} id="{{ block.id }}" class="block-{{ type }} tone-{{ layout.tone|default('paper') }} width-{{ layout.width|default('normal') }}{{ designClasses }}">
{% if type == 'section' %}<div class="shell section-inner">{% endif %} {% if type == 'section' %}<div class="shell section-inner">{% endif %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null)} only %}{% endfor %} {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
{% if type == 'section' %}</div>{% endif %} {% if type == 'section' %}</div>{% endif %}
</{{ tag }}> </{{ tag }}>
{% elseif type == 'heading' %} {% elseif type == 'heading' %}
@@ -26,7 +26,18 @@
{% elseif type == 'button' %} {% elseif type == 'button' %}
<a id="{{ block.id }}" class="button{{ designClasses }}" href="{{ props.href }}">{{ props.text }} <span aria-hidden="true">↗</span></a> <a id="{{ block.id }}" class="button{{ designClasses }}" href="{{ props.href }}">{{ props.text }} <span aria-hidden="true">↗</span></a>
{% elseif type == 'image' %} {% elseif type == 'image' %}
<figure id="{{ block.id }}" class="block-image{{ designClasses }}">{% set eager = eagerImage|default(null) == block.id %}{% if props.href|default('') %}<a class="image-link" href="{{ props.href }}">{% endif %}<img src="{{ props.src }}" alt="{{ props.alt }}" width="{{ props.width }}" height="{{ props.height }}" loading="{{ eager ? 'eager' : 'lazy' }}"{{ eager ? ' fetchpriority="high"' : '' }} decoding="async"{% if props.sources|default([]) %} srcset="{% for source in props.sources %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px"{% endif %}>{% if props.href|default('') %}</a>{% endif %}{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure> {# Wie breit das Bild tatsaechlich erscheint. Steht im Block eine feste Breite - ein Logo mit
190px, ein Symbol mit 72px -, dann ist das die Antwort, und der Browser laedt die kleine
Variante statt der grossen. Ohne feste Breite bleibt es bei der Annahme nach Spaltenbreite. #}
{% set festeBreite = block.style.desktop.maxWidth|default('') %}
{% set eager = eagerImage|default(null) == block.id %}
{# „auto“ heißt: der Browser misst selbst nach, wie breit das Bild tatsächlich erscheint, und
wählt genau dazu die Variante. Das geht nur bei verzögert geladenen Bildern - beim
vorgezogenen Bild steht die Größe noch nicht fest. Die Angaben dahinter sind der Rückfall
für Browser, die „auto“ noch nicht kennen (heute Safari). #}
{% set annahme = '(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px' %}
{% set imageSizes = festeBreite ends with 'px' ? festeBreite : (eager or inHeader|default(false) ? annahme : 'auto, ' ~ annahme) %}
<figure id="{{ block.id }}" class="block-image{{ designClasses }}">{% if props.href|default('') %}<a class="image-link" href="{{ props.href }}">{% endif %}<img src="{{ props.src }}" alt="{{ props.alt }}" width="{{ props.width }}" height="{{ props.height }}" loading="{{ eager or inHeader|default(false) ? 'eager' : 'lazy' }}"{{ eager ? ' fetchpriority="high"' : '' }} decoding="async"{% if props.sources|default([]) %} srcset="{% for source in props.sources %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="{{ imageSizes }}"{% endif %}>{% if props.href|default('') %}</a>{% endif %}{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure>
{% elseif type == 'iconcard' %} {% elseif type == 'iconcard' %}
<div id="{{ block.id }}" class="block-iconcard{{ designClasses }}"> <div id="{{ block.id }}" class="block-iconcard{{ designClasses }}">
{% include 'blocks/icon.html.twig' with {name: props.icon} only %} {% include 'blocks/icon.html.twig' with {name: props.icon} only %}
@@ -45,7 +56,7 @@
</figure> </figure>
{% elseif type == 'logos' %} {% elseif type == 'logos' %}
<ul id="{{ block.id }}" class="block-logos{{ designClasses }}"> <ul id="{{ block.id }}" class="block-logos{{ designClasses }}">
{% for child in block.children|default([]) %}<li>{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null)} only %}</li>{% endfor %} {% for child in block.children|default([]) %}<li>{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}</li>{% endfor %}
</ul> </ul>
{% elseif type == 'cta' %} {% elseif type == 'cta' %}
<div id="{{ block.id }}" class="block-cta{{ designClasses }}"> <div id="{{ block.id }}" class="block-cta{{ designClasses }}">
@@ -88,7 +99,7 @@
{% set section = sections[props.partial]|default(null) %} {% set section = sections[props.partial]|default(null) %}
{% if section %} {% if section %}
<div id="{{ block.id }}" class="block-partialref{{ designClasses }}"> <div id="{{ block.id }}" class="block-partialref{{ designClasses }}">
{% for child in section.blocks %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null)} only %}{% endfor %} {% for child in section.blocks %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
</div> </div>
{% elseif editing|default(false) %} {% elseif editing|default(false) %}
<p id="{{ block.id }}" class="block-unavailable" role="note">Die verknüpfte Sektion „{{ props.partial }}“ gibt es nicht mehr. Der Verweis bleibt gespeichert.</p> <p id="{{ block.id }}" class="block-unavailable" role="note">Die verknüpfte Sektion „{{ props.partial }}“ gibt es nicht mehr. Der Verweis bleibt gespeichert.</p>
@@ -117,22 +128,22 @@
<ul class="tabs-nav"> <ul class="tabs-nav">
{% for child in block.children|default([]) %}<li><a href="#{{ child.id }}">{{ child.props.title }}</a></li>{% endfor %} {% for child in block.children|default([]) %}<li><a href="#{{ child.id }}">{{ child.props.title }}</a></li>{% endfor %}
</ul> </ul>
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null)} only %}{% endfor %} {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
</div> </div>
{% elseif type == 'tabitem' %} {% elseif type == 'tabitem' %}
<section id="{{ block.id }}" class="block-tabitem{{ designClasses }}"> <section id="{{ block.id }}" class="block-tabitem{{ designClasses }}">
<h3 class="tabs-heading">{{ props.title }}</h3> <h3 class="tabs-heading">{{ props.title }}</h3>
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null)} only %}{% endfor %} {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
</section> </section>
{% elseif type == 'accordion' %} {% elseif type == 'accordion' %}
<div id="{{ block.id }}" class="block-accordion{{ designClasses }}"> <div id="{{ block.id }}" class="block-accordion{{ designClasses }}">
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null)} only %}{% endfor %} {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
</div> </div>
{% elseif type == 'accordionitem' %} {% elseif type == 'accordionitem' %}
<details id="{{ block.id }}" class="block-accordionitem{{ designClasses }}"{% if props.open|default(false) %} open{% endif %}> <details id="{{ block.id }}" class="block-accordionitem{{ designClasses }}"{% if props.open|default(false) %} open{% endif %}>
<summary>{{ props.title }}</summary> <summary>{{ props.title }}</summary>
<div class="accordion-body"> <div class="accordion-body">
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null)} only %}{% endfor %} {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
</div> </div>
</details> </details>
{% elseif type == 'icon' %} {% elseif type == 'icon' %}
@@ -161,7 +172,7 @@
<div id="{{ block.id }}" class="block-slider{{ designClasses }} slide-desktop-{{ props.desktopVisible }} slide-tablet-{{ props.tabletVisible }} slide-mobile-{{ props.mobileVisible }}{{ props.snap ? ' slider-snap' : '' }}{{ props.dots ?? false ? ' slider-dots' : '' }}"{% if props.autoplay|default(0) >= 3 %} data-autoplay="{{ props.autoplay }}"{% endif %} <div id="{{ block.id }}" class="block-slider{{ designClasses }} slide-desktop-{{ props.desktopVisible }} slide-tablet-{{ props.tabletVisible }} slide-mobile-{{ props.mobileVisible }}{{ props.snap ? ' slider-snap' : '' }}{{ props.dots ?? false ? ' slider-dots' : '' }}"{% if props.autoplay|default(0) >= 3 %} data-autoplay="{{ props.autoplay }}"{% endif %}
tabindex="0" role="group" aria-label="Zum Blättern seitwärts scrollen"> tabindex="0" role="group" aria-label="Zum Blättern seitwärts scrollen">
{% for child in block.children|default([]) %} {% for child in block.children|default([]) %}
<div class="slider-slide">{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null)} only %}</div> <div class="slider-slide">{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}</div>
{% endfor %} {% endfor %}
</div> </div>
{% elseif type == 'embed' %} {% elseif type == 'embed' %}
@@ -204,7 +215,7 @@
</div> </div>
{% elseif type == 'gallery' %} {% elseif type == 'gallery' %}
<div id="{{ block.id }}" class="block-gallery{{ designClasses }}" role="group" aria-label="Bildergalerie"> <div id="{{ block.id }}" class="block-gallery{{ designClasses }}" role="group" aria-label="Bildergalerie">
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null)} only %}{% endfor %} {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
</div> </div>
{% else %} {% else %}
{# {#
+1 -1
View File
@@ -6,7 +6,7 @@
<title>Einwilligung{{ settings.legalName ? ' · ' ~ settings.legalName : '' }}</title> <title>Einwilligung{{ settings.legalName ? ' · ' ~ settings.legalName : '' }}</title>
<meta name="robots" content="noindex, follow"> <meta name="robots" content="noindex, follow">
{% include 'favicon.html.twig' %} {% include 'favicon.html.twig' %}
<link rel="stylesheet" href="/assets/site.css"> <link rel="stylesheet" href="/assets/site.css?v={{ kant_version() }}">
</head> </head>
<body class="kant consent-page"> <body class="kant consent-page">
<main id="main" tabindex="-1"> <main id="main" tabindex="-1">
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="robots" content="noindex"> <meta name="robots" content="noindex">
<title>Kurz nicht erreichbar</title> <title>Kurz nicht erreichbar</title>
{% include 'favicon.html.twig' %} {% include 'favicon.html.twig' %}
<link rel="stylesheet" href="/assets/site.css"> <link rel="stylesheet" href="/assets/site.css?v={{ kant_version() }}">
</head> </head>
<body class="kant"> <body class="kant">
<main class="shell error-page" id="main" tabindex="-1"> <main class="shell error-page" id="main" tabindex="-1">
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex"><title>Seite nicht gefunden · KantSites</title><link rel="stylesheet" href="/assets/site.css">{% include 'favicon.html.twig' %}</head> <html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex"><title>Seite nicht gefunden · KantSites</title><link rel="stylesheet" href="/assets/site.css?v={{ kant_version() }}">{% include 'favicon.html.twig' %}</head>
{% set consentOpen = not editing|default(false) and consentGate.needsBanner(app.request) %} {% set consentOpen = not editing|default(false) and consentGate.needsBanner(app.request) %}
<body class="{{ consentOpen ? 'consent-open' : '' }}"><main class="shell error-page"><p>404 · KantSites</p><h1>Hier ist noch keine Seite.</h1><p>Die Adresse existiert nicht oder die Seite ist noch nicht veröffentlicht.</p><a class="button" href="/">Zur Startseite</a></main> <body class="{{ consentOpen ? 'consent-open' : '' }}"><main class="shell error-page"><p>404 · KantSites</p><h1>Hier ist noch keine Seite.</h1><p>Die Adresse existiert nicht oder die Seite ist noch nicht veröffentlicht.</p><a class="button" href="/">Zur Startseite</a></main>
{# Privacy statement, imprint and the way back to the consent decision. All three have to be {# Privacy statement, imprint and the way back to the consent decision. All three have to be
+21 -9
View File
@@ -3,11 +3,13 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ page.document.title }}</title> {# Der Titel fuer die Suche, sonst der Titel der Seite. #}
{% set seoTitle = page.seo_title|default('') ?: page.document.title %}
<title>{{ seoTitle }}</title>
<meta name="description" content="{{ page.document.description }}"> <meta name="description" content="{{ page.document.description }}">
<link rel="canonical" href="{{ canonical }}"> <link rel="canonical" href="{{ canonical }}">
{% if page.robots|default('index') != 'index' %}<meta name="robots" content="noindex, follow">{% endif %} {% if page.robots|default('index') != 'index' %}<meta name="robots" content="noindex, follow">{% endif %}
<meta property="og:title" content="{{ page.document.title }}"> <meta property="og:title" content="{{ seoTitle }}">
<meta property="og:description" content="{{ page.document.description }}"> <meta property="og:description" content="{{ page.document.description }}">
<meta property="og:type" content="{{ page.type|default('page') == 'post' ? 'article' : 'website' }}"> <meta property="og:type" content="{{ page.type|default('page') == 'post' ? 'article' : 'website' }}">
<meta property="og:url" content="{{ canonical }}"> <meta property="og:url" content="{{ canonical }}">
@@ -23,14 +25,24 @@
<meta name="twitter:card" content="summary"> <meta name="twitter:card" content="summary">
{% endif %} {% endif %}
{% include 'favicon.html.twig' %} {% include 'favicon.html.twig' %}
<link rel="stylesheet" href="/assets/site.css"> {# Ein Bündel statt vier Dateien: jede einzelne hält das Zeichnen der Seite auf. Fehlt es
{% if googleFonts|default(null) %}<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link rel="stylesheet" href="{{ googleFonts }}">{% endif %} (kein Schreibrecht im Ordner), werden die Quellen einzeln geladen - langsamer, aber heil. #}
{% if styleBundle|default(null) %}
<link rel="stylesheet" href="{{ styleBundle }}">
{% else %}
<link rel="stylesheet" href="/assets/site.css?v={{ kant_version() }}">
{% if localFonts|default([]) %}<link rel="stylesheet" href="/assets/fonts/fonts.css?v={{ kant_version() }}">{% endif %}
{% if page.document.schemaVersion >= 2 %}<link rel="stylesheet" href="/assets/design.css?v={{ kant_version() }}">{% endif %}
{% if page.document.schemaVersion >= 3 %}<link rel="stylesheet" href="/assets/enhancements.css?v={{ kant_version() }}">{% endif %}
{% endif %}
{% if localFonts|default([]) %}
{# Die wichtigsten Schnitte vorab, damit die erste Zeile in der richtigen Schrift steht. #}
{% for font in localFonts %}<link rel="preload" href="{{ font }}" as="font" type="font/woff2" crossorigin>{% endfor %}
{% elseif googleFonts|default(null) %}<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link rel="stylesheet" href="{{ googleFonts }}">{% endif %}
{% if hasPosts|default(false) %} <link rel="alternate" type="application/rss+xml" title="Beiträge" href="/feed.xml"> {% if hasPosts|default(false) %} <link rel="alternate" type="application/rss+xml" title="Beiträge" href="/feed.xml">
{% endif %} {% endif %}
{% if page.document.schemaVersion >= 2 %}<link rel="stylesheet" href="/assets/design.css">{% endif %} {% if needsTabs|default(false) %}<script src="/assets/tabs.js?v={{ kant_version() }}" defer></script>{% endif %}
{% if page.document.schemaVersion >= 3 %}<link rel="stylesheet" href="/assets/enhancements.css">{% endif %} {% if needsSlider|default(false) %}<script src="/assets/slider.js?v={{ kant_version() }}" defer></script>{% endif %}
{% if needsTabs|default(false) %}<script src="/assets/tabs.js" defer></script>{% endif %}
{% if needsSlider|default(false) %}<script src="/assets/slider.js" defer></script>{% endif %}
{# Own scripts of this page, as a file. Never rendered in the editor preview: the preview must {# Own scripts of this page, as a file. Never rendered in the editor preview: the preview must
show the layout, not run code that could change the page under the editor's hands. #} show the layout, not run code that could change the page under the editor's hands. #}
{% if pageScript|default(null) and not editing|default(false) %}<script src="{{ pageScript }}" defer></script>{% endif %} {% if pageScript|default(null) and not editing|default(false) %}<script src="{{ pageScript }}" defer></script>{% endif %}
@@ -52,7 +64,7 @@
instead of being written into this template. #} instead of being written into this template. #}
{% if areas.header|default(null) %} {% if areas.header|default(null) %}
<header class="site-header{{ siteSettings.load().stickyHeader ? ' is-sticky' : '' }}"> <header class="site-header{{ siteSettings.load().stickyHeader ? ' is-sticky' : '' }}">
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.header.schemaVersion|default(1) >= 12, richHeadings: areas.header.schemaVersion|default(1) >= 17, eagerImage: eagerImage|default(null)} only %}{% endfor %} {% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.header.schemaVersion|default(1) >= 12, richHeadings: areas.header.schemaVersion|default(1) >= 17, eagerImage: eagerImage|default(null), inHeader: true} only %}{% endfor %}
</header> </header>
{% endif %} {% endif %}
<main id="main" tabindex="-1"> <main id="main" tabindex="-1">
+195 -3
View File
@@ -6,6 +6,7 @@ use App\Content\Consent;
use App\Content\CustomCode; use App\Content\CustomCode;
use App\Content\DemoContent; use App\Content\DemoContent;
use App\Content\DocumentValidator; use App\Content\DocumentValidator;
use App\Content\FontLibrary;
use App\Content\MediaLibrary; use App\Content\MediaLibrary;
use App\Content\MenuRepository; use App\Content\MenuRepository;
use App\Content\PageRepository; use App\Content\PageRepository;
@@ -15,6 +16,7 @@ use App\Content\PostRepository;
use App\Content\RedirectRepository; use App\Content\RedirectRepository;
use App\Content\RichText; use App\Content\RichText;
use App\Content\SiteSettings; use App\Content\SiteSettings;
use App\Content\StyleBundle;
use App\Content\StyleCompiler; use App\Content\StyleCompiler;
use App\Content\StyleSchema; use App\Content\StyleSchema;
use App\Content\Theme; use App\Content\Theme;
@@ -707,7 +709,7 @@ final class CmsTest extends TestCase
$this->pages->publish('page-1', $revision, $this->pages->findForEditor('page-1')['version']); $this->pages->publish('page-1', $revision, $this->pages->findForEditor('page-1')['version']);
$mit = $this->request('/'); $mit = $this->request('/');
self::assertStringContainsString('<script src="/assets/slider.js" defer></script>', $mit->getContent()); self::assertStringContainsString('<script src="/assets/slider.js?v=', $mit->getContent());
self::assertStringContainsString('data-autoplay="7"', $mit->getContent()); self::assertStringContainsString('data-autoplay="7"', $mit->getContent());
self::assertStringContainsString("script-src 'self'", (string) $mit->headers->get('Content-Security-Policy')); self::assertStringContainsString("script-src 'self'", (string) $mit->headers->get('Content-Security-Policy'));
@@ -877,6 +879,194 @@ final class CmsTest extends TestCase
self::assertStringContainsString('Nächstes Projekt', $rand); self::assertStringContainsString('Nächstes Projekt', $rand);
} }
public function testABackgroundPictureShrinksForSmallScreensEvenWhenSetOnce(): void
{
// Gemessen auf der nachgebauten Startseite: das Telefon lud das Hintergrundbild mit 1920
// Pixeln und 189 KB, weil die Regel nur unter „Desktop" stand und ein kleines Gerät sie
// erbt. Sichtbar war davon nichts - bezahlt wurde es trotzdem.
$media = new MediaLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-hintergrund-');
$image = imagecreatetruecolor(2000, 1200);
imagepng($image, $file);
imagedestroy($image);
$item = $media->upload(new UploadedFile($file, 'hintergrund.png', test: true));
@unlink($file);
$document = DemoContent::starter('Startseite');
$document['schemaVersion'] = 18;
$document['blocks'][0]['style'] = ['desktop' => [
'backgroundImage' => $item['id'],
'backgroundSize' => 'cover',
'overlayColor' => '#0a192f',
'overlayOpacity' => 0.6,
]];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
$id = $document['blocks'][0]['id'];
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('/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.
self::assertStringContainsString('#0a192f 60%', $mobil[1]);
self::assertStringContainsString('background-size:cover', $mobil[1]);
// Eine eigene Regel für kleine Geräte wird nie überschrieben.
$document['blocks'][0]['style']['mobile'] = ['backgroundColor' => '#ffffff'];
$version = $this->pages->findForEditor('page-1')['version'];
$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.');
}
public function testTheBigPictureLoadsFirstAndSmallOnesAskForSmallVariants(): void
{
// Zwei Fehler auf einmal: die Eile lag auf dem Logo im Kopfbereich statt auf dem großen
// Bild darunter, und jedes Bild fragte nach einer Variante für 800 px Breite - auch ein
// Logo, das 190 px breit ist. Das waren 32 KB für nichts.
$media = new MediaLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-bild-');
$image = imagecreatetruecolor(1200, 800);
imagepng($image, $file);
imagedestroy($image);
$item = $media->upload(new UploadedFile($file, 'bild.png', test: true));
@unlink($file);
$variants = array_column($item['variants'], 'src', 'width');
$bild = static fn (string $id, array $style): array => [
'id' => $id,
'type' => 'image',
'props' => ['src' => $variants[960], 'alt' => 'Ein Bild', 'decorative' => false, 'width' => 960, 'height' => 640, 'sources' => [['src' => $variants[480], 'width' => 480], ['src' => $variants[960], 'width' => 960]]],
'style' => $style,
];
$document = DemoContent::starter('Startseite');
$document['schemaVersion'] = 18;
$document['blocks'] = [
['id' => 'oben', 'type' => 'section', 'props' => [], 'children' => [
['id' => 'titel', 'type' => 'heading', 'props' => ['text' => 'Startseite', 'level' => 1]],
$bild('gross', []),
$bild('logo', ['desktop' => ['maxWidth' => '190px']]),
]],
['id' => 'unten', 'type' => 'section', 'props' => [], 'children' => [$bild('weiter-unten', [])]],
];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
// Genau ein Bild wird vorgezogen, und zwar das erste im ersten Abschnitt.
self::assertSame(1, substr_count($html, 'fetchpriority="high"'));
self::assertMatchesRegularExpression('#<figure id="gross".{0,400}fetchpriority="high"#s', $html);
self::assertMatchesRegularExpression('#<figure id="weiter-unten".{0,400}loading="lazy"#s', $html);
// Ein Block mit fester Breite fragt nach genau dieser Breite.
self::assertMatchesRegularExpression('#<figure id="logo".{0,500}sizes="190px"#s', $html);
self::assertMatchesRegularExpression('#<figure id="gross".{0,500}sizes="\(max-width: 650px\) 100vw#s', $html);
}
public function testFontsAreReadFromGoogleStylesheetAndServedFromHere(): void
{
// Eine Schrift von Google kostet zwei fremde Verbindungen hintereinander und schickt die
// IP jedes Besuchers nach Kalifornien. Der Parser holt aus dem Stylesheet genau das, was
// eine deutschsprachige Website braucht - Kyrillisch bleibt liegen.
$fonts = new FontLibrary($this->path.'-fonts');
$css = <<<'CSS'
/* cyrillic */
@font-face{font-family:'Lato';font-style:normal;font-weight:400;src:url(https://fonts.gstatic.com/s/lato/kyrillisch.woff2) format('woff2');unicode-range:U+0301, U+0400-045F}
/* latin */
@font-face{font-family:'Lato';font-style:normal;font-weight:400;src:url(https://fonts.gstatic.com/s/lato/latein.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131}
/* latin */
@font-face{font-family:'Lato';font-style:normal;font-weight:700;src:url(https://fonts.gstatic.com/s/lato/latein-fett.woff2) format('woff2');unicode-range:U+0000-00FF}
CSS;
$faces = $fonts->parse($css, 'Lato');
self::assertCount(2, $faces, 'Kyrillisch gehört nicht dazu.');
self::assertSame([400, 700], array_column($faces, 'weight'));
self::assertSame('https://fonts.gstatic.com/s/lato/latein.woff2', $faces[0]['url']);
self::assertStringContainsString('U+0131', $faces[0]['range']);
// Ohne heruntergeladene Dateien bleibt es beim Verweis auf Google - und das Theme weiß es.
$theme = new Theme($this->db);
$gespeichert = $theme->load();
$theme->save($gespeichert['colors'], [
['key' => 'body', 'name' => 'Fließtext', 'family' => 'Lato', 'source' => 'google', 'weights' => [400, 700], 'fallback' => 'system-ui'],
['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Lato', 'source' => 'google', 'weights' => [700], 'fallback' => 'system-ui'],
], $gespeichert['base']);
self::assertFalse($fonts->hasLocal($theme->load()));
self::assertSame(['Lato 400', 'Lato 700'], $fonts->missing($theme->load()));
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::starter('Startseite'), 0), 1);
self::assertStringContainsString('fonts.googleapis.com', $this->request('/')->getContent());
// Mit Dateien: kein Wort mehr von Google, dafür die eigenen Dateien und ein Vorabholen.
mkdir($this->path.'-fonts', 0o775, true);
foreach (['lato-400-latin.woff2', 'lato-700-latin.woff2'] as $datei) {
file_put_contents($this->path.'-fonts/'.$datei, 'wOF2test');
}
file_put_contents($this->path.'-fonts/fonts.css', "@font-face{font-family:'Lato'}");
self::assertTrue($fonts->hasLocal($theme->load()));
self::assertSame(['/assets/fonts/lato-400-latin.woff2', '/assets/fonts/lato-700-latin.woff2'], $fonts->preload($theme->load()));
}
public function testTheStylesheetsGoOutAsOneFileWithoutComments(): void
{
// Vier Stylesheets sind vier Anfragen, und jede einzelne hält das Zeichnen der Seite auf.
$assets = $this->path.'-assets';
mkdir($assets, 0o775, true);
file_put_contents($assets.'/site.css', '/* Das Grundgerüst. */
.kant {
color: red;
}
');
file_put_contents($assets.'/design.css', '/* Gestaltung. */
.block-row { gap: 10px }
');
$bundle = new StyleBundle($assets);
$url = $bundle->url(false);
self::assertNotNull($url);
self::assertStringStartsWith('/assets/build/kant-', $url);
$inhalt = (string) file_get_contents($assets.'/build/'.basename((string) $url));
self::assertStringNotContainsString('Das Grundgerüst', $inhalt, 'Kommentare erklären den Quelltext, nicht den Browser.');
self::assertStringContainsString('color: red;', $inhalt, 'Leerzeichen innerhalb einer Zeile bleiben - in calc() tragen sie Bedeutung.');
self::assertStringContainsString('.block-row { gap: 10px }', $inhalt);
// Derselbe Inhalt ergibt denselben Namen; eine Änderung einen neuen.
self::assertSame($url, (new StyleBundle($assets))->url(false));
touch($assets.'/site.css', time() + 10);
clearstatcache();
self::assertNotSame($url, (new StyleBundle($assets))->url(false));
}
public function testTheTitleInSearchResultsCanDifferFromTheTitleOnThePage(): void
{
// Auf der Seite steht "Leistungen", im Suchergebnis hilft der lange Titel mit Ort und
// Firma. Bisher war beides dasselbe Feld, also musste man sich entscheiden.
$document = DemoContent::starter('Leistungen');
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('<title>Leistungen</title>', $html);
$this->posts->saveMetadata('page-1', ['seoTitle' => 'IT-Leistungen für Mittelbaden – Kant-IT Solutions'], [], [], (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']));
$html = $this->request('/')->getContent();
self::assertStringContainsString('<title>IT-Leistungen für Mittelbaden – Kant-IT Solutions</title>', $html);
self::assertStringContainsString('<meta property="og:title" content="IT-Leistungen für Mittelbaden – Kant-IT Solutions">', $html);
// Die Überschrift auf der Seite bleibt, was sie war.
self::assertStringContainsString('>Leistungen</h1>', $html);
// Wieder geleert heißt: der Titel der Seite gilt.
$this->posts->saveMetadata('page-1', ['seoTitle' => ''], [], [], (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']));
self::assertStringContainsString('<title>Leistungen</title>', $this->request('/')->getContent());
try {
$this->posts->saveMetadata('page-1', ['seoTitle' => str_repeat('a', 161)], [], [], (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']));
self::fail('Ein Titel über 160 Zeichen muss abgelehnt werden.');
} catch (InvalidPage) {
}
}
public function testThePreviewPictureForSharingActuallyReachesTheHead(): void public function testThePreviewPictureForSharingActuallyReachesTheHead(): void
{ {
// Das Feld „Vorschaubild beim Teilen" gab es seit dem SEO-Paket - ausgegeben wurde es // Das Feld „Vorschaubild beim Teilen" gab es seit dem SEO-Paket - ausgegeben wurde es
@@ -1370,7 +1560,9 @@ final class CmsTest extends TestCase
self::assertStringContainsString('<section id="tab-web" class="block-tabitem">', $html); self::assertStringContainsString('<section id="tab-web" class="block-tabitem">', $html);
self::assertStringContainsString('<section id="tab-app" class="block-tabitem">', $html); self::assertStringContainsString('<section id="tab-app" class="block-tabitem">', $html);
// The enhancement is an external file; inline script never appears. // The enhancement is an external file; inline script never appears.
self::assertStringContainsString('<script src="/assets/tabs.js" defer></script>', $html); // Die Fassung haengt als Parameter an: nach einem Update laedt niemand ein altes Skript
// aus dem Zwischenspeicher.
self::assertStringContainsString('<script src="/assets/tabs.js?v=', $html);
// Scripting is permitted for this page only, and only from this origin. // Scripting is permitted for this page only, and only from this origin.
self::assertStringContainsString("script-src 'self'", (string) $response->headers->get('Content-Security-Policy')); self::assertStringContainsString("script-src 'self'", (string) $response->headers->get('Content-Security-Policy'));
@@ -3100,7 +3292,7 @@ final class CmsTest extends TestCase
private function request(string $url, string $method = 'GET', array $body = [], array $cookies = []): Response private function request(string $url, string $method = 'GET', array $body = [], array $cookies = []): Response
{ {
$previous = []; $previous = [];
foreach (['APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->path), 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media'] as $key => $value) { foreach (['APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->path), 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media', 'FONT_DIRECTORY' => $this->path.'-fonts', 'ASSET_DIRECTORY' => $this->path.'-assets'] as $key => $value) {
$previous[$key] = [$_SERVER[$key] ?? null, $_ENV[$key] ?? null]; $previous[$key] = [$_SERVER[$key] ?? null, $_ENV[$key] ?? null];
$_SERVER[$key] = $_ENV[$key] = $value; $_SERVER[$key] = $_ENV[$key] = $value;
} }
+2
View File
@@ -17,6 +17,7 @@ use DoctrineMigrations\Version20260912001000;
use DoctrineMigrations\Version20260912001100; use DoctrineMigrations\Version20260912001100;
use DoctrineMigrations\Version20260912001200; use DoctrineMigrations\Version20260912001200;
use DoctrineMigrations\Version20260912001300; use DoctrineMigrations\Version20260912001300;
use DoctrineMigrations\Version20260913000100;
use Psr\Log\NullLogger; use Psr\Log\NullLogger;
/** /**
@@ -45,6 +46,7 @@ final class TestSchema
Version20260912001100::class, Version20260912001100::class,
Version20260912001200::class, Version20260912001200::class,
Version20260912001300::class, Version20260912001300::class,
Version20260913000100::class,
] as $migration) { ] as $migration) {
(new $migration($db, new NullLogger()))->up($schema); (new $migration($db, new NullLogger()))->up($schema);
} }