diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index f696c1e..08d4bdd 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -5,3 +5,6 @@ twig: # Reading the decision needs the current request, so the templates ask the gate directly # instead of every controller passing the same three values along. consentGate: '@App\Content\Consent' + # Dasselbe für die Einstellungen der Website: der Kopfbereich fragt, ob er beim + # Scrollen kleben soll, und das steht in keinem Dokument. + siteSettings: '@App\Content\SiteSettings' diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7bc996a..0e802d4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -440,6 +440,22 @@ 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 043 – Was beim Nachbauen noch fehlte (Dokumentschema v17, 13.09.2026) + +Umut hat den Nachbau angesehen und vier Dinge benannt: der Abstand zwischen den Lösungskarten und der Projektliste fehlt, die Karten sind zu hoch, die Beiträge sehen nicht aus wie im Original, und später: im Hero fehlen die letzten Projekte als Slider, der Kopfbereich klebt nicht beim Scrollen, das Logo verlinkt nicht auf die Startseite. Zwei davon waren mein Aufbau, vier waren fehlende Fähigkeiten. + +**Eine Beitragsliste kann jetzt ein Projektraster sein.** Im Original zeigt eine Projektkarte Bild und Titel. Die Beitragsliste zeigte immer zusätzlich Datum, Autor, Auszug und Kategorie – richtig für ein Blogarchiv, falsch für eine Projektseite, und nicht abstellbar. Drei Schalter (`showMeta`, `showExcerpt`, `showTerms`) beantworten das, mit den bisherigen Werten als Voreinstellung, damit bestehende Seiten sich nicht ändern. + +**Dabei wäre es beinahe schiefgegangen, und zwar lautlos:** `{{ block.props.showMeta|default(true) }}` gibt in Twig auch dann `true` zurück, wenn der Wert `false` ist – der `default`-Filter greift auf jeden leeren Wert, nicht nur auf einen fehlenden. Die Einstellung hätte sich speichern lassen und nichts getan. Mit `??` statt `|default()` bleibt `false` ein `false`. Der Test prüft beide Richtungen, weil nur die Gegenprobe diesen Fehler findet. + +**Ein Bild darf verlinken.** Klingt selbstverständlich und fehlte: ein Logo im Kopfbereich ohne Verweis auf die Startseite ist der erste Klick, den jeder sucht. Die Adresse geht durch dieselbe Prüfung wie jeder andere Link, also kein `javascript:`, kein `data:`, kein schemaloses `//fremd.test`. + +**Der Kopfbereich kann beim Scrollen oben bleiben.** Das ist bewusst eine Einstellung der Website und keine Eigenschaft eines Blocks: kleben kann nur das Element, das den Bereich umschließt (`.site-header`), und ein Abschnitt *innerhalb* davon würde nur solange kleben, wie sein Elternelement sichtbar ist – also gar nicht. Wer es im Designer an einem Abschnitt versucht hätte, hätte eine Einstellung gefunden, die nichts tut. + +**Der Slider im Hero** brauchte nichts Neues: vier Folien mit Bild und Titel im vorhandenen Slider-Baustein. Ehrlich dazugesagt: **er rotiert nicht von selbst.** Öffentliche Seiten laufen ohne JavaScript, und daran ändert eine Animation nichts – gewischt, gescrollt und mit der Tastatur bedient wird er, und er rastet je Folie ein. Eine automatische Rotation wäre als reine CSS-Animation möglich, aber sie wäre nicht anhaltbar, und ein Karussell, das sich nicht anhalten lässt, ist ein Barrierefreiheitsproblem. + +**Und ein Fehler, der größer ist als alles andere in diesem Paket:** die Liste bekannter Schemafassungen im Editor endete bei **12**, während Bausteine längst v13 bis v15 verlangten. Folge: **in einem aktuellen Dokument ließ sich im Editor gar kein Baustein mehr einfügen** – jeder Versuch endete mit „Unbekanntes Dokumentschema“ – und Symbol, Fortschritt, Slider und eingebetteter Inhalt nirgends. Der Playwright-Editortest hat es nicht gefunden, weil er auf einem alten Dokument arbeitet und die betroffenen Typen nicht einfügt. Jetzt steht die höchste Fassung als **eine Zahl an einer Stelle** (`SCHEMA_VERSION`), und ein Test fügt jeden Bausteintyp in Dokumente von v1 bis v17 ein. + ## ADR 042 – Der Nachbau von kant-it.de, und was er gefunden hat (12.09.2026) Umut: „Baue mal lokal kant-it.de komplett nach. Ich möchte sehen ob das funktioniert.“ Gebaut ist die Startseite mit allen neun Abschnitten, die Projekteseite, vier Projektbeiträge, Impressum und Datenschutzgerüst, Kopf- und Fußbereich, zwei Menüs, das Kontaktformular, zwölf Bilder und die Palette der echten Website – in einer eigenen Datenbank, damit die Entwicklungsinstallation unberührt bleibt. diff --git a/docs/QUALITY.md b/docs/QUALITY.md index f27db4a..cd9ecbd 100644 --- a/docs/QUALITY.md +++ b/docs/QUALITY.md @@ -1,5 +1,14 @@ # Qualitätsbericht – erster ausführbarer Prototyp +## Ergänzung: Nachbau überarbeitet, Dokumentschema v17, 13.09.2026 + +- **389 PHP-Tests / 1636 Assertions** unter PHP 8.2.12 und 8.4.22, **18 JavaScript-Modelltests**, Playwright-Editortest, öffentliche Browserprüfung. +- **Der schwerste Fund des Tages kommt aus dem Editor, nicht aus dem Nachbau:** in einem Dokument ab v13 ließ sich kein Baustein mehr einfügen, und Symbol, Fortschritt, Slider und eingebetteter Inhalt in gar keinem. Der bestehende Editortest hat das nicht bemerkt, weil er ein altes Dokument bearbeitet. Der neue Test fügt jeden Typ in Dokumente von v1 bis v17 ein. +- **Ein zweiter, der nur in der Gegenprobe sichtbar wird:** `|default(true)` macht in Twig aus einem gespeicherten `false` ein `true`. Die neuen Anzeigeoptionen der Beitragsliste hätten sich speichern lassen und nichts getan – dieselbe Krankheit wie Rahmenstärke und Bildrundung. Der Test prüft beide Richtungen. +- Neu geprüft: verlinktes Bild (mit denselben Linkregeln, sechs Fälle), klebender Kopfbereich als Einstellung, Projektraster gegen Blogarchiv. +- Am Nachbau nachgemessen: Lösungskarte 213 px statt 300, Abstand zur Projektliste 54 px, Projektkarte 337 px mit Bild und Titel – das Original liegt bei 324 px. +- **Nicht geprüft:** ob der klebende Kopfbereich auf einem Telefon mit dynamischer Adressleiste gut aussieht, und der Slider ist nicht auf einem echten Touchgerät gewischt worden. + ## Ergänzung: kant-it.de nachgebaut, 12.09.2026 - **381 PHP-Tests / 1619 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung. diff --git a/docs/STATUS.md b/docs/STATUS.md index 74a2928..d566b2c 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -27,6 +27,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. - Adresshierarchie: neue Spalten `path`, `parent_id`, `type`, `created_at` über Migration `20260912000400`; websiteweit eindeutiger Pfad statt eindeutigem Slug, Tiefe bis vier Ebenen, Beiträge ohne Unterseiten, reservierte Adressen und Adressableitung aus dem Titel. Öffentliche Route bedient verschachtelte Pfade wie `/leistungen/webdesign`; ein Lookahead hält `/admin`, `/media` und `/assets` aus dem Auffangmuster. - **Ladeverhalten gemessen statt vermutet** (ADR 039, Arbeitspaket M): ein Zähler liefert die Datenbankabfragen je Anfrage im Kopf `X-Kant-Queries` (außerhalb der Produktion). Die erste Messung zeigte 18 Abfragen auf der Startseite, weil Einstellungen und Theme je Aufrufer erneut gelesen wurden; beide merken sich ihren Stand jetzt für die Dauer einer Anfrage – **18 → 8**. Die Browserprüfung misst je Seite HTML, CSS, Anfragen, Skripte und Abfragen gegen Obergrenzen: Startseite 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 0 Skripte. - **Das erste Bild einer Seite lädt sofort**, alles darunter weiterhin verzögert. Es ist meistens das größte Ding über der Falz. +- **Dokumentschema v17** (ADR 043): eine Beitragsliste kann Datum, Auszug und Kategorie ausblenden und damit als Projektraster erscheinen; ein Bild kann verlinken (Logo auf die Startseite). Dazu die Einstellung „Kopfbereich beim Scrollen oben halten“. Behoben: im Editor ließ sich in Dokumenten ab v13 kein Baustein mehr einfügen. - **Die Gestaltung färbt jetzt auch die Bausteine** (ADR 042): Symbole, Knöpfe, Rahmen, ruhiger Text, Fokusring und Einwilligungsdialog nehmen die Farben des Themes statt fester Werte aus `site.css`. Der Einwilligungsdialog lässt sich zusätzlich eigens färben über eine Palettenfarbe mit dem Schlüssel `consent`. Überschriften werden nicht mehr getrennt. - **Installer (Arbeitspaket N, ADR 041):** `/install` führt in sechs Schritten von einer leeren Installation zu einer Website – Systemprüfung, Datenbank (SQLite oder MySQL, Verbindung sofort geprüft), Migration im Prozess ohne Konsole, Website mit Startseite, Kopf-/Fußbereich, Menü sowie Impressum und Datenschutzgerüst, erstes Administratorkonto, Abschlussliste. Solange keine Installation existiert, führt jede Adresse dorthin; sobald ein Konto existiert, antwortet `/install` mit 404. Davor liegt `scripts/install.php`: eine einzelne Datei, die das Releasearchiv mit fester Adresse und fester SHA-256 lädt, gegen ZIP-Slip gesichert entpackt und sich danach selbst löscht. - **Fassungsverlauf, Einsendungen und Weiterleitungen blättern** statt bei 30, 50 beziehungsweise 2000 Einträgen stillschweigend aufzuhören. diff --git a/public/assets/editor-model.js b/public/assets/editor-model.js index 954ef8e..6219376 100644 --- a/public/assets/editor-model.js +++ b/public/assets/editor-model.js @@ -108,8 +108,15 @@ export function moveBlock(document, selected, targetId, inside = false) { export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }; // Called only inside a history transaction: all other fields and IDs stay untouched. +// Die höchste Fassung, die dieser Editor kennt. Sie stand bisher als Liste an zwei Stellen und +// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument +// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt +// nirgends. Eine Zahl, an einer Stelle. +export const SCHEMA_VERSION = 17; +const known = version => Number.isInteger(version) && version >= 1 && version <= SCHEMA_VERSION; + export function upgradeDocument(document, version = 2) { - if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].includes(document.schemaVersion) || ![2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].includes(version)) throw new Error('Unbekanntes Dokumentschema.'); + if (!known(document.schemaVersion) || !known(version) || version < 2) throw new Error('Unbekanntes Dokumentschema.'); document.schemaVersion = Math.max(document.schemaVersion, version); return document; } @@ -126,7 +133,7 @@ export function effectiveDesign(block, device, key) { } export function setDesign(document, id, device, key, value) { - if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.'); + if (!known(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.'); if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.'); const block = locate(document.blocks, id)?.block; if (!block) return; diff --git a/public/assets/editor.js b/public/assets/editor.js index e9430a8..ba2a71b 100644 --- a/public/assets/editor.js +++ b/public/assets/editor.js @@ -283,9 +283,13 @@ function renderProperties() { field('Anzahl Beiträge', 'count', block.props.count, { number: true, help: 'Zwischen 1 und 24. Die Beiträge selbst kommen beim Ausliefern dazu, nicht aus dem gespeicherten Inhalt.' }); field('Nur Kategorie', 'category', block.props.category || '', { help: 'Adresse der Kategorie, zum Beispiel projekte. Leer lassen für alle Beiträge.' }); field('Nur Schlagwort', 'tag', block.props.tag || '', { help: 'Adresse des Schlagworts. Leer lassen für alle Beiträge.' }); + field('Datum und Autor zeigen', 'showMeta', block.props.showMeta !== false, { checkbox: true }); + field('Auszug zeigen', 'showExcerpt', block.props.showExcerpt !== false, { checkbox: true }); + field('Kategorie zeigen', 'showTerms', block.props.showTerms !== false, { checkbox: true, help: 'Alle drei aus: ein Raster aus Bild und Titel, wie es zu Projekten passt.' }); } if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' }); if (block.type === 'image') { + field('Linkziel', 'href', block.props.href || '', { help: 'Leer lassen, wenn das Bild nicht verlinkt. Für ein Logo im Kopfbereich meist /.' }); const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen'; picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker); field('Bildadresse', 'src', block.props.src, { help: 'Vorhandene lokale Datei oder HTTPS-Adresse. Eine Mediathek folgt später.' }); diff --git a/public/assets/site.css b/public/assets/site.css index 9da8d3a..b9c3a2e 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -6,7 +6,7 @@ h1,h2,h3,h4,h5,h6{hyphens:manual} p{hyphens:auto} .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)}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:underline}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}.section-inner>.block-text:first-child{font-size:12px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:color-mix(in srgb,var(--accent) 68%,currentColor);margin:0 0 30px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500;max-width:900px}h2{font-size:clamp(32px,4vw,49px);font-weight:500;max-width:760px}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;max-width:690px;margin:0 0 23px;color: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{border:1px solid var(--line);border-radius:12px;padding:30px;min-width:0}.block-column .block-text{font-size:15px}.block-column>.block-text:first-child{font-size:12px;font-weight:700;letter-spacing:1px;color:color-mix(in srgb,var(--accent) 68%,currentColor);margin-bottom:34px}.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 .section-inner>.block-text:first-child{color:var(--mint)}.tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px} +*{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}.section-inner>.block-text:first-child{font-size:12px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:color-mix(in srgb,var(--accent) 68%,currentColor);margin:0 0 30px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500;max-width:900px}h2{font-size:clamp(32px,4vw,49px);font-weight:500;max-width:760px}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;max-width:690px;margin:0 0 23px;color: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{border:1px solid var(--line);border-radius:12px;padding:30px;min-width:0}.block-column .block-text{font-size:15px}.block-column>.block-text:first-child{font-size:12px;font-weight:700;letter-spacing:1px;color:color-mix(in srgb,var(--accent) 68%,currentColor);margin-bottom:34px}.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 .section-inner>.block-text:first-child{color:var(--mint)}.tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px} @media(max-width:650px){.shell{width:calc(100% - 36px)}.site-header nav{width:100%;justify-content:space-between;gap:12px;font-size:12px}.site-header nav .nav-link{padding:10px}.nav-link span{margin-left:6px}.block-section,.block-section:first-child{padding-block:48px}h1{font-size:49px}.block-text{font-size:16px}.block-column{padding:26px}.site-footer{align-items:flex-start;flex-direction:column;gap:10px}.site-footer p{margin:0}.block-row{gap:16px}} /* Menus. Submenus open with details/summary, so the navigation works without JavaScript. */ diff --git a/src/Content/DocumentValidator.php b/src/Content/DocumentValidator.php index 09a5db6..3de06fa 100644 --- a/src/Content/DocumentValidator.php +++ b/src/Content/DocumentValidator.php @@ -38,7 +38,7 @@ final class DocumentValidator public function validate(array $document, bool $requireHeading = true): void { $schemaVersion = $document['schemaVersion'] ?? null; - $this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], true), 'Unsupported document schema.'); + $this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17], true), 'Unsupported document schema.'); $this->text($document['title'] ?? null, 160); $this->text($document['description'] ?? null, 320); $this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.'); @@ -132,6 +132,16 @@ final class DocumentValidator $this->require(is_string($props[$kind]) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props[$kind]) && strlen($props[$kind]) <= 80, 'Ungültiger Filter für die Beitragsliste.'); } } + // Was eine Karte zeigt. Ein Projektraster besteht aus Bild und Titel; Datum, Auszug + // und Kategorie gehören in ein Blogarchiv und lassen eine Projektseite falsch + // aussehen. Voreinstellung ist der alte Zustand, damit bestehende Seiten sich nicht + // ändern, wenn niemand etwas einstellt. + foreach (['showMeta', 'showExcerpt', 'showTerms'] as $switch) { + if (isset($props[$switch])) { + $this->require($schemaVersion >= 17, 'Die Anzeigeoptionen der Beitragsliste brauchen Schema v17.'); + $this->require(is_bool($props[$switch]), sprintf('"%s" ist ja oder nein.', $switch)); + } + } } if ('button' === $type) { $this->url($props['href'] ?? null); @@ -249,6 +259,12 @@ final class DocumentValidator } if ('image' === $type) { $this->url($props['src'] ?? null); + // Ein Bild darf verlinken. Klingt selbstverstaendlich, fehlte aber: ein Logo im + // Kopfbereich ohne Verweis auf die Startseite ist der erste Klick, den jeder sucht. + if ('' !== ($props['href'] ?? '')) { + $this->require($schemaVersion >= 17, 'Ein verlinktes Bild braucht Schema v17.'); + $this->url($props['href']); + } $this->require(is_bool($props['decorative'] ?? null), 'Image purpose is required.'); $this->text($props['alt'] ?? null, 300, $props['decorative']); $this->require(!$props['decorative'] || '' === $props['alt'], 'Decorative images need empty alternative text.'); diff --git a/src/Content/SiteSettings.php b/src/Content/SiteSettings.php index 2c7688c..8767ea7 100644 --- a/src/Content/SiteSettings.php +++ b/src/Content/SiteSettings.php @@ -24,6 +24,9 @@ final class SiteSettings 'sameAs' => [], 'localBusiness' => false, 'llmsTxt' => false, + // Der Kopfbereich bleibt beim Scrollen oben stehen. Eine Eigenschaft der Website und + // nicht des Dokuments: kleben kann nur das Element, das den Bereich umschliesst. + 'stickyHeader' => false, 'robotsExtra' => '', // Which stored page answers at the site root. Everything else derives from this. 'homePath' => 'home', @@ -93,6 +96,7 @@ final class SiteSettings throw new InvalidPage('Ein lokales Unternehmen braucht mindestens Straße und Ort.'); } $values['llmsTxt'] = (bool) ($input['llmsTxt'] ?? false); + $values['stickyHeader'] = (bool) ($input['stickyHeader'] ?? false); // Only comment and simple directive lines; nothing that could smuggle in a second policy. $extra = []; foreach (preg_split('/\R/', (string) ($input['robotsExtra'] ?? '')) ?: [] as $line) { diff --git a/templates/admin/settings.html.twig b/templates/admin/settings.html.twig index 299d545..0b3b781 100644 --- a/templates/admin/settings.html.twig +++ b/templates/admin/settings.html.twig @@ -73,6 +73,7 @@
Nur Kommentarzeilen und einfache Anweisungen wie Disallow: /pfad. Höchstens dreißig Zeilen.
Ohne eingebettete Inhalte anderer Anbieter braucht diese Website keine Einwilligung – dann ist ein Banner nur im Weg und sollte aus bleiben. Eingebettete Inhalte laden ohnehin erst nach Zustimmung, unabhängig von diesem Schalter.