From a434e0cefc0317fd4c9dc67ab839ae49d320cd82 Mon Sep 17 00:00:00 2001 From: Umut Kantarci Date: Tue, 15 Sep 2026 00:15:38 +0200 Subject: [PATCH] Serve every font from this server and surface open tasks in 1.14.0 Fonts were still reachable from Google in two places. The editor and area previews were handed `googleFonts` and no `localFonts`, so the screen used most of the day fetched Lato from fonts.googleapis.com on every open; the public page fell back to the same link whenever the files were missing. Neither path remains: the template has no Google branch, `googleFontsUrl()` is gone, and the policies name no foreign font host - `font-src 'self'` publicly, `style-src 'self'; font-src 'self'` in administration. Without local files a site now shows the theme's fallback and says so. Preloading took the first two weights per family. The Kant-IT theme names Lato 400, 700 and 900 and sets its headings on 800/900, so the one weight that carried them loaded after the stylesheet and `font-display:swap` swapped the heading in plain sight. Every weight of the theme is preloaded now, latin subset, and the links sit before the stylesheet: measured cold, all three files finish at 168 ms against a first paint at 292 ms. Open tasks no longer hide on the screen that owns them. A strip above every management screen names what needs a button and carries it: a new version leads to System, missing fonts to Design. Neither asks the network - the version comes from the daily update-check task, the fonts from a look in the folder. System and update was unusable on a phone: `.stack` is a grid whose automatic column grows to its widest content, and one long database path pushed the page sideways. It gets `minmax(0, 1fr)`, the backup table stacks below 620px, and the prose is 45% shorter with the fine print folded away. The device chips drew the current device with an outline, which lies outside the box, ran under its neighbour and was clipped by the sidebar - it read as a cut-off red frame. Used devices are filled in `#9d4e0d` now (5.6:1), the current one carries a border inside the chip. Co-Authored-By: Claude Opus 5 --- .claude/launch.json | 10 +++ README.md | 11 +++ docs/ARCHITECTURE.md | 14 +++- docs/QUALITY.md | 2 +- docs/STATUS.md | 53 ++++++++++++ public/assets/admin.css | 48 ++++++++++- public/assets/editor.css | 29 +++++-- src/Content/FontLibrary.php | 10 ++- src/Content/Theme.php | 15 ---- src/Controller/EditorController.php | 5 +- src/Controller/PageController.php | 26 +++--- src/Controller/PartialController.php | 4 +- .../AdminHeadersSubscriber.php | 6 +- src/Twig/VersionExtension.php | 56 +++++++++++-- templates/admin/shell.html.twig | 9 +++ templates/admin/system.html.twig | 80 ++++++++++++------- templates/admin/theme.html.twig | 35 ++++---- templates/page.html.twig | 11 ++- tests/CmsTest.php | 47 +++++++---- 19 files changed, 345 insertions(+), 126 deletions(-) diff --git a/.claude/launch.json b/.claude/launch.json index a255775..6204dd9 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -48,6 +48,16 @@ "$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/tagespflege-moldovan.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/tagespflege-moldovan-media'; $env:SITE_ORIGIN='http://127.0.0.1:8089'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8089 -t public bin/dev-router.php" ], "port": 8089 + }, + { + "name": "kantit", + "runtimeExecutable": "powershell", + "runtimeArgs": [ + "-NoProfile", + "-Command", + "$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantit.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; $env:SITE_ORIGIN='http://127.0.0.1:8130'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8130 -t public bin/dev-router.php" + ], + "port": 8130 } ] } diff --git a/README.md b/README.md index 2922d14..8fbae98 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,22 @@ Lokaler CMS-Prototyp für PHP-Webhosting mit erstem visuellen Editor. Symfony 7. Die übernommenen Originaldokumente sind `AGENTS.md` und `ENTWICKLUNGSPLAN.md`. Den tatsächlichen Stand enthält `docs/STATUS.md`, Entscheidungen und Datenmodell stehen in `docs/ARCHITECTURE.md`. +## Schriften vom eigenen Server, Hinweise auf jedem Bildschirm (ab 1.14.0) + +**Schriften kommen ausschließlich von deinem Server.** Die Ausgabe verlinkt kein Stylesheet mehr von `fonts.googleapis.com` – weder die öffentliche Seite noch die Editorvorschau, die es bisher bei jedem Aufruf tat. Fehlen die Dateien, zeigt die Website die eingestellte Ersatzschrift und sagt es dir. Geholt werden sie wie bisher einmalig unter **Design → Schriften ausliefern**. Zusätzlich wird jetzt **jeder** Schnitt des Designs vorab geladen: bisher waren es die ersten zwei je Familie, weshalb die Überschriften sichtbar von der Ersatzschrift auf die richtige Schrift sprangen. + +**Offene Hinweise stehen auf jedem Verwaltungsbildschirm**, mit dem Knopf daneben: „Fassung X ist verfügbar“ → *Jetzt aktualisieren*, „Schriftdateien fehlen“ → *Schriften holen*. Keiner der beiden fragt beim Öffnen im Netz nach. + +**System und Update** ist auf dem Telefon benutzbar: Die Seite lief seitlich aus dem Bild, die Sicherungstabelle wird zu gestapelten Blöcken, und die Texte sind knapper – das Kleingedruckte steht zugeklappt. + +**Im Designbereich** zeigen die Gerätechips **D/T/M** jetzt in Orange, welches Gerät einen eigenen Wert trägt; das gerade bearbeitete Gerät hat einen Rahmen statt des bisherigen roten Rings, der am Rand der Spalte abgeschnitten wurde. + ## Öffnungszeiten, Einsatzgebiet und FAQ in den strukturierten Daten (ab 1.14.0) Unter **Einstellungen → Unternehmensdaten** gibt es **Öffnungszeiten** (je Wochentag Öffnen und Schließen; leer heißt geschlossen) und das **Einsatzgebiet** (ein Ort je Zeile). Beides erscheint in den strukturierten Daten der Startseite (`openingHoursSpecification`, `areaServed`); gleiche Zeiten werden zu einer Angabe zusammengefasst. Der sichtbare Text auf der Website bleibt unabhängig davon. Ein Akkordeon wird per Kästchen **Als FAQ auszeichnen** zur `FAQPage`: die Titel sind die Fragen, die Textbausteine im Eintrag die Antwort. Einträge ohne Text erscheinen nicht. Das gilt nur für Akkordeons mit gesetztem Kästchen – ein Akkordeon mit Leistungen bleibt ein Akkordeon. + ## Mehr Symbole, Menü-Knopf gestalten, Nach-oben-Knopf zuverlässig (ab 1.13.0) Der Symbolsatz hat drei Zeichnungen mehr: **Menschen**, **Musik** und **Haus**. Sie stehen überall zur Wahl, wo es Symbole gibt – Symbolbaustein, Symbolkarte und Menüeinträge. Eine am Symbolbaustein eingestellte **Farbe** wirkt jetzt auch wirklich auf die Zeichnung; bisher blieb sie in der Akzentfarbe der Website stecken. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 22faa63..24bf350 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,16 @@ # Architekturentscheidungen -Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen. +Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen. + +## ADR 095 – Schriften ausschließlich vom eigenen Server (15.09.2026) + +**Entscheidung.** Die Ausgabe verlinkt kein Stylesheet von `fonts.googleapis.com` mehr, in keinem Pfad. Liegen die Schriftdateien lokal, liefert die Website sie selbst; liegen sie nicht vor, zeigt sie die eingestellte Ersatzschrift. `Theme::googleFontsUrl()` entfällt, und die Richtlinien nennen keinen fremden Schrifthost mehr – öffentlich `font-src 'self'`, in der Verwaltung zusätzlich `style-src 'self'`. Das Herunterladen der Dateien bleibt, wo es hingehört: ein Knopf im Designbereich beziehungsweise `cms:fonts:fetch`, einmalig und von einem Menschen ausgelöst. + +**Warum.** ADR 037ff. hatten den Verweis auf Google als ausdrückliche Entscheidung Umuts stehen lassen, mit dem Selbsthosten als offenem Weg. Seit die Dateien lokal liegen können, ist der Rückfallpfad nur noch eine Falle: er greift genau dann, wenn niemand hinsieht, und gibt dann die IP-Adresse jedes Besuchers an einen Dritten weiter (LG München I, 3 O 17493/20). Vor allem betraf er die Editorvorschau, die `googleFonts` und **kein** `localFonts` bekam und die Schriften deshalb bei jedem Aufruf von Google holte – auf dem Bildschirm, an dem am meisten gearbeitet wird. + +**Folge.** Eine Installation, deren Schriften fehlen, sieht anders aus als vorher: Ersatzschrift statt Wunschschrift. Das ist beabsichtigt und sichtbar gemacht – der Designbereich nennt die fehlenden Schnitte, und seit derselben Fassung sagt es ein Hinweisstreifen auf jedem Verwaltungsbildschirm mit dem Knopf daneben. Ein stilles „lädt halt von Google“ wäre die schlechtere Überraschung. + +**Und das Vorabholen.** Es holte je Familie die ersten zwei Schnitte. Das Kant-IT-Theme nennt Lato 400, 700 und 900, die Überschriften stehen auf 800/900 und landen damit auf dem einzigen Schnitt ohne Vorabholen: er lud erst nach dem Stylesheet, und `font-display:swap` tauschte die Überschrift sichtbar. Jetzt wird jeder Schnitt des Themes vorab geholt, im lateinischen Grundbereich, und die Verweise stehen vor dem Stylesheet. Gemessen: alle Dateien sind vor dem ersten Zeichnen da. ## ADR 095 – Öffnungszeiten, Einsatzgebiet und FAQ in den strukturierten Daten (14.09.2026) @@ -440,7 +450,7 @@ Das Stylesheet steht aus Geschwindigkeitsgründen inline und ist **über seinen `Theme` ist die Grundlage darüber: eine frei definierbare Farbpalette, Schriften und die Grundtypografie. Farben werden als `@schlüssel` referenziert und zu CSS-Variablen; eine Farbe zu ändern wirkt überall, wo sie benutzt wird. Die Überschriftengrößen entstehen aus einer modularen Skala, sodass eine Zahl die ganze Hierarchie verschiebt. -**Google Fonts auf ausdrückliche Entscheidung von Umut.** Das bedeutet eine Anfrage an Google bei jedem Seitenaufruf, mit den bekannten Folgen für Datenschutz und Ladezeit – der Entwicklungsplan verlangt eigentlich lokale Schriften. Umgesetzt ist es so, dass nur die tatsächlich benötigten Schnitte geladen werden und die Richtlinie ihre Hosts nur dann öffnet, wenn wirklich eine Google-Schrift eingestellt ist. Der Weg zum Selbsthosten bleibt offen: es wäre ein zusätzlicher Quelltyp neben `system` und `google`. +**Schriften von Google, aber nicht zur Laufzeit.** Eine Familie lässt sich weiterhin bei Google aussuchen; ausgeliefert wird sie aber ausschließlich vom eigenen Server. Die Dateien holt ein Knopf im Designbereich einmalig her, und die Ausgabe verlinkt nie ein fremdes Stylesheet – Näheres in ADR 095. Die alten Tokenwerte aus v2 bis v9 bleiben gültig und werden weiterhin gerendert. Blöcke, die noch welche tragen, zeigen sie im Editor in einem eingeklappten Bereich „Klassische Werte dieser Seite“, damit ältere Seiten bearbeitbar bleiben, ohne das neue Panel zu überladen. diff --git a/docs/QUALITY.md b/docs/QUALITY.md index e84d265..819aba4 100644 --- a/docs/QUALITY.md +++ b/docs/QUALITY.md @@ -255,7 +255,7 @@ Die Messung prüft die gemeinsame öffentliche Ausgabe nach Entfernung der feste - **154 PHP-Tests / 563 Assertions** unter PHP 8.2.12 und PHP 8.4.22 erfolgreich, dazu **15 JavaScript-Modelltests**. - Ein v10-Dokument mit Palettenfarbe, eigenem Farbwert, Abständen je Seite, Ecken je Ecke, Schatten, Buchstabenabstand, Hover-Zustand und eigenem Mobilwert wird Byte für Byte gespeichert und zu blockgenauem CSS: Regel für den Block, eigene Regel für `:hover,:focus-within` und eine Media Query für die schmale Breite. - **Vierzehn abgewiesene Eingaben schreiben nichts.** Darunter gezielt die Ausbruchsversuche: eine Farbe als Funktion (`rgb(1,2,3)`), eine Länge als Ausdruck (`calc(1px + 2px)`), eine angehängte Deklaration (`18px;position:fixed`), ein Schriftschlüssel mit Anführungszeichen und ein Hintergrundbild als Dateipfad. Jeder dieser Werte scheitert am verankerten Muster seines Typs, bevor er gespeichert wird. -- **Die Sicherheitsrichtlinie wurde eigens geprüft:** das erzeugte Stylesheet ist über seinen eigenen SHA-256-Hash erlaubt, `unsafe-inline` kommt im Kopf nicht vor, und Skripte bleiben auf Seiten ohne Tabs abgeschaltet. Ohne eingestellte Google-Schrift wird kein fremder Host erlaubt und keiner kontaktiert. +- **Die Sicherheitsrichtlinie wurde eigens geprüft:** das erzeugte Stylesheet ist über seinen eigenen SHA-256-Hash erlaubt, `unsafe-inline` kommt im Kopf nicht vor, und Skripte bleiben auf Seiten ohne Tabs abgeschaltet. Ein fremder Schrifthost wird nie erlaubt und nie kontaktiert: Schriften liefert seit 1.14.0 ausschliesslich der eigene Server (ADR 095). - **Acht ungültige Themes** werden abgewiesen, ohne das gespeicherte Theme zu verändern; danach liefert das Theme weiterhin die Voreinstellungen. - Im Editormodell geprüft: Vererbung, einzelnes Zurücksetzen, Entfernen leerer Reste, Kopieren ohne geteilte Objekte und vollständiges Undo. - **Im Browser geprüft wurde nur der Aufbau, nicht die Bedienung.** Im laufenden Editor wurde über das DOM bestätigt, dass das Designpanel aus dem Serverschema entsteht: sieben Gruppen, 58 Felder, 11 Farbwähler, 12 Schieberegler, 34 Einheitenauswahlen. **Es wurde kein Gestaltungsvorgang von Hand ausgeführt**, keine Tastaturbedienung erprobt und keine axe- oder Lighthouse-Messung gemacht. Für ein Werkzeug dieser Größe ist das ein erheblicher Vorbehalt. diff --git a/docs/STATUS.md b/docs/STATUS.md index d72b894..1cf3a03 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,57 @@ # Projektstatus +## Globale Hinweise, der Schriftenknopf und die Gerätechips (15.09.2026) + +**Anlass:** Umut fehlte ein Hinweis, der auch außerhalb der Systemseite sagt, dass etwas zu tun ist; der Knopf „Schriften holen“ sah nicht aus wie die übrigen; und im Designbereich störte ein roter Rahmen an den Gerätechips, der abgeschnitten wirkte. + +**Hinweisstreifen auf jedem Verwaltungsbildschirm (`kant_admin_notices()`, `admin/shell.html.twig`).** Über dem Inhalt steht jetzt, was einen Knopf braucht, und der Knopf gleich daneben: „Fassung X ist verfügbar“ → *Jetzt aktualisieren* (System), „Schriftdateien fehlen: …“ → *Schriften holen* (Design). Keine Zeile davon geht ins Netz – die verfügbare Fassung steht im Protokoll der täglichen Aufgabe `update-check`, die fehlenden Schriften sind ein Blick in den Ordner. Der Updatehinweis erscheint nur für `ROLE_ADMIN`, weil nur die auf die Systemseite dürfen. Warm statt rot: es ist eine Aufgabe, kein Fehler. Auf 390 px bricht der Streifen um, der Knopf rutscht unter den Text. + +**Schriftenknopf.** Er trug gar keine Klasse und war deshalb der einzige Knopf der Verwaltung im Standardaussehen des Browsers. Jetzt `primary` (bzw. `secondary` beim erneuten Holen), und der ganze Abschnitt steht wie alles andere in einer Karte mit Kopf und Körper. + +**Gerätechips D/T/M (`editor.css`).** Der rote Kasten war `outline:2px` mit `outline-offset:1px` auf dem gerade bearbeiteten Gerät. Ein `outline` liegt außerhalb des Kästchens: bei 2 px Abstand lief er unter den Nachbarchip und wurde von der seitlich beschnittenen Sidebar gekappt – daher der abgeschnittene Eindruck. Jetzt zwei Zustände mit zwei Mitteln: + +- **belegt** (dieses Gerät trägt einen eigenen Wert): gefülltes Orange `--used: #9d4e0d`, weiße Schrift – 5,6:1 Kontrast. Dieselbe Farbe trägt der Punkt vor der Beschriftung, damit „hier steht ein eigener Wert“ überall gleich aussieht. +- **gerade bearbeitet**: 2 px Rahmen **innen** (`border-width`, kein `outline`), auf belegten Chips zusätzlich ein weißer Innenring. Nichts ragt mehr aus dem Kästchen, nichts wird beschnitten. + +Chips von 19 auf 22 px, Abstand von 2 auf 4 px – auf einen Blick lesbar, welche Geräte an einer Eigenschaft hängen. + +**Geprüft** (isolierte Kopie der Pilotdatenbank, künstlich auf „Lato 300 fehlt“ und „Fassung 9.9.9 verfügbar“ gesetzt): beide Streifen erscheinen auf `/admin`, tragen die richtigen Ziele, und auf 390 px bleibt die Dokumentbreite bei 390. Schriftenknopf trägt `primary`. Chips: belegte auf `rgb(157,78,13)`, bearbeitete mit 2 px Rahmen, kein gezeichnetes `outline` mehr. axe über die Editorseitenleiste ohne Verstöße. PHP-Tests: 498, ein bekannter Fehler (`testAnyPublishedPageCanBecomeTheRootAndEverythingFollows`). Screenshots: `reports/admin-hints/`. + +**Zu beachten:** `admin.css` und `editor.css` hängen an `?v={{ kant_version() }}`. Bis die `VERSION` steigt, liefert der Browser die alten Fassungen aus dem Zwischenspeicher. + +## Schriften nur noch vom eigenen Server, kein sichtbarer Schriftwechsel mehr (14.09.2026) + +**Anlass:** Umut sah im Browser, wie die Schrift kurz von fett auf dünn sprang, und stellte zwei Regeln auf: keine Schriften über Adressen von Google, und kein Nachladen nach dem fertigen Seitenaufbau. + +**Zwei getrennte Ursachen, beide belegt.** + +1. **Der späte Schnitt.** `FontLibrary::preload()` holte je Familie nur die **ersten zwei** Schnitte vorab. Das Kant-IT-Theme nennt Lato 400, 700 und 900; die Überschriften stehen auf 800/900 und landen damit auf Lato 900 – dem einzigen Schnitt ohne Vorabholen. Gemessen am Nachbau: `lato-400` und `lato-700` starteten bei 151 ms, `lato-900` erst bei 156 ms, also nach dem Stylesheet. Mit `font-display:swap` zeichnet der Browser die Überschrift bis dahin in der Ersatzschrift und tauscht sie danach – der gemeldete Sprung. Jetzt wird jeder Schnitt des Themes vorab geholt (lateinischer Grundbereich; die erweiterten Bereiche bleiben draußen). +2. **Google in der Editorvorschau.** `EditorController` und `PartialController` gaben der Vorlage `googleFonts` und **kein** `localFonts`. In der Vorschau kamen die Schriften damit bei jedem Aufruf von `fonts.googleapis.com` und `fonts.gstatic.com` – zwei fremde Verbindungen hintereinander, und genau dort arbeitet Umut am meisten. Beide Vorschauen nutzen jetzt dieselben lokalen Dateien wie die öffentliche Seite. + +**Die Regel steht jetzt im Code, nicht in der Einstellung.** `page.html.twig` hat keinen Google-Zweig mehr; fehlen die Schriftdateien, zeigt die Website die Ersatzschrift des Themes. Die Kopfzeilen lassen keinen fremden Schrifthost mehr zu – öffentlich `font-src 'self'`, in der Verwaltung `style-src 'self'; font-src 'self'` statt der bisherigen Ausnahmen für `fonts.googleapis.com`/`fonts.gstatic.com`. `Theme::googleFontsUrl()` ist entfernt; das Herunterladen der Dateien über die Theme-Seite bleibt unverändert und baut seine Adresse selbst. Die Vorabholen-Verweise stehen jetzt **vor** dem Stylesheet, und die Texte auf der Theme-Seite sagen nicht mehr „kommen zurzeit von Google“, sondern „die Dateien fehlen, bis dahin die Ersatzschrift“. + +**Geprüft** (Chromium, kalter Zwischenspeicher, isolierte Kopie der Pilotdatenbank): alle drei Schriftdateien starten bei 96 ms und sind bei 166–168 ms fertig, der erste sichtbare Inhalt erscheint bei 292 ms – die Schriften stehen also vor dem ersten Zeichnen, es gibt nichts mehr zu tauschen. Jede Datei wird genau einmal angefragt. Über die ganze Sitzung – öffentliche Seite, Anmeldung, Seiteneditor mit Vorschau, Bereiche – **keine einzige Anfrage außerhalb des eigenen Servers**. PHP-Tests: 498, ein bekannter Fehler (siehe unten). `npm run test:browser` und der Lighthouse-Lauf sind **nicht** gelaufen. + +**Tests angepasst:** `testFontsAreReadFromGoogleStylesheetAndServedFromHere` prüft jetzt die Ersatzschrift statt des Google-Verweises und alle drei Vorabhol-Dateien; `testThemeDrivesEveryPageAndGoogleFontsOpenTheirOwnPolicy` heißt `…AndNoFontIsEverFetchedFromGoogle` und prüft die engere Kopfzeile. In `testEveryAllowedIconHasADrawingAndTakesItsColourFromTheBlock` stand eine echte Zeilenschaltung im Suchmuster; unter Windows mit `core.autocrlf=true` schlägt das fehl, sobald die Arbeitskopie CRLF trägt. Das Muster nimmt jetzt `\R` und gilt für beide Schreibweisen; die Vorlage selbst bleibt unverändert. + +**Weiterhin offen, nicht von dieser Aufgabe:** `CmsTest::testAnyPublishedPageCanBecomeTheRootAndEverythingFollows` schlägt fehl – der Test erwartet `Start`, das Menü schreibt seit den Symbolen `` um die Beschriftung. + +## Kopfmenü im Hochformat und die Update-Ansicht auf dem Handy (14.09.2026) + +**Anlass:** Umut meldete, das Mobilmenü im Kopf erscheine nur im Querformat, und bat darum, Texte und Ansichten der Update-Seite fürs Handy zu kürzen. + +**Befund zum Kopfmenü – falsch gebaut, kein Fehler im CMS.** Der Kopfbereich trug zwei Menüblöcke: `area-header-menu` (waagerecht, `mobile: display:none`) und daneben `area-header-burger` (eigener Burger-Block, nur mobil sichtbar). Der veröffentlichte Stand auf `web.kant-it.de` kennt den zweiten Block nicht – abgerufen am 14.09.2026 enthält das Markup dort nur `area-header-menu` samt `@media (max-width:767px){#area-header-menu{display:none}}`. Damit ist im Hochformat (bis 767 px) gar kein Menü da, während im Querformat (768–1024 px) das Standardverhalten `collapse: tablet` denselben Block zum Burger zusammenlegt und ein Knopf erscheint. Genau die gemeldete Beobachtung. In `var/kantit.db` lag zwar ein zweiter Burger-Block, der das Loch stopfte, aber mit eigener Gestaltung: im Hochformat ein 161 px breiter Knopf, im Querformat der 101 px breite des waagerechten Menüs. + +**Behoben in `var/kantit.db`:** ein einziger Menüblock `area-header-menu` mit `collapse: tablet` und `burgerLabel: both`, ohne `display:none` auf Mobil, `burgerFontSize: 14px`; der doppelte Block `area-header-burger` ist entfernt (Kopfbereich Fassung 8, Sicherung `var/kantit.db.vor-kopfmenue-20260914-2244.bak`). Geprüft am laufenden Nachbau: 375 px Hochformat und 844 px Querformat zeigen denselben Knopf („MENÜ“, 101 × 45 px, 14 px), die Liste bleibt in beiden verborgen und die aufklappende Fläche liegt über die volle Breite unter dem Kopf; ab 1025 px stehen die vier Einträge wieder waagerecht. Startkonfiguration `kantit` (Port 8130) in `.claude/launch.json` ergänzt. + +**Update-Ansicht (`templates/admin/system.html.twig`, `public/assets/admin.css`).** Die Seite schob sich auf dem Handy seitlich aus dem Bild: `.stack` ist ein Raster mit automatischer Spalte, und eine automatische Rasterspalte wächst auf die Mindestbreite ihres breitesten Inhalts – eine lange Datenbankpfadangabe reichte. `.stack` bekommt `minmax(0, 1fr)`, `.facts` beide Spalten als `minmax(0, …)` und unter 620 px nur noch eine, `.card-body` und die Prüfpunkte `min-width: 0` samt `overflow-wrap: anywhere`. Die Sicherungstabelle wird unter 620 px zu gestapelten Blöcken (`.table-stack`, Spaltenüberschrift als `data-label`), statt waagerecht zu scrollen. Texte gekürzt und das Kleingedruckte in zugeklappte `
` gelegt, ebenso die älteren 15 der 20 Protokollzeilen. + +**Geprüft:** 390 × 844 mit Touch-Emulation gegen eine isolierte Kopie der Pilotdatenbank (`tests/pilot-fixture.php`, eigenes Zufallskonto): Dokumentbreite 390 statt 492, kein Element mehr breiter als der Inhaltsbereich, Wortzahl der Seite 1017 → 556, axe ohne Verstöße. `/admin`, `/admin/media`, `/admin/menus`, `/admin/forms`, `/admin/theme`, `/admin/settings` bleiben mobil bei 390 px; dieselben Seiten und `/admin/system` bei 1440 px unverändert breit. Screenshots: `reports/system-mobile/`. + +**Bekannter Fehler, nicht von dieser Aufgabe:** `CmsTest::testAnyPublishedPageCanBecomeTheRootAndEverythingFollows` schlägt fehl (498 Tests, 1 Fehler) – der Test erwartet `Start`, das Menü schreibt seit den Symbolen `` um die Beschriftung. Auch ohne diese Änderungen und mit beiden PHP-Fassungen reproduzierbar. + +**Nächster Schritt:** Umut spielt `var/kantit.db` auf den Server; erst dort ist das Kopfmenü im Hochformat live. Für die Admin-Änderungen die `VERSION` beim nächsten Release erhöhen, sonst liefert der Browser `admin.css?v=1.13.0` weiter aus dem Zwischenspeicher. + ## 1.14.0 – Öffnungszeiten, Einsatzgebiet und FAQ in den strukturierten Daten; Elektro Bernhard aktualisiert (14.09.2026) **Anlass:** Umut nannte die tatsächlichen Bürozeiten (Mo–Fr 7:30–17:00), das Einsatzgebiet von Karlsruhe bis Offenburg und wollte die FAQ „mit allem, was notwendig ist“ – das JSON-LD, das das CMS bisher nicht hergab (siehe Lücke in den Einträgen vom Abgleich). @@ -9,6 +61,7 @@ **Elektro Bernhard:** Bürozeiten überall Mo–Fr 07:30–17:00 Uhr (Fuß, Kontakt, Beschreibung, FAQ); Einsatzgebiet Baden-Baden, Sinzheim, Hügelsheim, Iffezheim, Bühl, Rastatt, Kuppenheim, Muggensturm, Gaggenau, Gernsbach, Murgtal, Ettlingen, Karlsruhe, Achern, Oberkirch, Offenburg in Text und `areaServed`; FAQ um „Wann ist das Büro erreichbar?“ und „Wie läuft eine Anfrage ab?“ ergänzt und als FAQ gekennzeichnet. Die Öffnungszeiten weichen bewusst von der laufenden Website ab (dort Mo–Do 7:00–16:30, Fr bis 13:00) – Umuts Angabe gilt. **Arbeitsweise:** Weil eine parallele Sitzung im Hauptarbeitsbaum dreizehn Dateien offen hatte, darunter Validator, PageController und Editor, entstand diese Änderung im eigenen Worktree `var/wt-faq` auf dem Zweig `oeffnungszeiten-faq` und wurde von dort auf `main` gebracht. Der Hauptarbeitsbaum muss danach `git pull` ausführen. + ## 1.13.0 – Drei Symbole mehr, gestaltbarer Menü-Knopf, Nachbau „Tagespflege Moldovan“ (14.09.2026) **Anlass:** Umut ließ die ZIP-Vorlage „Tagespflege Moldovan“ nachbauen. Beim Nachbau fehlten dem Symbolsatz genau die drei Bilder, die die Vorlage zeigt; danach kamen zwei Meldungen vom Telefon: das Menü sei nicht zu sehen, und „Scroll to top“ funktioniere nicht. diff --git a/public/assets/admin.css b/public/assets/admin.css index b967ddc..969659c 100644 --- a/public/assets/admin.css +++ b/public/assets/admin.css @@ -211,13 +211,37 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 } .shell-heading p { font-size: 13px; margin: 7px 0 0; max-width: 62ch } .shell-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap } .shell-body { padding: 30px 38px 60px; max-width: 1180px; width: 100% } -.stack { display: grid; gap: 20px } +/* Eine Spalte, die nie breiter wird als der Bildschirm: ohne `minmax(0, 1fr)` waechst + die automatische Rasterspalte auf die Mindestbreite ihres breitesten Inhalts - auf dem + Handy schiebt eine einzige lange Pfadangabe dann die ganze Seite zur Seite. */ +.stack { display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px } .notice { padding: 13px 17px; border-radius: 8px; line-height: 1.6; font-size: 13px; margin: 0 } .notice.ok { background: #edf3dc; color: #2c5340; border: 1px solid #c5d89a } .error, .notice.error { background: #fff0eb; color: var(--danger); border: 1px solid #e2b2a7 } .shell-body > .notice { margin-bottom: 4px } +/* Der Hinweisstreifen ueber jedem Verwaltungsbildschirm: eine Sache, die getan werden will, und + gleich daneben der Knopf, der sie tut. Warm statt rot - es ist eine Aufgabe, kein Fehler. */ +.shell-notice { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px 18px; + margin: 0 0 4px; + padding: 12px 14px 12px 17px; + border: 1px solid #e3cf9b; + border-radius: 10px; + background: #fdf6e4; + color: #5b4412; + line-height: 1.6; + font-size: 13px; +} +.shell-notice > span { min-width: 0; flex: 1 1 260px } +.shell-notice .secondary { flex: none; border-color: #dcc48a; background: var(--paper); color: #5b4412 } +.shell-notice .secondary:hover { background: #fbeecd; border-color: #cbb072 } + /* ---- cards and sections ---------------------------------------------------------------- */ .card { background: var(--paper); border: 1px solid var(--line); border-radius: 12px } @@ -232,7 +256,12 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 } } .card-head h2, .card-head h3 { margin: 0 } .card-head .muted { font-size: 12px } -.card-body { padding: 8px 22px 24px } +.card-body { padding: 8px 22px 24px; min-width: 0 } +.card-body code { overflow-wrap: anywhere } +/* Das Kleingedruckte einer Karte: zugeklappt, damit auf einem Handy der Knopf oben bleibt. */ +.hint { margin: 12px 0 0 } +.hint > summary { font-size: 12px; font-weight: 600; color: var(--muted); cursor: pointer; min-height: 44px; display: flex; align-items: center } +.hint[open] > summary { margin-bottom: 2px } /* A heading for a group of rows, outside any card. */ .section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px 20px; flex-wrap: wrap; margin: 16px 2px -6px } .section-head h2 { margin: 0 } @@ -550,6 +579,18 @@ details.card > summary:hover { background: #fafbf8 } .row.depth-1, .row.depth-2, .row.depth-3 { margin-left: 14px } .login-card { padding: 26px } .login-body { padding: 16px } + /* Zwei Rasterspalten aus Bezeichner und Wert sind auf einem Handy eine Spalte zu viel. */ + .facts { grid-template-columns: minmax(0, 1fr); gap: 2px } + .facts dt { margin-top: 10px } + .facts dt:first-child { margin-top: 0 } + /* Eine sechsspaltige Tabelle passt auf kein Handy. Jede Zeile wird zu einem Block, und die + Spaltenueberschrift steht als Bezeichner vor ihrem Wert. */ + .table-stack thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%) } + .table-stack, .table-stack tbody, .table-stack tr, .table-stack td { display: block; width: 100% } + .table-stack tr { border: 1px solid var(--line); border-radius: 10px; padding: 8px 12px; margin-top: 10px } + .table-stack tbody tr + tr td { border-top: 0 } + .table-stack td { display: flex; gap: 14px; justify-content: space-between; align-items: baseline; padding: 5px 0; text-align: right } + .table-stack td::before { content: attr(data-label); color: var(--muted); text-align: left; flex: none } } /* A group of related checkboxes needs a name of its own, not just a label per box. */ @@ -586,11 +627,12 @@ details.card > summary:hover { background: #fafbf8 } /* ---- system: preflight, update steps, log ---------------------------------------------- */ -.facts { display: grid; grid-template-columns: auto 1fr; gap: 8px 20px; margin: 14px 0 0; font-size: 13px } +.facts { display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr); gap: 8px 20px; margin: 14px 0 0; font-size: 13px } .facts dt { color: var(--muted) } .facts dd { margin: 0; overflow-wrap: anywhere } .check-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 10px; font-size: 13px; line-height: 1.55 } .check-list li { display: flex; gap: 11px; align-items: flex-start } +.check-list li > span { min-width: 0; overflow-wrap: anywhere } .check-mark { display: grid; place-items: center; diff --git a/public/assets/editor.css b/public/assets/editor.css index 4ce0829..4968eaf 100644 --- a/public/assets/editor.css +++ b/public/assets/editor.css @@ -3,6 +3,9 @@ The layout follows the Flatsome UX Builder: a dark bar on top, one sidebar on the left that shows one thing at a time, and the page itself filling the rest. */ +/* Die Farbe fuer "hier steht ein eigener Wert". Dunkler als der Fokusring, damit weisse + Schrift darauf die Kontrastschwelle sicher nimmt (5,6:1). */ +:root{--used:#9d4e0d} dialog{max-width:450px;width:calc(100% - 40px);padding:28px;background:var(--paper);border:1px solid var(--line);border-radius:12px;color:var(--ink);box-shadow:0 20px 80px #1b2a2433} dialog::backdrop{background:#172e2966} dialog p{font-size:14px;line-height:1.7;color:var(--muted)} @@ -185,8 +188,10 @@ html:has(>.editor-body){overflow:hidden;height:100%} .design-row{margin-top:14px} .design-row-head{display:flex;justify-content:space-between;align-items:center;gap:8px} .design-row-head label{margin:0;font-size:11px;line-height:1.5;flex:1;min-width:0} -.design-own .design-row-head label{color:#2f5d4a;font-weight:700} -.design-own .design-row-head label::before{content:'● ';color:var(--accent)} +/* Belegte Zeilen tragen dieselbe Farbe wie die belegten Geraetechips - eine Farbe, eine + Bedeutung: hier steht ein eigener Wert und nicht der geerbte. */ +.design-own .design-row-head label{color:#6f3c0a;font-weight:700} +.design-own .design-row-head label::before{content:'● ';color:var(--used)} .design-reset-one{font-size:14px;padding:2px 6px;min-height:24px;line-height:1} .design-inherited{font-size:10px;color:var(--muted);margin:5px 0 0;line-height:1.45} .length-control{display:grid;grid-template-columns:minmax(0,1fr) 74px;gap:6px} @@ -210,12 +215,20 @@ html:has(>.editor-body){overflow:hidden;height:100%} .side-unit{display:flex;align-items:center;gap:7px;margin-top:7px} .side-unit label{margin:0;font-size:10px;color:var(--muted)} .side-unit-select{flex:1;min-width:0} -/* The device chips on a single option: which devices carry an own value, and switch to one. */ -.device-chips{display:flex;gap:2px;margin-left:auto} -.device-chip{width:19px;height:19px;display:grid;place-items:center;padding:0;min-height:0;border:1px solid var(--line);border-radius:4px;background:#fff;color:var(--muted);font:700 9px/1 system-ui,sans-serif;cursor:pointer} -.device-chip:hover{border-color:var(--accent)} -.device-chip-own{background:#eef5e6;border-color:var(--accent);color:var(--accent)} -.device-chip[aria-pressed=true]{outline:2px solid var(--ring,#b55b12);outline-offset:1px} +/* The device chips on a single option: which devices carry an own value, and switch to one. + + Zwei Zustaende, zwei Mittel. "Belegt" - dieses Geraet traegt einen eigenen Wert - ist die + Auskunft, die man ueber die ganze Liste hinweg sucht, und bekommt deshalb die kraeftige Farbe. + "Gerade bearbeitet" ist ein Rahmen, und zwar ein Rahmen INNEN: ein `outline` liegt ausserhalb + des Kaestchens, laeuft bei 2 px Abstand unter den Nachbarn und wird von der seitlich + beschnittenen Spalte gekappt - es sah aus wie ein abgeschnittener roter Kasten. */ +.device-chips{display:flex;gap:4px;margin-left:auto} +.device-chip{width:22px;height:22px;display:grid;place-items:center;padding:0;min-height:0;border:1px solid var(--line);border-radius:5px;background:#fff;color:var(--muted);font:700 10px/1 system-ui,sans-serif;cursor:pointer} +.device-chip:hover{border-color:var(--ink)} +.device-chip-own{background:var(--used);border-color:var(--used);color:#fff} +.device-chip[aria-pressed=true]{border-width:2px;border-color:var(--ink)} +.device-chip-own[aria-pressed=true]{border-color:var(--ink);box-shadow:inset 0 0 0 1px #fff} +.device-chip:focus-visible{outline-width:2px;outline-offset:1px} .design-side-head{margin-bottom:5px} .design-side-head .design-group-label{margin:0} /* A number field that can be dragged sideways. The cursor is the only announcement it needs. */ diff --git a/src/Content/FontLibrary.php b/src/Content/FontLibrary.php index 6286b35..eccf030 100644 --- a/src/Content/FontLibrary.php +++ b/src/Content/FontLibrary.php @@ -108,9 +108,11 @@ final class FontLibrary /** * 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. + * Jeder Schnitt, den das Theme nennt, im lateinischen Grundbereich. Die früheren „ersten + * zwei je Familie“ ließen genau den Schnitt aus, der die Überschriften trägt: Lato 900 lud + * erst nach dem Stylesheet, und mit `font-display:swap` sprang die Überschrift sichtbar von + * der Ersatzschrift auf die richtige. Die erweiterten Bereiche bleiben draußen - sie werden + * nur für Zeichen gebraucht, die auf den meisten Seiten nicht vorkommen. * * @return list */ @@ -118,7 +120,7 @@ final class FontLibrary { $preload = []; foreach ($this->wanted($theme) as $family => $weights) { - foreach (array_slice($weights, 0, 2) as $weight) { + foreach ($weights as $weight) { $name = $this->slug($family).'-'.$weight.'-latin.woff2'; if (is_file($this->directory.'/'.$name)) { $preload[] = '/assets/fonts/'.$name; diff --git a/src/Content/Theme.php b/src/Content/Theme.php index 96b7472..7ed14fb 100644 --- a/src/Content/Theme.php +++ b/src/Content/Theme.php @@ -329,19 +329,4 @@ final class Theme ).ButtonDesign::css($theme['buttons'] ?? ButtonDesign::defaults($base)); } - /** - * The Google Fonts stylesheet for every family that comes from there, or null when none does. - * Chosen by the user; it means one request to a third party per page view. - */ - public function googleFontsUrl(array $theme): ?string - { - $families = []; - foreach ($theme['fonts'] as $font) { - if ('google' === $font['source'] && '' !== $font['family']) { - $families[] = sprintf('family=%s:wght@%s', str_replace(' ', '+', $font['family']), implode(';', $font['weights'])); - } - } - - return [] === $families ? null : 'https://fonts.googleapis.com/css2?'.implode('&', array_unique($families)).'&display=swap'; - } } diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php index f887c81..61875ca 100644 --- a/src/Controller/EditorController.php +++ b/src/Controller/EditorController.php @@ -5,6 +5,7 @@ namespace App\Controller; use App\Content\CustomCode; use App\Content\DemoContent; use App\Content\DocumentValidator; +use App\Content\FontLibrary; use App\Content\MenuRepository; use App\Content\PageRepository; use App\Content\PartialRepository; @@ -28,7 +29,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; #[Route('/%kant.admin_path%')] final class EditorController extends AbstractController { - public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private CsrfTokenManagerInterface $csrf, private RequestStack $requests) + public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private FontLibrary $fonts, private CsrfTokenManagerInterface $csrf, private RequestStack $requests) { } @@ -435,7 +436,7 @@ final class EditorController extends AbstractController $navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path']); } - return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'areas' => $areas, 'sections' => $sections, 'needsTabs' => false, 'pageStyles' => $this->theme->css($theme).$this->styles->compileDocument($document), 'googleFonts' => $this->theme->googleFontsUrl($theme)]); + return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'areas' => $areas, 'sections' => $sections, 'needsTabs' => false, 'pageStyles' => $this->theme->css($theme).$this->styles->compileDocument($document), 'localFonts' => $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : []]); } if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion']) || $body['expectedVersion'] < 0) { throw new \InvalidArgumentException(); diff --git a/src/Controller/PageController.php b/src/Controller/PageController.php index 946abdb..da00f6b 100644 --- a/src/Controller/PageController.php +++ b/src/Controller/PageController.php @@ -70,8 +70,6 @@ final class PageController extends AbstractController private function renderPage(Request $request, array $page, string $path, int $status = 200): Response { $styles = ''; - $googleFonts = null; - $localFonts = []; // The page decides which header and footer it gets - including none. $areas = $this->areas($status >= 400 ? null : $page); $theme = $this->theme->load(); @@ -88,13 +86,11 @@ final class PageController extends AbstractController foreach ([...array_values($areas), ...array_values($sections)] as $extra) { $styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra); } - // 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); - } + // Schriften kommen ausschliesslich vom eigenen Server. Fehlen die Dateien, zeigt die + // Website die Ersatzschrift des Themes - nie ein Stylesheet von fonts.googleapis.com: + // das gaebe die IP-Adresse jedes Besuchers an einen Dritten weiter und haengt den + // Seitenaufbau an zwei fremde Verbindungen nacheinander. + $localFonts = $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : []; $response = $this->render('page.html.twig', [ 'page' => $page, 'canonical' => $status >= 400 ? null : rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $this->settings->homePath()), @@ -133,7 +129,6 @@ final class PageController extends AbstractController // ist gar kein Bild, sondern der Hintergrund des ersten Abschnitts. 'heroBackground' => $this->heroBackground($areas['header'] ?? null, $page['document']), 'pageStyles' => $styles, - 'googleFonts' => $googleFonts, 'localFonts' => $localFonts, 'styleBundle' => $this->bundle->url([] !== $localFonts), ], new Response(status: $status)); @@ -151,7 +146,7 @@ final class PageController extends AbstractController $response->headers->set('X-Robots-Tag', 'noindex, follow'); } - return $this->shell($response, $wantsScript, $styles, null !== $googleFonts, $frames); + return $this->shell($response, $wantsScript, $styles, $frames); } /** @@ -167,18 +162,15 @@ final class PageController extends AbstractController * Nothing is added to the policy for an embed still waiting * for consent. */ - private function shell(Response $response, bool $allowScript = false, string $styles = '', bool $googleFonts = false, array $frameHosts = []): Response + private function shell(Response $response, bool $allowScript = false, string $styles = '', array $frameHosts = []): Response { $styleSources = ["'self'"]; if ('' !== $styles) { $styleSources[] = sprintf("'sha256-%s'", base64_encode(hash('sha256', $styles, true))); } + // Stylesheets und Schriftdateien kommen nur von hier. Kein fonts.googleapis.com, kein + // fonts.gstatic.com - die Regel steht in der Kopfzeile und nicht nur in der Vorlage. $fontSources = ["'self'"]; - if ($googleFonts) { - // Chosen deliberately: the stylesheet comes from Google and the files from its CDN. - $styleSources[] = 'https://fonts.googleapis.com'; - $fontSources[] = 'https://fonts.gstatic.com'; - } $response->headers->set('Cache-Control', 'no-store'); $response->headers->set('X-Content-Type-Options', 'nosniff'); $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); diff --git a/src/Controller/PartialController.php b/src/Controller/PartialController.php index 624a11f..2b5ff86 100644 --- a/src/Controller/PartialController.php +++ b/src/Controller/PartialController.php @@ -4,6 +4,7 @@ namespace App\Controller; use App\Content\CustomCode; use App\Content\DocumentValidator; +use App\Content\FontLibrary; use App\Content\MenuRepository; use App\Content\PartialRepository; use App\Content\StyleCompiler; @@ -35,6 +36,7 @@ final class PartialController extends AbstractController private Theme $theme, private StyleCompiler $styles, private CustomCode $code, + private FontLibrary $fonts, private CsrfTokenManagerInterface $csrf, ) { } @@ -175,7 +177,7 @@ final class PartialController extends AbstractController 'needsTabs' => false, 'forms' => [], 'pageStyles' => $this->theme->css($theme).$this->styles->compileDocument($document), - 'googleFonts' => $this->theme->googleFontsUrl($theme), + 'localFonts' => $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : [], ]); } diff --git a/src/EventSubscriber/AdminHeadersSubscriber.php b/src/EventSubscriber/AdminHeadersSubscriber.php index 1eafd80..72ea372 100644 --- a/src/EventSubscriber/AdminHeadersSubscriber.php +++ b/src/EventSubscriber/AdminHeadersSubscriber.php @@ -51,12 +51,12 @@ final class AdminHeadersSubscriber implements EventSubscriberInterface $headers->set('X-Content-Type-Options', 'nosniff'); $headers->set('Referrer-Policy', 'same-origin'); $headers->set('Permissions-Policy', 'accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()'); - // The preview must show the same typeface as the published page, so the font hosts are - // allowed here as well. Everything else stays as narrow as on the public side. + // Die Vorschau zeigt dieselbe Schrift wie die veroeffentlichte Seite - und die liegt, + // genau wie dort, auf diesem Server. Fremde Schrifthosts braucht sie nicht. // The nonce only ever adds the one script it names; 'self' keeps applying to everything // else, so nothing is loosened by it. $nonce = $event->getRequest()->attributes->get('csp_nonce'); $script = is_string($nonce) && '' !== $nonce ? sprintf("'self' 'nonce-%s'", $nonce) : "'self'"; - $headers->set('Content-Security-Policy', "default-src 'self'; script-src {$script}; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"); + $headers->set('Content-Security-Policy', "default-src 'self'; script-src {$script}; style-src 'self'; font-src 'self'; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"); } } diff --git a/src/Twig/VersionExtension.php b/src/Twig/VersionExtension.php index 21c4264..5a788db 100644 --- a/src/Twig/VersionExtension.php +++ b/src/Twig/VersionExtension.php @@ -2,25 +2,71 @@ namespace App\Twig; +use App\Content\FontLibrary; +use App\Content\Theme; use App\Ops\Updater; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; /** - * The running version, for the administration shell. + * Fassung und offene Hinweise für die Verwaltungshülle. * - * A function rather than a global, so the templates get exactly this one read-only answer and not - * a whole updater with an update() method on it. + * Funktionen statt Variablen, damit die Vorlagen genau diese Antworten bekommen und nicht einen + * ganzen Updater mit einem update() daran. */ final class VersionExtension extends AbstractExtension { - public function __construct(private Updater $updater, private \Doctrine\DBAL\Connection $db) + public function __construct(private Updater $updater, private \Doctrine\DBAL\Connection $db, private FontLibrary $fonts, private Theme $theme) { } public function getFunctions(): array { - return [new TwigFunction('kant_version', $this->updater->version(...)), new TwigFunction('kant_update_version', $this->available(...))]; + return [ + new TwigFunction('kant_version', $this->updater->version(...)), + new TwigFunction('kant_update_version', $this->available(...)), + new TwigFunction('kant_admin_notices', $this->notices(...)), + ]; + } + + /** + * Was auf jedem Verwaltungsbildschirm oben stehen soll, weil es einen Knopf braucht. + * + * Keine Zeile davon geht ins Netz: die verfügbare Fassung steht im Protokoll der täglichen + * Aufgabe, die fehlenden Schriften stehen als Dateien im Ordner. Ein Hinweis, der bei jedem + * Klick eine fremde Verbindung öffnet, wäre die Sorte Hinweis, die man abschaltet. + * + * @return list + */ + public function notices(): array + { + $notices = []; + if (null !== $version = $this->available()) { + $notices[] = [ + 'kind' => 'update', + 'text' => sprintf('Fassung %s ist verfügbar; installiert ist %s.', $version, $this->updater->version()), + 'route' => 'admin_system', + 'action' => 'Jetzt aktualisieren', + 'admin' => true, + ]; + } + try { + $missing = $this->fonts->missing($this->theme->load()); + } catch (\Throwable) { + // Eine Installation, die noch auf ihre Migrationen wartet, hat noch kein Theme. + $missing = []; + } + if ([] !== $missing) { + $notices[] = [ + 'kind' => 'fonts', + 'text' => sprintf('Schriftdateien fehlen auf diesem Server: %s. Bis dahin zeigt die Website die Ersatzschrift.', implode(', ', $missing)), + 'route' => 'admin_theme', + 'action' => 'Schriften holen', + 'admin' => false, + ]; + } + + return $notices; } public function available(): ?string diff --git a/templates/admin/shell.html.twig b/templates/admin/shell.html.twig index 2a0fe63..0a8e2cd 100644 --- a/templates/admin/shell.html.twig +++ b/templates/admin/shell.html.twig @@ -71,6 +71,15 @@
{% block actions %}{% endblock %}
+ {# Offene Handlungen, auf jedem Bildschirm gleich: fehlende Schriften, eine neue Fassung. + Sie stehen hier statt nur auf ihrer eigenen Seite - einen Hinweis findet nur, wer schon + weiss, wo er steht. Jeder traegt den Knopf, der ihn erledigt. #} + {% for hinweis in kant_admin_notices() %}{% if not hinweis.admin or is_granted('ROLE_ADMIN') %} +

+ {{ hinweis.text }} + {{ hinweis.action }} → +

+ {% endif %}{% endfor %} {% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}

{{ message }}

{% endfor %}{% endfor %} diff --git a/templates/admin/system.html.twig b/templates/admin/system.html.twig index 924e1ed..4a2048a 100644 --- a/templates/admin/system.html.twig +++ b/templates/admin/system.html.twig @@ -2,7 +2,7 @@ {% set nav = 'system' %} {% block title %}System{% endblock %} {% block heading %}System und Update{% endblock %} -{% block lead %}Welche Fassung läuft, ob ein Update möglich ist, und der Weg dorthin: sichern, Wartungszustand, Dateien tauschen, migrieren.{% endblock %} +{% block lead %}Fassung, Updateweg und Sicherungen dieser Installation.{% endblock %} {% block content %}
@@ -13,13 +13,13 @@
Fassung
{{ state.version }}
-
Adresse der Verwaltung
+
Verwaltung
/{{ adminPath }} {% if adminPath == 'admin' %} - – die Adresse, die jeder Scanner zuerst probiert. Ändern mit php bin/console cms:admin:path <neu>; sie steht danach in .env.local und in keiner öffentlichen Datei. + – Standardpfad, den jeder Scanner zuerst probiert. Ändern: php bin/console cms:admin:path <neu>. {% else %} - – geändert. Sie wird in der robots.txt nicht genannt; notiere sie dir an einem sicheren Ort. Zurück mit php bin/console cms:admin:path admin. + – geändert; steht in keiner öffentlichen Datei. Zurück: php bin/console cms:admin:path admin. {% endif %}
Datenbank
@@ -28,17 +28,17 @@ {% if database.datei %} {{ database.datei }} {% if database.bytes %}· {{ (database.bytes / 1048576)|number_format(1, ',', '.') }} MB{% endif %} - {% if not database.schreibbar %}
Nicht beschreibbar. Nach einem Upload per FTP gehört die Datei dem FTP-Benutzer; PHP läuft als ein anderer. SQLite braucht Schreibrechte auf die Datei und auf den Ordner darüber.{% endif %} + {% if not database.schreibbar %}
Nicht beschreibbar. SQLite braucht Schreibrechte auf die Datei und auf den Ordner darüber – nach einem FTP-Upload gehört sie dem FTP-Benutzer.{% endif %} {% else %} {{ database.adresse }} {% endif %} -
Eingestellt in {{ database.quelle }}. Die Installationskonfiguration steht in .env. Explizite Server-Umgebungsvariablen haben Vorrang. +
Eingestellt in {{ database.quelle }}; Server-Umgebungsvariablen haben Vorrang. {% if state.worktree %}
Stand
{{ state.describe }}{% if state.commit != 'unbekannt' %} · Commit {{ state.commit }}{% endif %}
{% else %}
Eingerichtet über
-
das Releasearchiv – kein Git in diesem Verzeichnis, und dafür ist es auch nicht gedacht. Updates laufen über die Karte weiter unten.
+
Releasearchiv – kein Git; Updates laufen über die Karte unten.
{% endif %} {% if state.worktree %}
Verfügbar
@@ -55,7 +55,7 @@
{% if state.maintenance %} - + {% endif %}
@@ -74,7 +74,7 @@ vollkommen in Ordnung ist. #} {% if state.worktree %}
-

Vorprüfung

Jede Bedingung wird geprüft, nicht angenommen
+

Vorprüfung

{{ checks|length }} Bedingungen
    {% for check in checks %} @@ -97,9 +97,9 @@ {{ pendingMigrations|length }} Stück
-

Der Code ist neuer als die Datenbank. Das passiert, wenn ein Update von Hand über FTP eingespielt wurde – dann fehlt der Schritt, den sonst der Updater übernimmt.

+

Der Code ist neuer als die Datenbank – typisch nach einem Update von Hand über FTP.

    {% for name in pendingMigrations %}
  • {{ name }}
  • {% endfor %}
-

Lege vorher eine Sicherung an: Migrationen sind nicht umkehrbar, der Rückweg ist die Sicherung.

+

Vorher sichern: Migrationen sind nicht umkehrbar, der Rückweg ist die Sicherung.

@@ -111,7 +111,7 @@

{{ state.worktree ? 'Update aus dem Releasearchiv' : 'Update einspielen' }}

- {{ state.worktree ? 'Alternative zum Weg über Git' : 'holt die neue Fassung und tauscht die Dateien' }} + {{ state.worktree ? 'Alternative zum Weg über Git' : 'holt die neue Fassung' }}
{% if archivePending %} @@ -122,14 +122,19 @@ {% else %} -

Dieser Weg braucht kein Git: Er lädt das Archiv der neuen Fassung von der Releaseseite, vergleicht die Prüfsumme, sichert Datenbank und Medien, schaltet den Wartungszustand ein und ersetzt die Dateien der Anwendung. Unberührt bleiben var/, .env, .env.local, die geladenen Schriften und das erzeugte Stylesheet-Bündel. Der vorherige Stand wird beiseitegelegt, nicht gelöscht.

-

Danach folgt ein zweiter Knopf für die Migrationen. Das ist kein Umweg: Erst eine neue Anfrage startet mit dem Code der neuen Fassung – ein Prozess, der sich selbst ausgetauscht hat, arbeitet mit dem alten weiter.

+

Ohne Git: Archiv laden, Prüfsumme vergleichen, Datenbank und Medien sichern, Wartungszustand ein, Dateien tauschen. Der vorherige Stand wird beiseitegelegt, nicht gelöscht.

+ {# Das Kleingedruckte steht zugeklappt: Wer aktualisiert, braucht zuerst den Knopf. #} +
+ Was unberührt bleibt +

var/, .env, .env.local, die geladenen Schriften und das erzeugte Stylesheet-Bündel.

+

Danach folgt ein zweiter Knopf für die Migrationen. Das ist kein Umweg: Erst eine neue Anfrage startet mit dem Code der neuen Fassung.

+
{% if archiveVersion %}

Verfügbar: {{ archiveVersion }} (installiert: {{ state.version }}).

{% elseif asked %}

Die Releaseseite nennt nichts Neueres als {{ state.version }}.

{% else %} -

Nachsehen, ob es etwas Neues gibt – das fragt die Releaseseite und dauert einen Moment.

+

Nachsehen, ob es etwas Neues gibt – dauert einen Moment.

{% endif %}
@@ -150,9 +155,12 @@

Update einspielen

{{ ready ? 'Vorprüfung bestanden' : 'erst die Vorprüfung erfüllen' }}
-

Der Updater sichert zuerst Datenbank und Umgebungsdateien, schaltet den Wartungszustand ein, wechselt auf den Tag, installiert Abhängigkeiten, migriert, räumt den Cache und prüft, ob die Installation antwortet. Scheitert ein Schritt, wird auf den vorherigen Stand zurückgesetzt – und nur wenn die Migration schon begonnen hatte, auch die Datenbank zurückgeschrieben.

-

Der Medienordner wird nicht gesichert. Er ist zu groß, und kein Update schreibt darin. Ein Update ist keine Datensicherung.

-

Auf manchen Hostings bricht ein Browseraufruf nach kurzer Zeit ab. Dann ist php bin/console cms:update der verlässliche Weg – dort gibt es dieselben Schritte mit --check, --dry-run und --to=v1.2.0.

+

Sichern, Wartungszustand ein, auf den Tag wechseln, Abhängigkeiten, Migrationen, Cache, Antwortprüfung. Scheitert ein Schritt, wird zurückgesetzt – die Datenbank nur, wenn die Migration schon begonnen hatte.

+
+ Zwei Hinweise vorab +

Der Medienordner wird nicht gesichert. Ein Update ist keine Datensicherung.

+

Bricht der Browseraufruf auf dem Hosting ab: php bin/console cms:update – dieselben Schritte mit --check, --dry-run und --to=v1.2.0.

+
@@ -164,7 +172,7 @@
-

Prüft alles und verändert nichts. Nimm das Häkchen weg, wenn es wirklich laufen soll.

+

Prüft alles und verändert nichts.

@@ -175,7 +183,7 @@ {% if steps is not empty %}
-

Letzter Durchlauf

Schritt für Schritt
+

Letzter Durchlauf

{{ steps|length }} Schritte
    {% for step in steps %} @@ -197,31 +205,31 @@

    Sicherungen

    {{ backups|length }} vorhanden · var/backups
    -

    Eine Sicherung enthält die Datenbank als vollständige Kopie – nicht als Dateikopie während des Schreibens, die unbrauchbar sein kann – und den Medienordner. Nicht enthalten sind .env, vendor/ und der Quellcode: die kommen aus dem Repository, und .env enthält das Geheimnis dieser Installation.

    -

    Zurückspielen geht bewusst nur auf der Konsole: php bin/console cms:restore <name>. Ein Knopf, der die ganze Datenbank ersetzt, gehört nicht neben einen, der eine Seite speichert. Die tägliche Sicherung erledigt der Aufgabenplan – auch ohne Cronjob.

    +

    Eine Sicherung enthält die Datenbank als vollständige Kopie und den Medienordner – nicht .env, vendor/ und den Quellcode.

    +

    Zurückspielen nur auf der Konsole: php bin/console cms:restore <name>. Die tägliche Sicherung erledigt der Aufgabenplan.

    {% if backups is not empty %} - +
    {% for backup in backups %} - - - - - - + + + + + + {% endfor %}
    Vorhandene Sicherungen
    NameErstelltFassungInhaltGrößeZustand
    {{ backup.name }}{{ backup.manifest ? backup.manifest.erstellt|date('d.m.Y H:i') : '—' }}{% if backup.manifest %}
    {{ backup.manifest.anlass }}{% endif %}
    {{ backup.manifest ? backup.manifest.fassung : '—' }}{% if backup.manifest %}{{ backup.manifest.inhalt.seiten }} Seiten, {{ backup.manifest.medien.dateien }} Dateien{% else %}—{% endif %}{{ (backup.bytes / 1048576)|number_format(1, ',', '.') }} MB{% if backup.intact %}unversehrt{% else %}Prüfsumme falsch{% endif %}{{ backup.name }}{{ backup.manifest ? backup.manifest.erstellt|date('d.m.Y H:i') : '—' }}{% if backup.manifest %}
    {{ backup.manifest.anlass }}{% endif %}
    {{ backup.manifest ? backup.manifest.fassung : '—' }}{% if backup.manifest %}{{ backup.manifest.inhalt.seiten }} Seiten, {{ backup.manifest.medien.dateien }} Dateien{% else %}—{% endif %}{{ (backup.bytes / 1048576)|number_format(1, ',', '.') }} MB{% if backup.intact %}unversehrt{% else %}Prüfsumme falsch{% endif %}
    -

    „Unversehrt“ heißt: die Prüfsumme der gesicherten Datenbank stimmt noch mit dem Manifest überein. Stimmt sie nicht, wird die Sicherung nicht eingespielt.

    +

    „Unversehrt“ heißt: die Prüfsumme stimmt noch. Stimmt sie nicht, wird die Sicherung nicht eingespielt.

    {% endif %}
    @@ -232,9 +240,19 @@ {% if state.log is empty %}

    Noch kein Update über diesen Weg eingespielt.

    {% else %} + {# Zwanzig Protokollzeilen sind auf einem Handy zwanzig Absätze. Die jüngsten fünf + stehen offen, der Rest liegt eine Umdrehung dahinter. #}
      - {% for line in state.log %}
    • {{ line }}
    • {% endfor %} + {% for line in state.log|slice(0, 5) %}
    • {{ line }}
    • {% endfor %}
    + {% if state.log|length > 5 %} +
    + Ältere Einträge ({{ state.log|length - 5 }}) +
      + {% for line in state.log|slice(5) %}
    • {{ line }}
    • {% endfor %} +
    +
    + {% endif %} {% endif %}
diff --git a/templates/admin/theme.html.twig b/templates/admin/theme.html.twig index 630fbcf..c20191f 100644 --- a/templates/admin/theme.html.twig +++ b/templates/admin/theme.html.twig @@ -129,22 +129,27 @@ -

Schriften ausliefern

Von Google oder vom eigenen Server
-{% if fontsMissing is empty and not fontsLocal %} -

Dieses Design nutzt nur Systemschriften – es lädt ohnehin nichts von Google.

-{% else %} -

- {% if fontsLocal %} - Die Schriften liegen auf diesem Server. Besucher laden nichts von Google, und die Seite baut sich schneller auf, weil eine Kette aus zwei fremden Verbindungen entfällt. +

+
+

Schriften ausliefern

+ {{ fontsMissing ? fontsMissing|length ~ ' fehlt noch' : 'immer vom eigenen Server' }} +
+
+ {% if fontsMissing is empty and not fontsLocal %} +

Dieses Design nutzt nur Systemschriften – es lädt ohnehin nichts von Google.

{% else %} - Die Schriften kommen zurzeit von Google. 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. + {% if fontsLocal %} +

Die Schriften liegen auf diesem Server. Besucher laden nichts von Google, und die Seite baut sich schneller auf.

+ {% else %} +

Die Schriftdateien fehlen auf diesem Server. Bis sie hier liegen, zeigt die Website die eingestellte Ersatzschrift – von Google wird nichts geladen, weder hier noch beim Besucher.

+ {% endif %} + {% if fontsMissing %}

Noch nicht hier: {{ fontsMissing|join(', ') }}.

{% endif %} +
+ + +
{% endif %} - {% if fontsMissing %}
Noch nicht hier: {{ fontsMissing|join(', ') }}.{% endif %} -

-
- - -
-{% endif %} +
+

KantSites · Arbeitsstand

{% endblock %} diff --git a/templates/page.html.twig b/templates/page.html.twig index fc9be91..086ca96 100644 --- a/templates/page.html.twig +++ b/templates/page.html.twig @@ -33,6 +33,13 @@ drei Abfragen trifft zu - und zwar auf dieselbe Variante, die auch die Regel nimmt. #} {% for bild in heroBackground|default([]) %} {% endfor %} + {% if localFonts|default([]) %} + {# Jeder Schnitt, den das Theme nennt, vorab - und zwar alle. Fehlt einer, laedt er erst, + wenn das Stylesheet geparst ist, und die Ueberschrift springt sichtbar von der + Ersatzschrift auf die richtige. Schriften kommen ausschliesslich vom eigenen Server; + ein Stylesheet von fonts.googleapis.com wird nie verlinkt. #} + {% for font in localFonts %}{% endfor %} + {% endif %} {% if styleBundle|default(null) %} {% else %} @@ -42,10 +49,6 @@ {% if page.document.schemaVersion >= 2 %}{% endif %} {% if page.document.schemaVersion >= 3 %}{% endif %} {% endif %} - {% if localFonts|default([]) %} - {# Die wichtigsten Schnitte vorab, damit die erste Zeile in der richtigen Schrift steht. #} - {% for font in localFonts %}{% endfor %} - {% elseif googleFonts|default(null) %}{% endif %} {% if hasPosts|default(false) %} {% endif %} {% if needsTabs|default(false) %}{% endif %} diff --git a/tests/CmsTest.php b/tests/CmsTest.php index c498407..0474477 100644 --- a/tests/CmsTest.php +++ b/tests/CmsTest.php @@ -1393,27 +1393,37 @@ final class CmsTest extends TestCase 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. + // Ohne heruntergeladene Dateien bleibt es bei der Ersatzschrift - verlinkt wird Google + // nie, denn das gäbe die IP jedes Besuchers an einen Dritten weiter. $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'], + ['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Lato', 'source' => 'google', 'weights' => [700, 900], 'fallback' => 'system-ui'], ], $gespeichert['base']); self::assertFalse($fonts->hasLocal($theme->load())); - self::assertSame(['Lato 400', 'Lato 700'], $fonts->missing($theme->load())); + self::assertSame(['Lato 400', 'Lato 700', 'Lato 900'], $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()); + $ohne = $this->request('/'); + self::assertStringNotContainsString('fonts.googleapis.com', $ohne->getContent()); + self::assertStringNotContainsString('fonts.gstatic.com', $ohne->getContent()); + self::assertStringNotContainsString('rel="preload"'." href=\"/assets/fonts", $ohne->getContent()); - // Mit Dateien: kein Wort mehr von Google, dafür die eigenen Dateien und ein Vorabholen. + // Mit Dateien: die eigenen Dateien, und zwar **jeder** Schnitt vorab. Fehlte einer, läge + // er erst nach dem Stylesheet vor, und die Überschrift spränge sichtbar von der + // Ersatzschrift auf die richtige. mkdir($this->path.'-fonts', 0o775, true); - foreach (['lato-400-latin.woff2', 'lato-700-latin.woff2'] as $datei) { + foreach (['lato-400-latin.woff2', 'lato-700-latin.woff2', 'lato-900-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())); + self::assertSame([ + '/assets/fonts/lato-400-latin.woff2', + '/assets/fonts/lato-700-latin.woff2', + '/assets/fonts/lato-900-latin.woff2', + ], $fonts->preload($theme->load())); } public function testTheStylesheetsGoOutAsOneFileWithoutComments(): void @@ -1900,9 +1910,7 @@ final class CmsTest extends TestCase $template = (string) file_get_contents(__DIR__.'/../templates/blocks/icon.html.twig'); foreach (DocumentValidator::ICONS as $icon) { self::assertMatchesRegularExpression( - '/ -\s{4}'.preg_quote($icon, '/').": '[^']{4,}',? -/", + '/\R\s{4}'.preg_quote($icon, '/').": '[^']{4,}',?\R/", $template, sprintf('Das Symbol „%s“ steht in der Liste, hat aber keine Zeichnung.', $icon), ); @@ -2352,7 +2360,7 @@ final class CmsTest extends TestCase $this->pages->saveDraft('page-1', $document, 0); } - public function testThemeDrivesEveryPageAndGoogleFontsOpenTheirOwnPolicy(): void + public function testThemeDrivesEveryPageAndNoFontIsEverFetchedFromGoogle(): void { $this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1); // Without a stored theme the defaults apply and no third party is contacted. @@ -2373,10 +2381,14 @@ final class CmsTest extends TestCase self::assertStringContainsString('--kf-body:"Inter",sans-serif', $html); self::assertStringContainsString('font-size:var(--k-size)', $html); self::assertStringContainsString('--k-container:1100px', $html); - self::assertStringContainsString('fonts.googleapis.com/css2?family=Inter:wght@400;700', $html); + // Auch eine Google-Familie im Theme verlinkt nichts nach draußen: Solange die Dateien + // nicht hier liegen, greift die Ersatzschrift, und die Kopfzeile lässt keinen fremden + // Schrifthost zu. + self::assertStringNotContainsString('fonts.googleapis.com', $html); $policy = (string) $response->headers->get('Content-Security-Policy'); - self::assertStringContainsString('https://fonts.googleapis.com', $policy); - self::assertStringContainsString("font-src 'self' https://fonts.gstatic.com", $policy); + self::assertStringNotContainsString('googleapis.com', $policy); + self::assertStringContainsString("font-src 'self';", $policy); + self::assertStringContainsString("style-src 'self' 'sha256-", $policy); } /** @@ -2591,7 +2603,12 @@ final class CmsTest extends TestCase $this->menus->save('header', 'Hauptnavigation', [ ['label' => 'Start', 'type' => 'page', 'pageId' => $start, 'anchor' => '', 'children' => []], ], 0); - self::assertStringContainsString('Start', $this->request('/')->getContent()); + // Seit den Menuesymbolen (1.13.0) steht die Beschriftung in einer eigenen Huelle. Geprueft + // wird, was die Aussage traegt: Adresse, Kennzeichnung der aktuellen Seite, Beschriftung. + self::assertMatchesRegularExpression( + '#.*?Start.*?#s', + (string) $this->request('/')->getContent(), + ); // The root is protected from being renamed or binned while it is the root. foreach ([fn () => $this->pages->movePage($start, 'anders', null, $this->pages->findForEditor($start)['version'], $this->redirects),