Beitraege optional, Unterstriche nur im Text, eigenes Symbol
Eine Website ohne Beitraege redet nirgends von Beitraegen: Archiv und Feed antworten mit 404, der Feedverweis faellt aus dem Kopf, und der Hinweiskasten der Beitragsliste bleibt im Editor statt oeffentlich zu erscheinen. Das gilt auch je Kategorie, sobald deren letzter Beitrag weg ist. Dabei gefunden: die Liste fragte je Beitrag nach Bild und Begriffen - jetzt je eine Abfrage fuer die ganze Liste (22 -> 16 Abfragen auf der Startseite des Nachbaus). Unterstrichene Links gibt es nur noch im Fliesstext, dort mit einem Schalter im Designbereich; Karten, Menue, Kontaktzeilen und der Ansehen-Streifen haben keinen mehr. Die aktuelle Seite im Menue wird ueber das Schriftgewicht kenntlich - die Akzentfarbe fiel im dunklen Fussmenue durch die Kontrastpruefung. Neu: das Symbol der Website laesst sich festlegen. Aus einem Bild der Mediathek entstehen beim Speichern quadratische PNG-Dateien in 32 und 180 px; schlaegt das fehl, wird die Einstellung nicht gespeichert. Die Browserpruefung laeuft jetzt gegen jede Installation statt nur gegen die Demo: geprueft wird, was in der Sitemap steht, und das Ladebudget trennt Bilder vom Beiwerk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f0b634f0c1
commit
f2ef2a4901
28 files changed
+482
-64
No files matched your search
@@ -440,6 +440,32 @@ Vom Verweis bleibt nur der Host. Die vollständige Adresse, aus der jemand kam,
|
||||
|
||||
**Aufbewahrung:** nach 60 Tagen werden die Stunden eines Tages zu einer Zeile verschmolzen, nach 425 Tagen wird gelöscht. Gut ein Jahr, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. `cms:stats:prune` macht beides und ist für einen Cronjob gedacht.
|
||||
|
||||
## ADR 055 – Das Symbol der Website entsteht aus einem Bild der Mediathek (13.09.2026)
|
||||
|
||||
Umut: „dann brauchen wir Favicon-Möglichkeit zum Festlegen“. Ein Favicon ist der einzige Teil der Gestaltung, den ein Besucher sieht, bevor die Seite geladen ist – und bisher war es das mitgelieferte Zeichen, für jede mit KantSites gebaute Website dasselbe.
|
||||
|
||||
Naheliegend wäre gewesen, die WebP-Varianten der Mediathek zu verwenden. Dagegen sprechen zwei Dinge: die kleinste Variante ist 480 px breit (für ein 32-px-Symbol zwei Größenordnungen zu viel), und ein Favicon ist **quadratisch** – ein breites Logo würde gestaucht oder zur Briefmarke.
|
||||
|
||||
**Deshalb entstehen die Symboldateien beim Speichern der Einstellung, nicht beim Aufruf**: ein mittiger quadratischer Ausschnitt in 32 px (Reiter) und 180 px (Startbildschirm), als PNG, weil das jeder Browser liest. Schlägt das fehl, wird die Einstellung gar nicht erst gespeichert – ein Verweis auf ein Symbol, das es nicht gibt, wäre schlechter als das alte Symbol. Ausgeliefert werden sie unter `/favicon.png` und `/apple-touch-icon.png`, mit der Bildkennung als Version am Verweis, damit ein Wechsel im Browser ankommt.
|
||||
|
||||
Nebenwirkung, die die Tests fanden: `SiteSettings` fragt die Datenbank jetzt auch aus der Nicht-gefunden-Seite heraus – und **vor** der Installation gibt es die Tabelle noch nicht. Eine gezielt gefangene `TableNotFoundException` liefert dort die Standardwerte; jeder andere Datenbankfehler bleibt ein Fehler.
|
||||
|
||||
## ADR 054 – Unterstrichene Links: nur im Fließtext, und dort abschaltbar (13.09.2026)
|
||||
|
||||
Umut: „und links haben alle underlined“. Stimmte: `a` hatte keine eigene Textdekoration, also entschied der Browser – und der unterstreicht alles. Sichtbar wurde das an Stellen, wo ein Unterstrich nur wie ein Fehler aussieht: im „Ansehen“-Streifen über einem Kartenbild, im Menü bei der aktuellen Seite, in der Rechtszeile des Fußes, neben dem Telefonsymbol im Kontaktblock.
|
||||
|
||||
Die Trennlinie ist nicht Geschmack, sondern Funktion: **ein Link mitten im Fließtext ist ohne Unterstrich nur an seiner Farbe zu erkennen** – für Menschen mit Farbsehschwäche also gar nicht (WCAG 1.4.1). Ein Link, der als Karte, Knopf, Menüeintrag oder Kontaktzeile mit Symbol auftritt, ist an Form und Ort erkennbar und braucht keinen.
|
||||
|
||||
Also: strukturelle Links ohne Unterstrich (beim Überfahren erscheint er), Fließtext mit – und für den Fließtext **ein Schalter im Designbereich** statt einer stillen Entscheidung, mit dem Hinweis, was man sich damit einhandelt. Die aktuelle Seite im Menü wird über das Schriftgewicht kenntlich und nicht mehr über die Akzentfarbe: die reichte im dunklen Fußmenü nicht für den Kontrast – gefunden von der Browserprüfung, nicht von mir.
|
||||
|
||||
## ADR 053 – Eine Website ohne Beiträge redet nicht von Beiträgen (13.09.2026)
|
||||
|
||||
Umut: „Können wir Beiträge optional machen? Wenn's keine gibt, überall ausblenden – ist das schon so?“ Teilweise: die Sitemap ließ das Archiv weg, `llms.txt` auch. Der Rest nicht – `/beitraege` antwortete mit einer leeren Seite, `/feed.xml` mit einem Feed ohne Einträge, jede Seite verwies im Kopf auf diesen Feed, und ein Beitragslisten-Baustein zeigte öffentlich den Kasten „Hier erscheinen die Beiträge, sobald welche veröffentlicht sind“.
|
||||
|
||||
**Eine Adresse, die nichts zeigt, ist kein Inhalt, sondern eine Baustelle mit eigener Adresse.** Archiv und Feed antworten deshalb mit 404, solange sie nichts zu zeigen haben – das gilt auch für eine einzelne Kategorie, deren letzter Beitrag gelöscht wurde: der Begriff bleibt als wiederverwendbare Beschriftung bestehen, sein Archiv verschwindet. Der Verweis auf den Feed steht nur im Kopf, wenn es ihn gibt. Der Hinweiskasten bleibt – aber **nur im Editor**, denn dort erklärt er die leere Stelle, während er öffentlich nur ein Versprechen wäre.
|
||||
|
||||
Beim Nachmessen fiel eine zweite Sache auf, die nichts mit Beiträgen zu tun hat: die Beitragsliste fragte **je Beitrag** nach seinem Bild und seinen Begriffen. Zwölf Beiträge kosteten 24 Abfragen, und `neighbours()` (Vor/Zurück) lädt bis zu 500 Beiträge. Bilder und Begriffe kommen jetzt in je einer Abfrage für die ganze Liste: die Startseite des Nachbaus fiel von 22 auf 16 Abfragen.
|
||||
|
||||
## ADR 052 – Einblenden beim Scrollen, ohne eine Zeile JavaScript (13.09.2026)
|
||||
|
||||
Umut: „Effekte gibt es keine, sind die aktiv aktuell?“ Nachgemessen statt geraten: die Zeigereffekte liefen alle (Karte −8 px, Bild 1,06×, Symbolkarte −4 px, Knopf −2 px, Titel in Akzentfarbe). Die **Einblendungen beim Laden** gab es seit Dokumentschema v3 – und **benutzt hatte sie niemand**. Ein Werkzeug, das im Kasten bleibt, ist kein Werkzeug.
|
||||
|
||||
@@ -4,6 +4,8 @@ Stand: 12.09.2026. Das System heißt **KantSites** (vorher Arbeitstitel „Kant
|
||||
|
||||
Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
|
||||
|
||||
**Im Nachbau von kant-it.de gefunden und behoben:** vier tote Verweise im Hero-Slider – die Adressen waren im Aufbauskript von Hand geschrieben, die Beiträge bekamen längere. Sie werden jetzt aus derselben Regel abgeleitet, mit der die Beiträge entstehen, und ein zweiter Lauf des Skripts aktualisiert vorhandene Seiten, statt sie zu überspringen. Eine Prüfung aller internen Verweise auf 404 gibt es im CMS noch nicht; gefunden wurden sie mit einem einmaligen Durchlauf im Browser.
|
||||
|
||||
## Erledigt
|
||||
|
||||
- `AGENTS.md` und `ENTWICKLUNGSPLAN.md` unverändert aus Downloads im Projektstamm abgelegt; SHA-256 jeweils mit dem Original identisch.
|
||||
@@ -28,6 +30,10 @@ 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.
|
||||
- **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.
|
||||
- **Beiträge sind optional** (ADR 053): ohne einen einzigen veröffentlichten Beitrag antworten `/beitraege` und `/feed.xml` mit 404, der Feedverweis fehlt im Kopf, die Sitemap kennt kein Archiv, und ein Beitragslisten-Baustein zeigt öffentlich nichts – der Hinweis „hier erscheinen Beiträge“ bleibt nur im Editor. Ein Kategorie- oder Schlagwortarchiv verschwindet ebenso, sobald es nichts mehr zu zeigen hat; der Begriff selbst bleibt. Dabei behoben: die Beitragsliste fragte je Beitrag einzeln nach Bild und Begriffen – jetzt je eine Abfrage für die ganze Liste, Startseite des Nachbaus **22 → 16 Abfragen**.
|
||||
- **Unterstriche nur noch im Fließtext** (ADR 054), dort mit Schalter im Designbereich („Links im Fließtext unterstreichen“, Standard an). Karten, Menü, Kontaktzeilen, Rechtszeile und der „Ansehen“-Streifen über Kartenbildern haben keinen mehr; beim Überfahren erscheint er überall. Die aktuelle Seite im Menü wird über das Schriftgewicht kenntlich – die Akzentfarbe fiel im dunklen Fußmenü durch die Kontrastprüfung.
|
||||
- **Eigenes Symbol der Website** (ADR 055): ein Bild aus der Mediathek in den Einstellungen wählen, beim Speichern entstehen daraus quadratische PNG-Dateien in 32 und 180 px (`/favicon.png`, `/apple-touch-icon.png`). Ohne Auswahl bleibt es beim mitgelieferten Zeichen.
|
||||
- **Die Browserprüfung prüft jetzt jede Installation, nicht nur die Demo**: die geprüften Seiten kommen aus der Sitemap, Tastatur- und Ohne-JavaScript-Weg folgen dem ersten Verweis im Inhalt statt festen Linktexten der Demo. Das Ladebudget trennt Bilder (Inhalt) vom Beiwerk (≤ 12 Anfragen) und deckelt stattdessen das übertragene Gewicht (1,5 MB); Skripte werden gegen die Richtlinie der Seite gezählt, strukturierte Daten zählen nicht mit. Der Nachbau von kant-it.de besteht die vollständige Prüfung ohne Befund.
|
||||
- **Einblendungen laufen beim Scrollen** (ADR 052): `animation-timeline: view()` hinter Bewegungsschranke und `@supports`; auf dem Scrollweg nur Bewegung, keine Deckkraft, damit Text außerhalb des Blicks lesbar bleibt. Die drei Beitragsvorlagen bringen sie mit.
|
||||
- **Vier Darstellungen der Beitragsliste** (ADR 051): Karten, Liste, Kompakt und Wechselseitig – am Baustein einstellbar, dieselben Daten, nur andere Regeln.
|
||||
- **Drei Aufbauten für neue Beiträge** (ADR 050): „Bildkopf“, „Klassisch“ und „Magazin“, wählbar beim Anlegen. Startpunkte, keine Schablonen – jeder Block bleibt änderbar.
|
||||
|
||||
@@ -650,3 +650,7 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.install-details { margin-top: 18px; border: 1px solid var(--line); border-radius: 10px; padding: 0 16px 16px }
|
||||
.install-details > summary { padding: 14px 0; font-size: 13px; font-weight: 600; cursor: pointer }
|
||||
.install-details[open] > summary { border-bottom: 1px solid var(--line); margin-bottom: 6px }
|
||||
|
||||
.favicon-field{display:flex;align-items:center;gap:12px}
|
||||
.favicon-field select{flex:1}
|
||||
.favicon-preview{border-radius:6px;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.12)}
|
||||
@@ -6,14 +6,14 @@ h1,h2,h3,h4,h5,h6{hyphens:manual}
|
||||
p{hyphens:auto}
|
||||
.visually-hidden{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}.block-image{margin:0;max-width:100%}.block-image img{max-width:100%;height:auto}
|
||||
:root{color-scheme:light;--paper:#f7f8f4;--ink:#182d2b;--muted:#50625d;--line:#d7dfd7;--mint:#e5f0b6;--accent:#315b49;font-family:Arial,Helvetica,sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}
|
||||
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid var(--accent);outline-offset:6px}main:focus{outline:none}.shell{width:min(var(--k-container,1180px),calc(100% - 64px));margin-inline:auto}.site-header{border-bottom:1px solid var(--line)}.site-header.is-sticky{position:sticky;top:0;z-index:40;background:var(--paper);box-shadow:0 1px 0 var(--line),0 10px 30px color-mix(in srgb,var(--ink) 8%,transparent)}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:underline}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500;max-width:900px}h2{font-size:clamp(32px,4vw,49px);font-weight:500;max-width:760px}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;max-width:690px;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
|
||||
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid var(--accent);outline-offset:6px}main:focus{outline:none}.shell{width:min(var(--k-container,1180px),calc(100% - 64px));margin-inline:auto}.site-header{border-bottom:1px solid var(--line)}.site-header.is-sticky{position:sticky;top:0;z-index:40;background:var(--paper);box-shadow:0 1px 0 var(--line),0 10px 30px color-mix(in srgb,var(--ink) 8%,transparent)}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:none;font-weight:800}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500;max-width:900px}h2{font-size:clamp(32px,4vw,49px);font-weight:500;max-width:760px}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;max-width:690px;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
|
||||
@media(max-width:650px){.shell{width:calc(100% - 36px)}.site-header nav{width:100%;justify-content:space-between;gap:12px;font-size:12px}.site-header nav .nav-link{padding:10px}.nav-link span{margin-left:6px}.block-section,.block-section:first-child{padding-block:48px}h1{font-size:49px}.block-text{font-size:16px}.site-footer{align-items:flex-start;flex-direction:column;gap:10px}.site-footer p{margin:0}.block-row{gap:16px}}
|
||||
|
||||
/* Menus. Submenus open with details/summary, so the navigation works without JavaScript. */
|
||||
.menu-list{list-style:none;display:flex;flex-wrap:wrap;align-items:center;gap:4px 22px;margin:0;padding:0}
|
||||
.menu-list a{text-decoration:none;padding:6px 2px;display:inline-block}
|
||||
.menu-list a:hover,.menu-list summary:hover{text-decoration:underline}
|
||||
.menu-list [aria-current=page]{font-weight:700;text-decoration:underline;text-underline-offset:6px}
|
||||
.menu-list [aria-current=page]{font-weight:700;text-decoration:none}
|
||||
.menu-branch>summary{cursor:pointer;padding:6px 2px;list-style:none}
|
||||
.menu-branch>summary::after{content:' ▾'}
|
||||
.menu-branch[open]>summary::after{content:' ▴'}
|
||||
@@ -23,7 +23,7 @@ p{hyphens:auto}
|
||||
/* Posts */
|
||||
.post-list{list-style:none;margin:0;padding:0;display:grid;gap:26px}.post-list-karten{grid-template-columns:repeat(auto-fill,minmax(260px,1fr))}.post-list-liste{gap:18px}.post-list-liste .post-card{flex-direction:row;align-items:stretch}.post-list-liste .post-image{flex:0 0 34%;max-width:300px}.post-list-liste .post-image img{height:100%;aspect-ratio:auto;min-height:170px}.post-list-liste .post-body{flex:1 1 auto;align-content:center;padding:22px 26px}.post-list-wechsel{gap:0;counter-reset:beitrag}.post-list-wechsel .post-card{position:relative;counter-increment:beitrag;display:grid;grid-template-columns:1fr 1fr;align-items:center;gap:0 56px;padding:38px 0;background:transparent;border:0;border-radius:0;overflow:visible}.post-list-wechsel .post-card + .post-card{border-top:1px solid var(--line)}.post-list-wechsel .post-image{border-radius:20px;overflow:hidden;box-shadow:0 18px 46px -22px color-mix(in srgb,var(--ink) 55%,transparent)}.post-list-wechsel .post-image img{aspect-ratio:16/10;border-radius:20px}.post-list-wechsel .post-body{padding:0;display:grid;gap:12px;align-content:center}.post-list-wechsel .post-body::before{content:counter(beitrag,decimal-leading-zero);font-size:13px;font-weight:800;letter-spacing:2px;color:var(--accent)}.post-list-wechsel .post-card h3{font-size:28px;line-height:1.18;letter-spacing:-.5px}.post-list-wechsel .post-excerpt{margin:0;max-width:46ch}.post-list-wechsel .post-meta,.post-list-wechsel .post-terms{margin:0}.post-list-wechsel .post-more{display:inline-flex;align-items:center;gap:8px;margin-top:4px;font-size:15px;font-weight:700;color:var(--accent)}.post-list-wechsel .post-more::after{content:'\2192'}.post-list-wechsel .post-card:nth-child(even) .post-image{order:2}.post-list-wechsel .post-card:nth-child(even) .post-body{order:1}@media(max-width:760px){.post-list-wechsel .post-card{grid-template-columns:1fr;gap:18px;padding:26px 0}.post-list-wechsel .post-card:nth-child(even) .post-image{order:0}.post-list-wechsel .post-card:nth-child(even) .post-body{order:0}.post-list-wechsel .post-card h3{font-size:22px}}.post-list-kompakt{gap:0}.post-list-kompakt .post-card{flex-direction:row;align-items:baseline;justify-content:space-between;gap:18px;background:transparent;border:0;border-bottom:1px solid var(--line);border-radius:0;overflow:visible}.post-list-kompakt .post-body{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 18px;padding:16px 2px}.post-list-kompakt .post-meta,.post-list-kompakt .post-terms{margin:0}.post-list-kompakt .post-excerpt{flex:1 1 100%;margin:0}
|
||||
.post-card{display:flex;flex-direction:column;background:var(--paper);border:1px solid var(--line);border-radius:18px;overflow:hidden}.post-card>.post-body{display:grid;gap:10px;padding:20px 22px 22px}
|
||||
.post-image{display:block;position:relative;overflow:hidden}.post-view{position:absolute;inset:0;display:grid;place-items:center;background:color-mix(in srgb,var(--ink) 55%,transparent);color:#fff;font-weight:700;font-size:15px;letter-spacing:.4px;opacity:0}.post-card:hover .post-view,.post-card:focus-within .post-view{opacity:1}.post-image img{width:100%;height:auto;aspect-ratio:16/10;object-fit:cover;border-radius:0;display:block;background:var(--ink)}
|
||||
.post-image{display:block;position:relative;overflow:hidden;text-decoration:none}.post-view{position:absolute;inset:0;display:grid;place-items:center;background:color-mix(in srgb,var(--ink) 55%,transparent);color:#fff;font-weight:700;font-size:15px;letter-spacing:.4px;opacity:0}.post-card:hover .post-view,.post-card:focus-within .post-view{opacity:1}.post-image img{width:100%;height:auto;aspect-ratio:16/10;object-fit:cover;border-radius:0;display:block;background:var(--ink)}
|
||||
.kant h1 mark,.kant h2 mark,.kant h3 mark{background:transparent;color:var(--accent);padding:0}
|
||||
.kant .post-card h3{margin:0;font-size:19px;font-weight:800;line-height:1.12;color:var(--ink);letter-spacing:-.2px}
|
||||
.post-card h3 a{text-decoration:none}
|
||||
@@ -59,6 +59,10 @@ p{hyphens:auto}
|
||||
.block-cta p{margin:0;line-height:1.6}
|
||||
.block-contact{display:grid;gap:14px;font-style:normal}
|
||||
.block-contact span{display:flex;align-items:center;gap:12px;line-height:1.5}
|
||||
/* Telefonnummer und E-Mail stehen neben ihrem Symbol - das sagt deutlicher als ein Strich,
|
||||
dass man darauf tippen kann. Beim Überfahren erscheint er trotzdem. */
|
||||
.block-contact a{text-decoration:none}
|
||||
.block-contact a:hover{text-decoration:underline}
|
||||
.block-divider{border:0;border-top:1px solid currentColor;opacity:.35;margin:0}
|
||||
.block-spacer{display:block}
|
||||
.spacer-small{height:18px}.spacer-medium{height:38px}.spacer-large{height:68px}.spacer-huge{height:110px}
|
||||
@@ -164,7 +168,8 @@ body.consent-open{padding-bottom:260px}
|
||||
/* One landmark for the three links that must be on every page. Loose text outside a landmark is
|
||||
also what axe objects to, and rightly: a screen reader then has no way to skip to it. */
|
||||
.legal-bar{padding:16px 20px;text-align:center;font-size:12px;color:var(--muted);background:color-mix(in srgb,var(--ink) 6%,var(--paper))}
|
||||
.legal-bar a{color:var(--accent)}
|
||||
.legal-bar a{color:var(--accent);text-decoration:none}
|
||||
.legal-bar a:hover{text-decoration:underline}
|
||||
|
||||
/* An image takes the rounding of its figure. Without this a radius on an image block rounded the
|
||||
figure and left the picture square - which looked like the setting did nothing. */
|
||||
|
||||
+59
-19
@@ -14,12 +14,23 @@ try {
|
||||
// itself. Without bin/dev-router.php they never reach the front controller - exactly the gap
|
||||
// that let them stay broken locally while every kernel test passed.
|
||||
const context = await browser.newContext();
|
||||
for (const [path, type] of [['/sitemap.xml', 'application/xml'], ['/robots.txt', 'text/plain'], ['/feed.xml', 'application/xml']]) {
|
||||
const sitemap = await context.request.get(origin + '/sitemap.xml');
|
||||
assert.equal(sitemap.status(), 200, '/sitemap.xml');
|
||||
assert.match(sitemap.headers()['content-type'] ?? '', /application\/xml/, '/sitemap.xml');
|
||||
// Eine Website ohne Beiträge kennt weder Archiv noch Feed: dann ist 404 die richtige Antwort,
|
||||
// und die Sitemap führt die Adresse gar nicht erst auf.
|
||||
const sitemapText = await sitemap.text();
|
||||
const hasPosts = sitemapText.includes('/beitraege');
|
||||
for (const [path, type] of [['/robots.txt', 'text/plain'], ['/feed.xml', 'application/xml']]) {
|
||||
const response = await context.request.get(origin + path);
|
||||
assert.equal(response.status(), 200, path);
|
||||
assert.match(response.headers()['content-type'] ?? '', new RegExp(type), path);
|
||||
const expected = '/feed.xml' === path && !hasPosts ? 404 : 200;
|
||||
assert.equal(response.status(), expected, path);
|
||||
// Auch die 404 muss aus der Anwendung kommen - der eingebaute PHP-Server beantwortet Dateien
|
||||
// mit Endung sonst selbst, und genau das soll dieser Test aufdecken.
|
||||
assert.ok('x-kant-queries' in response.headers(), `${path}: nicht von der Anwendung beantwortet`);
|
||||
if (200 === expected) assert.match(response.headers()['content-type'] ?? '', new RegExp(type), path);
|
||||
}
|
||||
results.push({ name: 'machine readable routes reachable', paths: 3 });
|
||||
results.push({ name: 'machine readable routes reachable', paths: 3, hasPosts });
|
||||
await context.close();
|
||||
|
||||
// A load budget, measured rather than assumed: what a page actually transfers, and how many
|
||||
@@ -34,7 +45,7 @@ try {
|
||||
const length = Number(response.headers()['content-length'] ?? 0);
|
||||
transferred.set(response.url(), { type, length });
|
||||
});
|
||||
for (const path of ['/', '/beitraege']) {
|
||||
for (const path of hasPosts ? ['/', '/beitraege'] : ['/']) {
|
||||
transferred.clear();
|
||||
const response = await page.goto(origin + path, { waitUntil: 'networkidle' });
|
||||
const queries = Number(response.headers()['x-kant-queries'] ?? -1);
|
||||
@@ -42,29 +53,49 @@ try {
|
||||
const styles = [...transferred.values()].filter(entry => entry.type === 'text/css').reduce((sum, entry) => sum + entry.length, 0);
|
||||
const scripts = [...transferred.values()].filter(entry => entry.type.includes('javascript')).length;
|
||||
const requests = transferred.size;
|
||||
// Bilder gehören zum Inhalt: eine bebilderte Startseite lädt zu Recht mehr Dateien als eine
|
||||
// Textseite. Gedeckelt wird deshalb das Beiwerk (Stile, Schriften, Skripte) und das
|
||||
// übertragene Gewicht - nicht die Zahl der Bilder.
|
||||
const beiwerk = [...transferred.values()].filter(entry => !entry.type.startsWith('image/')).length;
|
||||
const gewicht = [...transferred.values()].reduce((sum, entry) => sum + entry.length, 0);
|
||||
|
||||
assert.ok(html < 150000, `${path}: HTML ${html} Bytes, Obergrenze 150000`);
|
||||
assert.ok(styles < 120000, `${path}: CSS ${styles} Bytes, Obergrenze 120000`);
|
||||
// A public page runs no script unless it carries tabs or its own; the budget keeps that honest.
|
||||
assert.equal(scripts, 0, `${path}: ${scripts} Skript(e) geladen`);
|
||||
assert.ok(requests <= 12, `${path}: ${requests} Anfragen, Obergrenze 12`);
|
||||
// Eine öffentliche Seite lädt kein Skript, solange sie keines braucht - und das steht in
|
||||
// ihrem eigenen Kopf: nur wenn die Seite Skripte erlaubt (Reiter, Slider mit Autolauf,
|
||||
// eigener Code), darf überhaupt eines kommen, dann höchstens eines.
|
||||
const erlaubt = !/script-src 'none'/.test(response.headers()['content-security-policy'] ?? '');
|
||||
assert.ok(erlaubt ? scripts <= 1 : 0 === scripts, `${path}: ${scripts} Skript(e) geladen, erlaubt ${erlaubt ? 1 : 0}`);
|
||||
assert.ok(beiwerk <= 12, `${path}: ${beiwerk} Anfragen ohne Bilder, Obergrenze 12`);
|
||||
assert.ok(gewicht < 1500000, `${path}: ${gewicht} Bytes übertragen, Obergrenze 1500000`);
|
||||
if (queries >= 0) assert.ok(queries <= 20, `${path}: ${queries} Datenbankabfragen, Obergrenze 20`);
|
||||
results.push({ name: `load budget ${path}`, html, styles, requests, queries });
|
||||
console.log(`Budget ${path}: HTML ${html} B, CSS ${styles} B, ${requests} Anfragen, ${queries} Abfragen, ${scripts} Skripte`);
|
||||
results.push({ name: `load budget ${path}`, html, styles, requests, beiwerk, gewicht, queries });
|
||||
console.log(`Budget ${path}: HTML ${html} B, CSS ${styles} B, ${requests} Anfragen (${beiwerk} ohne Bilder), ${gewicht} B gesamt, ${queries} Abfragen, ${scripts} Skripte`);
|
||||
}
|
||||
await budget.close();
|
||||
}
|
||||
|
||||
// Die Seiten, die geprüft werden, stehen in der Sitemap - vorher waren es drei fest
|
||||
// geschriebene Adressen der Demo, und gegen eine andere Installation lief der Check ins Leere.
|
||||
const seiten = ['/', ...[...sitemapText.matchAll(/<loc>([^<]+)<\/loc>/g)]
|
||||
.map(treffer => new URL(treffer[1]).pathname)
|
||||
.filter(pfad => '/' !== pfad && !pfad.startsWith('/beitraege') && !pfad.startsWith('/kategorie/') && !pfad.startsWith('/schlagwort/'))
|
||||
.slice(0, 2)];
|
||||
|
||||
for (const width of [1440, 390]) {
|
||||
const context = await browser.newContext({ viewport: { width, height: 1000 } });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
for (const path of ['/', '/grundlagen', '/projektstand']) {
|
||||
for (const path of seiten) {
|
||||
const response = await page.goto(origin + path);
|
||||
assert.equal(response.status(), 200);
|
||||
assert.equal(await page.locator('h1').count(), 1);
|
||||
assert.equal(await page.locator('script').count(), 0);
|
||||
// Strukturierte Daten stehen zwar in einem script-Element, führen aber nichts aus. Gezählt
|
||||
// wird, was laufen kann - und das nur, wenn die Seite es in ihrer eigenen Richtlinie erlaubt.
|
||||
const laufend = await page.locator('script:not([type="application/ld+json"])').count();
|
||||
const darfSkripte = !/script-src 'none'/.test(response.headers()['content-security-policy'] ?? '');
|
||||
assert.ok(darfSkripte ? laufend <= 1 : 0 === laufend, `${path}: ${laufend} Skript(e), erlaubt ${darfSkripte ? 1 : 0}`);
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'Horizontal overflow');
|
||||
const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'best-practice']).analyze();
|
||||
|
||||
@@ -104,18 +135,27 @@ try {
|
||||
assert.equal(await page.locator(':focus').innerText(), 'Zum Inhalt springen');
|
||||
await page.keyboard.press('Enter');
|
||||
assert.equal(await page.locator(':focus').getAttribute('id'), 'main');
|
||||
await page.getByRole('link', { name: 'Die Grundlage entdecken' }).click();
|
||||
assert.equal(new URL(page.url()).pathname, '/grundlagen');
|
||||
await page.getByRole('link', { name: 'Projektstand' }).click();
|
||||
assert.equal(new URL(page.url()).pathname, '/projektstand');
|
||||
// Weiter mit der Tastatur: der erste Verweis im Inhalt muss anklickbar sein und tatsächlich
|
||||
// woandershin führen. Vorher standen hier die Linktexte der Demo - gegen jede andere
|
||||
// Installation lief die Prüfung damit ins Leere statt etwas zu prüfen.
|
||||
const ersterLink = page.locator('main a[href^="/"]').first();
|
||||
const ziel = await ersterLink.getAttribute('href');
|
||||
await ersterLink.click();
|
||||
assert.equal(new URL(page.url()).pathname, ziel.split('#')[0] || '/');
|
||||
assert.equal(await page.locator('h1').count(), 1, ziel + ': keine Überschrift');
|
||||
assert.deepEqual(errors, []);
|
||||
await context.close();
|
||||
}
|
||||
// Ohne JavaScript muss dieselbe Website benutzbar sein - eine öffentliche Seite lädt ohnehin
|
||||
// keines, aber das soll gemessen und nicht geglaubt werden.
|
||||
const noJs = await browser.newPage({ javaScriptEnabled: false });
|
||||
await noJs.goto(origin);
|
||||
assert.match(await noJs.locator('h1').innerText(), /Deine Inhalte/);
|
||||
await noJs.getByRole('link', { name: 'Die Grundlage entdecken' }).click();
|
||||
assert.equal(new URL(noJs.url()).pathname, '/grundlagen');
|
||||
assert.equal(await noJs.locator('h1').count(), 1);
|
||||
const ohneJs = noJs.locator('main a[href^="/"]').first();
|
||||
const zielOhneJs = await ohneJs.getAttribute('href');
|
||||
await ohneJs.click();
|
||||
assert.equal(new URL(noJs.url()).pathname, zielOhneJs.split('#')[0] || '/');
|
||||
assert.equal(await noJs.locator('h1').count(), 1);
|
||||
await writeFile('reports/browser.json', JSON.stringify({ origin, results, editorialFindings: findings, keyboard: 'passed', noJavaScript: 'passed' }, null, 2));
|
||||
console.log('Passed: 6 page/viewport accessibility checks, keyboard navigation, no-JS navigation.');
|
||||
if (findings.length > 0) {
|
||||
|
||||
@@ -60,6 +60,33 @@ final class MediaLibrary
|
||||
return $row ? $this->present($row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mehrere Bilder auf einmal, nach Kennung sortiert.
|
||||
*
|
||||
* Eine Liste mit zwölf Beiträgen soll nicht zwölfmal nach je einem Bild fragen. Fehlende
|
||||
* Kennungen fehlen auch im Ergebnis - der Aufrufer prüft ohnehin auf null.
|
||||
*
|
||||
* @param list<string> $ids
|
||||
*
|
||||
* @return array<string, array>
|
||||
*/
|
||||
public function findMany(array $ids): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter($ids)));
|
||||
if ([] === $ids) {
|
||||
return [];
|
||||
}
|
||||
$found = [];
|
||||
foreach ($this->db->fetchAllAssociative(
|
||||
'SELECT * FROM media WHERE id IN ('.implode(',', array_fill(0, count($ids), '?')).')',
|
||||
$ids,
|
||||
) as $row) {
|
||||
$found[$row['id']] = $this->present($row);
|
||||
}
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
private function present(array $row): array
|
||||
{
|
||||
$row['variants'] = json_decode($row['variants'], true, 512, JSON_THROW_ON_ERROR);
|
||||
@@ -163,6 +190,73 @@ final class MediaLibrary
|
||||
return [...$data, 'id' => $id, 'version' => $data['version'] + 1];
|
||||
}
|
||||
|
||||
/** Die Größen, die ein Favicon braucht: der Reiter im Browser und das Symbol auf dem Startbildschirm. */
|
||||
public const FAVICON_SIZES = [32, 180];
|
||||
|
||||
/**
|
||||
* Erzeugt aus einem Bild der Mediathek die Symboldateien der Website.
|
||||
*
|
||||
* Ein Favicon ist quadratisch, winzig und wird von den Browsern bis heute am zuverlässigsten
|
||||
* als PNG gelesen - deshalb hier PNG statt der WebP-Varianten und ein mittiger quadratischer
|
||||
* Ausschnitt statt eines gestauchten Bildes. Erzeugt wird einmal beim Speichern der
|
||||
* Einstellung, nicht bei jedem Aufruf.
|
||||
*/
|
||||
public function renderFavicon(string $mediaId): void
|
||||
{
|
||||
$source = null;
|
||||
foreach (array_reverse(self::WIDTHS) as $width) {
|
||||
$path = $this->file($mediaId, $width);
|
||||
if (null !== $path) {
|
||||
$source = @imagecreatefromwebp($path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$source) {
|
||||
throw new \RuntimeException('Das Bild für das Symbol konnte nicht gelesen werden.');
|
||||
}
|
||||
try {
|
||||
// Mittiger quadratischer Ausschnitt: ein breites Logo würde sonst zur Briefmarke.
|
||||
$kante = min(imagesx($source), imagesy($source));
|
||||
$links = (int) ((imagesx($source) - $kante) / 2);
|
||||
$oben = (int) ((imagesy($source) - $kante) / 2);
|
||||
foreach (self::FAVICON_SIZES as $size) {
|
||||
$target = imagecreatetruecolor($size, $size);
|
||||
try {
|
||||
imagealphablending($target, false);
|
||||
imagesavealpha($target, true);
|
||||
imagecopyresampled($target, $source, 0, 0, $links, $oben, $size, $size, $kante, $kante);
|
||||
if (!imagepng($target, $this->faviconPath($size), 9)) {
|
||||
throw new \RuntimeException('Das Symbol konnte nicht gespeichert werden.');
|
||||
}
|
||||
} finally {
|
||||
imagedestroy($target);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
imagedestroy($source);
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Pfad einer Symboldatei, oder null wenn sie (noch) nicht erzeugt wurde. */
|
||||
public function faviconFile(int $size): ?string
|
||||
{
|
||||
if (!in_array($size, self::FAVICON_SIZES, true)) {
|
||||
return null;
|
||||
}
|
||||
$path = $this->faviconPath($size);
|
||||
|
||||
return is_file($path) ? $path : null;
|
||||
}
|
||||
|
||||
private function faviconPath(int $size): string
|
||||
{
|
||||
if (!is_dir($this->directory) && !mkdir($this->directory, 0775, true) && !is_dir($this->directory)) {
|
||||
throw new \RuntimeException('Medienordner ist nicht beschreibbar.');
|
||||
}
|
||||
|
||||
return $this->directory.'/favicon-'.$size.'.png';
|
||||
}
|
||||
|
||||
public function file(string $id, int $width): ?string
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{32}$/D', $id)) {
|
||||
|
||||
@@ -127,17 +127,24 @@ final class PostRepository
|
||||
$params,
|
||||
);
|
||||
|
||||
return ['posts' => array_map($this->present(...), $rows), 'total' => $total, 'pages' => $pages, 'page' => $page];
|
||||
// Bilder und Begriffe für die ganze Liste in je einer Abfrage: vorher fragte jeder Beitrag
|
||||
// einzeln nach seinem Bild und seinen Begriffen, eine Liste mit zwölf Einträgen kostete
|
||||
// also 24 Abfragen statt zwei. Bei neighbours(), das 500 Beiträge lädt, war das absurd.
|
||||
$images = $this->media->findMany(array_map(static fn (array $row) => (string) $row['featured_media_id'], $rows));
|
||||
$terms = $this->termsForMany(array_column($rows, 'id'));
|
||||
|
||||
return [
|
||||
'posts' => array_map(fn (array $row) => $this->present($row, $images, $terms[$row['id']] ?? ['category' => [], 'tag' => []]), $rows),
|
||||
'total' => $total,
|
||||
'pages' => $pages,
|
||||
'page' => $page,
|
||||
];
|
||||
}
|
||||
|
||||
private function present(array $row): array
|
||||
private function present(array $row, array $images, array $terms): array
|
||||
{
|
||||
$document = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$image = null;
|
||||
if (null !== $row['featured_media_id']) {
|
||||
$item = $this->media->find($row['featured_media_id']);
|
||||
$image = null === $item ? null : ['alt' => $item['alt'] ?? '', 'variants' => $item['variants']];
|
||||
}
|
||||
$item = $images[$row['featured_media_id']] ?? null;
|
||||
|
||||
return [
|
||||
'title' => $document['title'] ?? $row['path'],
|
||||
@@ -145,11 +152,37 @@ final class PostRepository
|
||||
'excerpt' => $row['excerpt'] ?? $document['description'] ?? '',
|
||||
'author' => $row['author'],
|
||||
'publishedAt' => $row['published_at'],
|
||||
'image' => $image,
|
||||
'terms' => $this->termsFor($row['id']),
|
||||
'image' => null === $item ? null : ['alt' => $item['alt'] ?? '', 'variants' => $item['variants']],
|
||||
'terms' => $terms,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Begriffe mehrerer Seiten auf einmal.
|
||||
*
|
||||
* @param list<string> $pageIds
|
||||
*
|
||||
* @return array<string, array<string, list<array{slug: string, name: string, href: string}>>>
|
||||
*/
|
||||
private function termsForMany(array $pageIds): array
|
||||
{
|
||||
$pageIds = array_values(array_unique(array_filter($pageIds)));
|
||||
if ([] === $pageIds) {
|
||||
return [];
|
||||
}
|
||||
$terms = [];
|
||||
foreach ($this->db->fetchAllAssociative(
|
||||
'SELECT pt.page_id, t.kind, t.slug, t.name FROM term t JOIN page_term pt ON pt.term_id = t.id WHERE pt.page_id IN ('
|
||||
.implode(',', array_fill(0, count($pageIds), '?')).') ORDER BY t.name',
|
||||
$pageIds,
|
||||
) as $row) {
|
||||
$terms[$row['page_id']] ??= ['category' => [], 'tag' => []];
|
||||
$terms[$row['page_id']][$row['kind']][] = ['slug' => $row['slug'], 'name' => $row['name'], 'href' => '/'.self::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug']];
|
||||
}
|
||||
|
||||
return $terms;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<array{slug: string, name: string, href: string}>>
|
||||
*/
|
||||
@@ -192,6 +225,23 @@ final class PostRepository
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt es überhaupt einen veröffentlichten Beitrag?
|
||||
*
|
||||
* Eine Website ohne Blog soll nirgends von Beiträgen reden: kein leeres Archiv, kein Feed mit
|
||||
* null Einträgen, kein Kasten „hier erscheinen Beiträge“ auf einer Seite. Die Antwort wird für
|
||||
* die Dauer einer Anfrage gemerkt, weil mehrere Stellen sie brauchen.
|
||||
*/
|
||||
public function anyPublished(string $locale = 'de'): bool
|
||||
{
|
||||
return $this->anyPublished ??= (bool) $this->db->fetchOne(
|
||||
"SELECT 1 FROM page WHERE website_id = ? AND locale = ? AND type = 'post' AND published_revision_id IS NOT NULL AND deleted_at IS NULL LIMIT 1",
|
||||
['default', $locale],
|
||||
);
|
||||
}
|
||||
|
||||
private ?bool $anyPublished = null;
|
||||
|
||||
public function termsFor(string $pageId): array
|
||||
{
|
||||
$terms = ['category' => [], 'tag' => []];
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Content;
|
||||
|
||||
use App\Domain\InvalidPage;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Exception\TableNotFoundException;
|
||||
|
||||
/**
|
||||
* Company data and site wide switches. The structured data the pages emit is built from exactly
|
||||
@@ -30,6 +31,9 @@ final class SiteSettings
|
||||
// Datum, Autor und Kategorie über dem Beitrag. Aus, wenn der Beitrag seinen eigenen
|
||||
// Kopf mitbringt - sonst steht die Zeile über einem Bild, das den Titel schon trägt.
|
||||
'postMetaHeader' => true,
|
||||
// Das Symbol der Website im Browser-Reiter: ein Bild aus der Mediathek, aus dem beim
|
||||
// Speichern quadratische PNG-Dateien entstehen. Leer heißt: das mitgelieferte Zeichen.
|
||||
'faviconMediaId' => '',
|
||||
'robotsExtra' => '',
|
||||
// Which stored page answers at the site root. Everything else derives from this.
|
||||
'homePath' => 'home',
|
||||
@@ -42,7 +46,7 @@ final class SiteSettings
|
||||
'imprintPath' => '',
|
||||
];
|
||||
|
||||
public function __construct(private Connection $db)
|
||||
public function __construct(private Connection $db, private MediaLibrary $media)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -60,7 +64,14 @@ final class SiteSettings
|
||||
if (null !== $this->loaded) {
|
||||
return $this->loaded;
|
||||
}
|
||||
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']);
|
||||
try {
|
||||
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']);
|
||||
} catch (TableNotFoundException) {
|
||||
// Vor der Installation gibt es die Tabelle noch nicht. Fragen tun trotzdem schon
|
||||
// Seiten, die es in dem Moment geben kann - der Installer selbst und die
|
||||
// Nicht-gefunden-Seite. Die Standardwerte sind dort die richtige Antwort.
|
||||
return $this->loaded = self::DEFAULTS;
|
||||
}
|
||||
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
|
||||
|
||||
return $this->loaded = [...self::DEFAULTS, ...(is_array($stored) ? $stored : [])];
|
||||
@@ -100,6 +111,21 @@ final class SiteSettings
|
||||
}
|
||||
$values['llmsTxt'] = (bool) ($input['llmsTxt'] ?? false);
|
||||
$values['stickyHeader'] = (bool) ($input['stickyHeader'] ?? false);
|
||||
$favicon = trim((string) ($input['faviconMediaId'] ?? ''));
|
||||
if ('' !== $favicon) {
|
||||
if (1 !== preg_match('/^[a-f0-9]{32}$/D', $favicon) || null === $this->media->find($favicon)) {
|
||||
throw new InvalidPage('Das gewählte Symbolbild gibt es nicht (mehr).');
|
||||
}
|
||||
// Die Dateien entstehen hier und nicht bei jedem Seitenaufruf. Schlägt das fehl, wird
|
||||
// die Einstellung gar nicht erst gespeichert - ein Verweis auf ein fehlendes Symbol
|
||||
// wäre schlimmer als das alte Symbol.
|
||||
try {
|
||||
$this->media->renderFavicon($favicon);
|
||||
} catch (\RuntimeException $error) {
|
||||
throw new InvalidPage('Aus diesem Bild konnte kein Symbol erzeugt werden: '.$error->getMessage());
|
||||
}
|
||||
}
|
||||
$values['faviconMediaId'] = $favicon;
|
||||
$values['postMetaHeader'] = (bool) ($input['postMetaHeader'] ?? false);
|
||||
// Only comment and simple directive lines; nothing that could smuggle in a second policy.
|
||||
$extra = [];
|
||||
|
||||
@@ -49,6 +49,10 @@ final class Theme
|
||||
'linkColor' => '@link',
|
||||
'containerWidth' => '1240px',
|
||||
'headingScale' => 1.25,
|
||||
// Unterstrichene Links im Fließtext. An, weil ein Link, den nur die Farbe verrät,
|
||||
// für Menschen mit Farbsehschwäche kein Link ist. Wer eine ruhigere Seite will,
|
||||
// schaltet es hier ab - dann bleibt der Unterstrich beim Überfahren.
|
||||
'linkUnderline' => true,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -207,6 +211,7 @@ final class Theme
|
||||
}
|
||||
$base[$field] = $value;
|
||||
}
|
||||
$base['linkUnderline'] = (bool) ($input['linkUnderline'] ?? false);
|
||||
foreach (['lineHeight' => [0.8, 3.0], 'headingScale' => [1.0, 2.0]] as $field => [$min, $max]) {
|
||||
$value = (float) ($input[$field] ?? $base[$field]);
|
||||
if ($value < $min || $value > $max) {
|
||||
@@ -299,7 +304,9 @@ final class Theme
|
||||
return sprintf(
|
||||
':root{%s;--k-size:%s}.kant{font-family:var(--kf-%s);font-size:var(--k-size);line-height:%s;color:%s;background:%s}'
|
||||
.'.kant h1,.kant h2,.kant h3,.kant h4,.kant h5,.kant h6{font-family:var(--kf-%s)}'
|
||||
.'.kant .block-text a,.kant .post-excerpt a{color:%s}.kant .shell{max-width:var(--k-container)}%s',
|
||||
.'.kant .block-text a,.kant .block-list a,.kant .post-excerpt a{color:%s;text-decoration:%s}'
|
||||
.'.kant .block-text a:hover,.kant .block-list a:hover,.kant .post-excerpt a:hover{text-decoration:underline}'
|
||||
.'.kant .shell{max-width:var(--k-container)}%s',
|
||||
implode(';', $variables),
|
||||
$base['fontSize'],
|
||||
$base['fontFamily'],
|
||||
@@ -308,6 +315,7 @@ final class Theme
|
||||
$colour($base['backgroundColor']),
|
||||
$base['headingFontFamily'],
|
||||
$colour($base['linkColor']),
|
||||
($base['linkUnderline'] ?? true) ? 'underline' : 'none',
|
||||
$headings,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,6 +141,22 @@ final class MediaController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Symbol der Website, in den beiden Größen, die Browser abfragen.
|
||||
*
|
||||
* Ohne eingestelltes Bild gibt es die Dateien nicht - dann bleibt es beim mitgelieferten
|
||||
* favicon.svg, auf das die Seiten weiterhin verweisen.
|
||||
*/
|
||||
#[Route('/favicon.png', name: 'favicon_png', methods: ['GET'])]
|
||||
#[Route('/apple-touch-icon.png', name: 'favicon_touch', methods: ['GET'])]
|
||||
public function favicon(Request $request): Response
|
||||
{
|
||||
$size = 'favicon_touch' === $request->attributes->get('_route') ? 180 : 32;
|
||||
$path = $this->media->faviconFile($size) ?? throw $this->createNotFoundException();
|
||||
|
||||
return new BinaryFileResponse($path, headers: ['Content-Type' => 'image/png', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=604800', 'Content-Security-Policy' => "default-src 'none'"]);
|
||||
}
|
||||
|
||||
#[Route('/media/{id}/{width}', requirements: ['id' => '[a-f0-9]{32}', 'width' => '\d{1,4}'], methods: ['GET'])]
|
||||
public function serve(string $id, int $width): Response
|
||||
{
|
||||
|
||||
@@ -66,6 +66,8 @@ final class PageController extends AbstractController
|
||||
'canonical' => rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $home),
|
||||
'navigation' => $this->navigation($path),
|
||||
'postLists' => $this->postLists($page['document'], max(1, $request->query->getInt('seite', 1))),
|
||||
// Ohne Beiträge gibt es keinen Feed, auf den der Kopf verweisen könnte.
|
||||
'hasPosts' => $this->posts->anyPublished(),
|
||||
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null,
|
||||
// Die Nachbarn kosten eine Abfrage; sie fällt nur an, wenn die Seite den
|
||||
// Baustein wirklich enthält.
|
||||
@@ -285,6 +287,11 @@ final class PageController extends AbstractController
|
||||
}
|
||||
$page = max(1, $request->query->getInt('seite', 1));
|
||||
$result = $this->posts->published($page, $term['id'] ?? null);
|
||||
// Ein Archiv ohne Beitrag ist kein Inhalt, sondern eine Baustelle mit eigener Adresse -
|
||||
// egal ob die Website noch gar keinen Blog hat oder ob diese eine Kategorie leer ist.
|
||||
if (0 === $result['total']) {
|
||||
return $this->shell($this->render('not-found.html.twig', ['navigation' => $this->navigation('')], new Response(status: 404)));
|
||||
}
|
||||
$base = null === $term ? '/beitraege' : '/'.PostRepository::ARCHIVE_PREFIX[$kind].'/'.$term['slug'];
|
||||
|
||||
$frames = $this->frameHosts($request, ...array_values($this->areas()));
|
||||
@@ -304,6 +311,9 @@ final class PageController extends AbstractController
|
||||
#[Route('/feed.xml', name: 'feed', methods: ['GET'])]
|
||||
public function feed(): Response
|
||||
{
|
||||
if (!$this->posts->anyPublished()) {
|
||||
return $this->shell($this->render('not-found.html.twig', ['navigation' => $this->navigation('')], new Response(status: 404)));
|
||||
}
|
||||
$result = $this->posts->published(1, null, 20);
|
||||
$response = $this->shell($this->render('feed.xml.twig', [
|
||||
'posts' => $result['posts'],
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Content\Consent;
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
@@ -15,7 +16,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class SettingsController extends AbstractController
|
||||
{
|
||||
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages)
|
||||
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private MediaLibrary $media)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -38,6 +39,8 @@ final class SettingsController extends AbstractController
|
||||
'consentGroups' => Consent::GROUPS,
|
||||
'missingLegal' => $this->settings->missingLegalPages(),
|
||||
'consentGroupHelp' => Consent::GROUP_HELP,
|
||||
// Für das Symbol der Website: die Mediathek zur Auswahl, die neuesten zuerst.
|
||||
'images' => $this->media->page(1, false, 96)['items'],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>{% block title %}Verwaltung{% endblock %} · KantSites</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
{% include 'favicon.html.twig' %}
|
||||
<link rel="stylesheet" href="/assets/admin.css">
|
||||
{% block styles %}{% endblock %}
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
@@ -69,6 +69,15 @@
|
||||
<p class="field-help">Braucht mindestens Straße und Ort. Sinnvoll bei einem Standort mit Kundenverkehr.</p>
|
||||
<label class="check-label" for="llmsTxt"><input id="llmsTxt" name="llmsTxt" type="checkbox" value="1"{{ settings.llmsTxt ? ' checked' : '' }}> <code>llms.txt</code> ausliefern</label>
|
||||
<p class="field-help">Eine maschinenlesbare Liste der veröffentlichten, indexierbaren Seiten. Optional. Kein Rankingfaktor und keine Voraussetzung für irgendeine Suchfunktion; ohne Aktivierung antwortet die Adresse mit 404.</p>
|
||||
<label for="faviconMediaId">Symbol der Website</label>
|
||||
<div class="favicon-field">
|
||||
{% if settings.faviconMediaId %}<img class="favicon-preview" src="/favicon.png?v={{ settings.faviconMediaId|slice(0, 8) }}" alt="" width="32" height="32">{% endif %}
|
||||
<select id="faviconMediaId" name="faviconMediaId">
|
||||
<option value="">Mitgeliefertes Zeichen</option>
|
||||
{% for image in images %}<option value="{{ image.id }}"{{ settings.faviconMediaId == image.id ? ' selected' : '' }}>{{ image.title }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<p class="field-help">Das Bild im Browser-Reiter und auf dem Startbildschirm. Aus dem gewählten Bild entsteht beim Speichern ein quadratischer Ausschnitt aus der Mitte in 32 und 180 Pixeln – ein Logo mit viel Rand wird darin klein, ein quadratisches Zeichen funktioniert am besten.</p>
|
||||
<label for="robotsExtra">Zusätzliche Zeilen für robots.txt</label>
|
||||
<textarea id="robotsExtra" name="robotsExtra" rows="3">{{ settings.robotsExtra }}</textarea>
|
||||
<p class="field-help">Nur Kommentarzeilen und einfache Anweisungen wie <code>Disallow: /pfad</code>. Höchstens dreißig Zeilen.</p>
|
||||
|
||||
@@ -85,6 +85,10 @@
|
||||
<input id="base-scale" name="base[headingScale]" type="number" step="0.01" min="1" max="2" value="{{ theme.base.headingScale }}">
|
||||
<p class="field-help">1,25 ergibt eine ruhige Staffelung, 1,5 eine sehr kontrastreiche.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="check-label" for="base-underline"><input id="base-underline" name="base[linkUnderline]" type="checkbox" value="1"{{ (theme.base.linkUnderline ?? true) ? ' checked' : '' }}> Links im Fließtext unterstreichen</label>
|
||||
<p class="field-help">Aus sieht ruhiger aus, aber dann erkennt man einen Link im Text nur an der Farbe – für Menschen mit Farbsehschwäche also gar nicht. Beim Überfahren erscheint der Strich in beiden Fällen. Menüs, Karten und Schaltflächen sind davon nicht betroffen.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-container">Inhaltsbreite</label>
|
||||
<input id="base-container" name="base[containerWidth]" value="{{ theme.base.containerWidth }}" placeholder="1240px">
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{% if result.page > 1 %}<link rel="prev" href="{{ base }}{{ result.page > 2 ? '?seite=' ~ (result.page - 1) : '' }}">{% endif %}
|
||||
{% if result.page < result.pages %}<link rel="next" href="{{ base }}?seite={{ result.page + 1 }}">{% endif %}
|
||||
<link rel="alternate" type="application/rss+xml" title="Beiträge" href="/feed.xml">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
{% include 'favicon.html.twig' %}
|
||||
<link rel="stylesheet" href="/assets/site.css">
|
||||
<link rel="stylesheet" href="/assets/design.css">
|
||||
</head>
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
{% elseif type == 'postlist' %}
|
||||
<div id="{{ block.id }}" class="block-postlist{{ designClasses }}">
|
||||
{% set feed = postLists[block.id]|default([]) %}
|
||||
{% include 'blocks/post-list.html.twig' with {posts: feed.posts|default(feed), pages: feed.pages|default(1), page: feed.page|default(1), total: feed.total|default(null), layout: block.props.layout|default('karten'), showMeta: block.props.showMeta ?? true, showExcerpt: block.props.showExcerpt ?? true, showTerms: block.props.showTerms ?? true} only %}
|
||||
{% include 'blocks/post-list.html.twig' with {editing: editing|default(false), posts: feed.posts|default(feed), pages: feed.pages|default(1), page: feed.page|default(1), total: feed.total|default(null), layout: block.props.layout|default('karten'), showMeta: block.props.showMeta ?? true, showExcerpt: block.props.showExcerpt ?? true, showTerms: block.props.showTerms ?? true} only %}
|
||||
</div>
|
||||
{% elseif type == 'postnav' %}
|
||||
<div id="{{ block.id }}" class="block-postnav{{ designClasses }}">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{# Posts are supplied by the controller, never read from stored content. #}
|
||||
{% if posts is empty %}
|
||||
<p class="post-empty">Hier erscheinen die Beiträge, sobald welche veröffentlicht sind.</p>
|
||||
{# Auf der öffentlichen Seite bleibt die Stelle leer: eine Website ohne Blog soll nirgends
|
||||
von Beiträgen reden. Im Editor steht der Hinweis, sonst sucht jemand den Fehler. #}
|
||||
{% if editing|default(false) %}<p class="post-empty">Hier erscheinen die Beiträge, sobald welche veröffentlicht sind.</p>{% endif %}
|
||||
{% else %}
|
||||
{% set art = layout|default('karten') %}
|
||||
<ul class="post-list post-list-{{ art }}">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Einwilligung{{ settings.legalName ? ' · ' ~ settings.legalName : '' }}</title>
|
||||
<meta name="robots" content="noindex, follow">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
{% include 'favicon.html.twig' %}
|
||||
<link rel="stylesheet" href="/assets/site.css">
|
||||
</head>
|
||||
<body class="kant consent-page">
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{# Das Symbol der Website. Eigenes Bild aus den Einstellungen, sonst das mitgelieferte Zeichen.
|
||||
Die Kennung hängt als Version am Verweis, damit ein Wechsel im Browser auch ankommt. #}
|
||||
{% set favicon = siteSettings.load().faviconMediaId|default('') %}
|
||||
{% if favicon %}
|
||||
<link rel="icon" href="/favicon.png?v={{ favicon|slice(0, 8) }}" type="image/png" sizes="32x32">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v={{ favicon|slice(0, 8) }}">
|
||||
{% else %}
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
{% endif %}
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Kurz nicht erreichbar</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
{% include 'favicon.html.twig' %}
|
||||
<link rel="stylesheet" href="/assets/site.css">
|
||||
</head>
|
||||
<body class="kant">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!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"><link rel="icon" href="/favicon.svg" type="image/svg+xml"></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">{% include 'favicon.html.twig' %}</head>
|
||||
{% 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>
|
||||
{# Privacy statement, imprint and the way back to the consent decision. All three have to be
|
||||
|
||||
@@ -12,10 +12,11 @@
|
||||
<meta property="og:type" content="{{ page.type|default('page') == 'post' ? 'article' : 'website' }}">
|
||||
<meta property="og:url" content="{{ canonical }}">
|
||||
{% if socialImage|default(null) %}<meta property="og:image" content="{{ socialImage }}"><meta name="twitter:card" content="summary_large_image">{% endif %}
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
{% include 'favicon.html.twig' %}
|
||||
<link rel="stylesheet" href="/assets/site.css">
|
||||
{% if googleFonts|default(null) %}<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link rel="stylesheet" href="{{ googleFonts }}">{% endif %}
|
||||
<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 %}
|
||||
{% if page.document.schemaVersion >= 2 %}<link rel="stylesheet" href="/assets/design.css">{% endif %}
|
||||
{% if page.document.schemaVersion >= 3 %}<link rel="stylesheet" href="/assets/enhancements.css">{% endif %}
|
||||
{% if needsTabs|default(false) %}<script src="/assets/tabs.js" defer></script>{% endif %}
|
||||
|
||||
+113
-11
@@ -52,10 +52,10 @@ final class CmsTest extends TestCase
|
||||
foreach (['page-1' => 'home', 'page-2' => 'private'] as $id => $slug) {
|
||||
$this->db->insert('page', ['id' => $id, 'slug' => $slug, 'path' => $slug, 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
|
||||
}
|
||||
$this->pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db));
|
||||
$this->menus = new MenuRepository($this->db, new SiteSettings($this->db));
|
||||
$this->posts = new PostRepository($this->db, new MediaLibrary($this->db, $this->path.'-media'), new SiteSettings($this->db));
|
||||
$this->site = new SiteSettings($this->db);
|
||||
$this->pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
|
||||
$this->menus = new MenuRepository($this->db, new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
|
||||
$this->posts = new PostRepository($this->db, new MediaLibrary($this->db, $this->path.'-media'), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
|
||||
$this->site = new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media'));
|
||||
$this->redirects = new RedirectRepository($this->db);
|
||||
$this->themes = new Theme($this->db);
|
||||
$this->partials = new PartialRepository($this->db, new DocumentValidator());
|
||||
@@ -576,10 +576,9 @@ final class CmsTest extends TestCase
|
||||
$version = $this->pages->findForEditor($projekt)['version'];
|
||||
$this->posts->saveMetadata($projekt, ['publishedAt' => '2026-02-01'], ['Projekte'], [], $version);
|
||||
self::assertSame([], $this->posts->termsFor($projekt)['tag']);
|
||||
// The term itself survives as a reusable label, so its archive answers but lists nothing.
|
||||
$emptied = $this->request('/schlagwort/wordpress');
|
||||
self::assertSame(200, $emptied->getStatusCode());
|
||||
self::assertStringNotContainsString('KantTresor', $emptied->getContent());
|
||||
// The term itself survives as a reusable label, but an archive with nothing to list is a
|
||||
// dead end - it answers 404 until a post carries the tag again.
|
||||
self::assertSame(404, $this->request('/schlagwort/wordpress')->getStatusCode());
|
||||
}
|
||||
|
||||
public function testPostListBlockIsFilledByTheRendererAndRespectsItsFilter(): void
|
||||
@@ -878,6 +877,104 @@ final class CmsTest extends TestCase
|
||||
self::assertStringContainsString('Nächstes Projekt', $rand);
|
||||
}
|
||||
|
||||
public function testTheSiteIconIsMadeFromAPictureOfTheLibrary(): void
|
||||
{
|
||||
// Umut: „dann brauchen wir Favicon-Möglichkeit zum Festlegen“. Aus einem Bild der
|
||||
// Mediathek entstehen beim Speichern zwei quadratische PNG-Dateien; ohne Einstellung
|
||||
// bleibt es beim mitgelieferten Zeichen.
|
||||
self::assertStringContainsString('<link rel="icon" href="/favicon.svg"', $this->request('/')->getContent());
|
||||
self::assertSame(404, $this->request('/favicon.png')->getStatusCode());
|
||||
|
||||
$media = new MediaLibrary($this->db, $this->path.'-media');
|
||||
$file = tempnam(sys_get_temp_dir(), 'kant-symbol-');
|
||||
// Breit und flach: der Ausschnitt muss quadratisch werden, nicht gestaucht.
|
||||
$image = imagecreatetruecolor(600, 200);
|
||||
imagepng($image, $file);
|
||||
imagedestroy($image);
|
||||
$item = $media->upload(new UploadedFile($file, 'logo.png', test: true));
|
||||
@unlink($file);
|
||||
|
||||
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => $item['id']]);
|
||||
self::assertSame($item['id'], $this->site->load()['faviconMediaId']);
|
||||
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('<link rel="icon" href="/favicon.png?v='.substr($item['id'], 0, 8).'" type="image/png" sizes="32x32">', $html);
|
||||
self::assertStringContainsString('apple-touch-icon.png', $html);
|
||||
self::assertStringNotContainsString('favicon.svg', $html);
|
||||
|
||||
$symbol = $this->request('/favicon.png');
|
||||
self::assertSame(200, $symbol->getStatusCode());
|
||||
self::assertSame('image/png', $symbol->headers->get('Content-Type'));
|
||||
$size = getimagesizefromstring((string) file_get_contents($this->path.'-media/favicon-32.png'));
|
||||
self::assertSame([32, 32], [$size[0], $size[1]], 'Quadratisch, nicht gestaucht.');
|
||||
self::assertSame(200, $this->request('/apple-touch-icon.png')->getStatusCode());
|
||||
|
||||
// Ein Bild, das es nicht gibt, wird nicht gespeichert - sonst zeigte der Kopf ins Leere.
|
||||
try {
|
||||
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => str_repeat('a', 32)]);
|
||||
self::fail('Eine unbekannte Bildkennung muss abgelehnt werden.');
|
||||
} catch (InvalidPage) {
|
||||
}
|
||||
self::assertSame($item['id'], $this->site->load()['faviconMediaId']);
|
||||
}
|
||||
|
||||
public function testUnderlinedLinksAreAChoiceAndOnlyApplyToRunningText(): void
|
||||
{
|
||||
// Umut: „links haben alle underlined“. Im Menü, auf Karten und über Bildern hat ein
|
||||
// Unterstrich nichts zu suchen; im Fließtext ist er die einzige Kennzeichnung neben der
|
||||
// Farbe - deshalb dort ein Schalter statt einer stillen Entscheidung.
|
||||
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
|
||||
self::assertStringContainsString('.post-image{display:block;position:relative;overflow:hidden;text-decoration:none}', $css);
|
||||
self::assertStringNotContainsString('[aria-current=page]{text-decoration:underline}', $css);
|
||||
|
||||
$theme = new Theme($this->db);
|
||||
self::assertTrue($theme->load()['base']['linkUnderline'], 'Standard: unterstrichen.');
|
||||
self::assertStringContainsString('.kant .block-text a,.kant .block-list a,.kant .post-excerpt a{color:var(--k-link);text-decoration:underline}', $theme->css($theme->load()));
|
||||
|
||||
$gespeichert = $theme->load();
|
||||
$theme->save($gespeichert['colors'], $gespeichert['fonts'], [...$gespeichert['base'], 'linkUnderline' => null]);
|
||||
self::assertFalse($theme->load()['base']['linkUnderline']);
|
||||
$css = $theme->css($theme->load());
|
||||
self::assertStringContainsString('text-decoration:none}', $css);
|
||||
// Beim Überfahren bleibt der Strich in jedem Fall - sonst ist ein Link im Text nicht zu finden.
|
||||
self::assertStringContainsString('.kant .block-text a:hover,.kant .block-list a:hover,.kant .post-excerpt a:hover{text-decoration:underline}', $css);
|
||||
}
|
||||
|
||||
public function testAWebsiteWithoutPostsNeverMentionsPosts(): void
|
||||
{
|
||||
// Umut: „Können wir Beiträge optional machen? Wenn es keine gibt, überall ausblenden.“
|
||||
// Teilweise war es das schon - die Sitemap ließ das Archiv weg. Das Archiv selbst, der
|
||||
// Feed, der Verweis im Kopf und der Kasten „hier erscheinen Beiträge“ blieben.
|
||||
$document = DemoContent::starter('Ohne Blog');
|
||||
$document['schemaVersion'] = 18;
|
||||
$document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => ['count' => 4, 'category' => '', 'tag' => '']];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
|
||||
// Ohne einen einzigen Beitrag: kein Archiv, kein Feed.
|
||||
self::assertSame(404, $this->request('/beitraege')->getStatusCode());
|
||||
self::assertSame(404, $this->request('/feed.xml')->getStatusCode());
|
||||
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringNotContainsString('application/rss+xml', $html, 'Kein Verweis auf einen Feed, den es nicht gibt.');
|
||||
self::assertStringNotContainsString('post-empty', $html, 'Kein Kasten, der Beiträge ankündigt.');
|
||||
self::assertStringNotContainsString('Hier erscheinen die Beiträge', $html);
|
||||
// Die Sitemap führt das Archiv ebenfalls nicht.
|
||||
self::assertStringNotContainsString('/beitraege', $this->request('/sitemap.xml')->getContent());
|
||||
|
||||
// Sobald ein Beitrag veröffentlicht ist, ist alles davon wieder da.
|
||||
$this->publishedPost('erster', 'Erster Beitrag', '2026-02-01', ['Projekte'], []);
|
||||
self::assertSame(200, $this->request('/beitraege')->getStatusCode());
|
||||
self::assertSame(200, $this->request('/feed.xml')->getStatusCode());
|
||||
$mit = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('application/rss+xml', $mit);
|
||||
self::assertStringContainsString('Erster Beitrag', $mit);
|
||||
self::assertStringContainsString('/beitraege', $this->request('/sitemap.xml')->getContent());
|
||||
|
||||
// Im Editor bleibt der Hinweis stehen, sonst sucht jemand den Fehler an der leeren Stelle.
|
||||
$template = (string) file_get_contents(__DIR__.'/../templates/blocks/post-list.html.twig');
|
||||
self::assertStringContainsString('{% if editing|default(false) %}<p class="post-empty">', $template);
|
||||
}
|
||||
|
||||
public function testAPostListCanPageThroughEverythingInsteadOfCuttingOff(): void
|
||||
{
|
||||
// Umut: „fehlt die Pagination bei Projekte? Es werden nur 12 angezeigt.“ Richtig: eine
|
||||
@@ -1042,10 +1139,15 @@ final class CmsTest extends TestCase
|
||||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page_term'));
|
||||
// The terms themselves stay: they are reusable labels, not content of one post.
|
||||
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM term'));
|
||||
$archive = $this->request('/kategorie/projekte');
|
||||
self::assertSame(200, $archive->getStatusCode());
|
||||
self::assertStringNotContainsString('Weg', $archive->getContent());
|
||||
// Der Begriff existiert noch, sein Archiv hat aber nichts mehr zu zeigen: eine leere Liste
|
||||
// unter eigener Adresse ist eine Sackgasse, also 404 statt einer Seite mit Überschrift.
|
||||
self::assertSame(404, $this->request('/kategorie/projekte')->getStatusCode());
|
||||
self::assertSame(0, $this->posts->published(1, $this->posts->findTerm('category', 'projekte')['id'])['total']);
|
||||
|
||||
// Ein Beitrag in einer anderen Kategorie bringt deren Archiv zurück, dieses hier nicht.
|
||||
$this->publishedPost('bleibt', 'Bleibt', '2026-01-02', ['Technik'], []);
|
||||
self::assertSame(200, $this->request('/kategorie/technik')->getStatusCode());
|
||||
self::assertSame(404, $this->request('/kategorie/projekte')->getStatusCode());
|
||||
}
|
||||
|
||||
public function testPublishingStampsAMissingDateButKeepsAGivenOne(): void
|
||||
|
||||
@@ -42,7 +42,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
TestSchema::build($this->db);
|
||||
$this->db->insert('website', ['id' => 'default', 'name' => 'Test', 'default_locale' => 'de', 'theme' => '{}']);
|
||||
$this->db->insert('page', ['id' => 'p1', 'slug' => 'home', 'path' => 'home', 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
|
||||
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db));
|
||||
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
|
||||
$revision = $pages->saveDraft('p1', DemoContent::pages()['home'], 0);
|
||||
$pages->publish('p1', $revision, 1);
|
||||
// The navigation is part of the header area, so the fixture needs one.
|
||||
@@ -192,7 +192,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
|
||||
public function testMenuWritesNeedAnEditorRoleACsrfTokenAndTheCurrentVersion(): void
|
||||
{
|
||||
$menus = new MenuRepository($this->db, new SiteSettings($this->db));
|
||||
$menus = new MenuRepository($this->db, new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
|
||||
$menus->save('header', 'Hauptnavigation', [['label' => 'Start', 'type' => 'page', 'pageId' => 'p1', 'anchor' => '', 'children' => []]], 0);
|
||||
|
||||
$client = $this->login('editor');
|
||||
@@ -239,7 +239,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
$document = DemoContent::starter('Kontakt');
|
||||
$document['schemaVersion'] = 8;
|
||||
$document['blocks'][] = ['id' => 'formblock', 'type' => 'form', 'props' => ['form' => 'kontakt']];
|
||||
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db));
|
||||
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
|
||||
$revision = $pages->saveDraft('p1', $document, (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['p1']));
|
||||
$pages->publish('p1', $revision, (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['p1']));
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
@@ -126,13 +127,13 @@ final class InstallationTest extends TestCase
|
||||
// own address would look broken.
|
||||
$home = $this->make()->isInstalled();
|
||||
self::assertFalse($home, 'Ohne Konto gilt die Installation als unfertig.');
|
||||
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db));
|
||||
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->root.'/var/media')));
|
||||
self::assertNotNull($pages->findPublished('home'));
|
||||
self::assertNotNull($pages->findPublished('impressum'));
|
||||
self::assertSame('https://beispiel.de', $this->settingsOrigin());
|
||||
|
||||
// The legal pages are linked, otherwise the consent gate and the footer stay empty.
|
||||
$settings = (new SiteSettings($this->db))->load();
|
||||
$settings = (new SiteSettings($this->db, new MediaLibrary($this->db, $this->root.'/var/media')))->load();
|
||||
self::assertSame('datenschutz', $settings['privacyPath']);
|
||||
self::assertSame('impressum', $settings['imprintPath']);
|
||||
self::assertSame('Beispiel GmbH', $settings['legalName']);
|
||||
@@ -201,7 +202,7 @@ final class InstallationTest extends TestCase
|
||||
|
||||
private function make(): Installation
|
||||
{
|
||||
$settings = new SiteSettings($this->db);
|
||||
$settings = new SiteSettings($this->db, new MediaLibrary($this->db, $this->root.'/var/media'));
|
||||
$validator = new DocumentValidator();
|
||||
|
||||
return new Installation(
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Tests;
|
||||
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\SiteSettings;
|
||||
@@ -48,7 +49,7 @@ final class UserAdminTest extends WebTestCase
|
||||
TestSchema::build($this->db);
|
||||
$this->db->insert('website', ['id' => 'default', 'name' => 'Test', 'default_locale' => 'de', 'theme' => '{}']);
|
||||
$this->db->insert('page', ['id' => 'p1', 'slug' => 'home', 'path' => 'home', 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
|
||||
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db));
|
||||
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
|
||||
$pages->publish('p1', $pages->saveDraft('p1', DemoContent::pages()['home'], 0), 1);
|
||||
$partials = new PartialRepository($this->db, new DocumentValidator());
|
||||
foreach (DemoContent::areas() as $kind => $area) {
|
||||
|
||||
@@ -5,6 +5,7 @@ require dirname(__DIR__).'/vendor/autoload.php';
|
||||
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\SiteSettings;
|
||||
@@ -20,7 +21,7 @@ $url = 'sqlite:///'.str_replace('\\', '/', $path);
|
||||
$db = ConnectionFactory::create($url);
|
||||
TestSchema::build($db);
|
||||
$db->insert('website', ['id' => 'default', 'name' => 'Browser test', 'default_locale' => 'de', 'theme' => '{}']);
|
||||
$pages = new PageRepository($db, new DocumentValidator(), new SiteSettings($db));
|
||||
$pages = new PageRepository($db, new DocumentValidator(), new SiteSettings($db, new MediaLibrary($db, $path.'-media')));
|
||||
foreach (DemoContent::pages() as $slug => $document) {
|
||||
$id = 'test-'.$slug;
|
||||
$db->insert('page', ['id' => $id, 'slug' => $slug, 'path' => $slug, 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
|
||||
|
||||
Reference in new issue
Block a user