From c8a8fa74601cb168df7c2eed5a8a79d5c16e3f49 Mon Sep 17 00:00:00 2001 From: Umut Kantarci Date: Wed, 16 Sep 2026 07:32:35 +0200 Subject: [PATCH] Rebuild DEEG as a fifth installation, add stepped forms and a plugin system, and clear the review backlog Forms gained steps, conditions, image choices, number fields with units and a summary step; the DEEG price finder uses all of it. Plugins are manifest plus Twig: blocks, settings, containers, routes, presets and theme packs, switched per installation. The review backlog is done: archive pages render through the normal page pipeline, forms and post lists work inside areas and sections, repeated sections get their own ids, breakpoints and colours come from the palette, backup and update work on MySQL and lock each other out. The task worker no longer shortens a process that has no time limit: set_time_limit() sets the limit instead of extending it, which turned an unlimited CLI run into eighty seconds and killed the full test suite far away from the scheduler. Co-Authored-By: Claude Opus 5 --- .claude/launch.json | 20 + .github/workflows/ci.yml | 5 + README.md | 18 +- config/packages/twig.yaml | 3 + config/services.yaml | 8 + docs/ARCHITECTURE.md | 84 ++ docs/AUSBAUPLAN.md | 4 +- docs/PLUGINS.md | 183 ++++ docs/QUALITY.md | 4 + docs/STATUS.md | 80 ++ docs/UPDATE.md | 4 + migrations/Version20260915000100.php | 39 + migrations/Version20260916000100.php | 32 + plugins/vergleich/assets/bildvergleich.css | 23 + plugins/vergleich/assets/bildvergleich.js | 14 + .../vergleich/blocks/bildvergleich.html.twig | 24 + plugins/vergleich/blocks/galerie.html.twig | 10 + plugins/vergleich/plugin.json | 63 ++ plugins/vergleich/routes/daten.json.twig | 3 + public/assets/admin.css | 12 + public/assets/design.css | 2 +- public/assets/editor-model.js | 58 +- public/assets/editor.css | 2 +- public/assets/editor.js | 67 +- public/assets/enhancements.css | 2 +- public/assets/form-editor.js | 66 +- public/assets/form-steps.js | 198 ++++ public/assets/preview.css | 2 +- public/assets/site.css | 86 +- scripts/browser-check.mjs | 16 +- scripts/build-design.mjs | 2 +- scripts/build-release.php | 2 +- scripts/editor-check.mjs | 103 +- scripts/reset-immobilien-deeg.sh | 13 + scripts/seed-immobilien-deeg.php | 996 ++++++++++++++++++ src/Content/DocumentValidator.php | 53 +- src/Content/FormRepository.php | 253 ++++- src/Content/PageRepository.php | 4 +- src/Content/PartialRepository.php | 69 +- src/Content/Theme.php | 26 + src/Controller/EditorController.php | 67 +- src/Controller/FormAdminController.php | 14 +- src/Controller/FormController.php | 23 +- src/Controller/PageController.php | 227 +++- src/Controller/PartialController.php | 38 +- src/Controller/PluginAdminController.php | 73 ++ src/Controller/PluginAssetController.php | 36 + src/Controller/TaskController.php | 6 +- src/EventSubscriber/TaskWorkerSubscriber.php | 18 +- src/Ops/ArchiveUpdate.php | 90 +- src/Ops/Backup.php | 107 +- src/Ops/Download.php | 60 ++ src/Ops/Updater.php | 71 +- src/Plugin/PluginRegistry.php | 609 +++++++++++ src/Twig/PluginExtension.php | 65 ++ templates/admin/forms.html.twig | 53 +- templates/admin/icon.html.twig | 1 + templates/admin/plugins.html.twig | 82 ++ templates/admin/shell.html.twig | 3 +- templates/archive.html.twig | 63 -- templates/blocks/block.html.twig | 33 +- templates/blocks/form.html.twig | 67 +- templates/page.html.twig | 9 +- tests/ArchiveUpdateTest.php | 4 +- tests/BacklogTest.php | 308 ++++++ tests/CmsTest.php | 1 - tests/EditorSecurityTest.php | 4 +- tests/FormStepsTest.php | 351 ++++++ tests/PluginTest.php | 375 +++++++ tests/ReleaseOnDisk.php | 11 + tests/SchedulerTest.php | 34 + tests/TestSchema.php | 4 + tests/UpdaterTest.php | 2 +- tests/editor-model.test.mjs | 33 +- 74 files changed, 5352 insertions(+), 243 deletions(-) create mode 100644 docs/PLUGINS.md create mode 100644 migrations/Version20260915000100.php create mode 100644 migrations/Version20260916000100.php create mode 100644 plugins/vergleich/assets/bildvergleich.css create mode 100644 plugins/vergleich/assets/bildvergleich.js create mode 100644 plugins/vergleich/blocks/bildvergleich.html.twig create mode 100644 plugins/vergleich/blocks/galerie.html.twig create mode 100644 plugins/vergleich/plugin.json create mode 100644 plugins/vergleich/routes/daten.json.twig create mode 100644 public/assets/form-steps.js create mode 100644 scripts/reset-immobilien-deeg.sh create mode 100644 scripts/seed-immobilien-deeg.php create mode 100644 src/Controller/PluginAdminController.php create mode 100644 src/Controller/PluginAssetController.php create mode 100644 src/Plugin/PluginRegistry.php create mode 100644 src/Twig/PluginExtension.php create mode 100644 templates/admin/plugins.html.twig delete mode 100644 templates/archive.html.twig create mode 100644 tests/BacklogTest.php create mode 100644 tests/FormStepsTest.php create mode 100644 tests/PluginTest.php diff --git a/.claude/launch.json b/.claude/launch.json index 632ec13..c27f906 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -68,6 +68,26 @@ "$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/maler-drews.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/maler-drews-media'; $env:SITE_ORIGIN='http://127.0.0.1:8093'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8093 -t public bin/dev-router.php" ], "port": 8093 + }, + { + "name": "immobilien-deeg", + "runtimeExecutable": "powershell", + "runtimeArgs": [ + "-NoProfile", + "-Command", + "$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/immobilien-deeg.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/immobilien-deeg-media'; $env:SITE_ORIGIN='http://127.0.0.1:8094'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8094 -t public bin/dev-router.php" + ], + "port": 8094 + }, + { + "name": "immobilien-deeg-prod", + "runtimeExecutable": "powershell", + "runtimeArgs": [ + "-NoProfile", + "-Command", + "$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/immobilien-deeg.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/immobilien-deeg-media'; $env:SITE_ORIGIN='http://127.0.0.1:8095'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8095 -t public bin/dev-router.php" + ], + "port": 8095 } ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efea11f..aea1828 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,3 +83,8 @@ jobs: - run: php vendor/bin/doctrine-migrations migrate --no-interaction - run: php bin/console cms:seed-demo - run: php bin/console cms:seed-demo + # Vorher hoerte der Job hier auf und sagte nichts ueber Sicherung und Betrieb auf MySQL. + - run: php bin/console cms:health + - run: php bin/console cms:backup --keep=2 + - run: php bin/console cms:backup --list + - run: php vendor/bin/phpunit diff --git a/README.md b/README.md index ac01d34..2641f50 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # KantSites -Selbst betreibbares Block-CMS für PHP-Webhosting mit visuellem Editor nach dem Bedienprinzip des Flatsome UX Builders. Symfony 7.4, Twig, Doctrine DBAL/Migrations; SQLite oder MySQL/MariaDB. Öffentliche Seiten kommen als fertiges HTML ohne Pflicht-JavaScript, mit Sitemap, Feed, strukturierten Daten und `llms.txt`. Webinstaller, Releasearchive mit Update aus der Verwaltung, Sicherung, Aufgabenplan, Formulare, Statistik und Einwilligung sind eingebaut. Vier Websites laufen lokal als eigene Installationen (Kant-IT, Elektro Bernhard, Tagespflege Moldovan, Malerservice Drews), `web.kant-it.de` und das Drews-Hosting mit Releasearchiven. +Selbst betreibbares Block-CMS für PHP-Webhosting mit visuellem Editor nach dem Bedienprinzip des Flatsome UX Builders. Symfony 7.4, Twig, Doctrine DBAL/Migrations; SQLite oder MySQL/MariaDB. Öffentliche Seiten kommen als fertiges HTML ohne Pflicht-JavaScript, mit Sitemap, Feed, strukturierten Daten und `llms.txt`. Webinstaller, Releasearchive mit Update aus der Verwaltung, Sicherung, Aufgabenplan, Formulare, Statistik und Einwilligung sind eingebaut. Fünf Websites laufen lokal als eigene Installationen (Kant-IT, Elektro Bernhard, Tagespflege Moldovan, Malerservice Drews, DEEG Immobilien), `web.kant-it.de` und das Drews-Hosting mit Releasearchiven. Die übernommenen Originaldokumente sind `AGENTS.md` und `ENTWICKLUNGSPLAN.md`. Den tatsächlichen Stand enthält `docs/STATUS.md`, Entscheidungen und Datenmodell stehen in `docs/ARCHITECTURE.md`. @@ -16,6 +16,18 @@ Die übernommenen Originaldokumente sind `AGENTS.md` und `ENTWICKLUNGSPLAN.md`. Wähle eine Zeile oder eine ihrer Spalten: auf jeder Grenze zwischen zwei Spalten liegt ein Griff. Ziehen macht die eine Spalte breiter und die andere schmaler, Loslassen ist ein Schritt für „Rückgängig“; die Pfeiltasten verschieben um fünf Prozentpunkte. Der Wert steht auch im Design-Bedienfeld der Zeile unter **Layout → Spaltenbreiten**, etwa `2 1` oder `25 50 25`, und gilt je Gerät. Wo die Spalten gestapelt sind, etwa auf dem Handy, gibt es keinen Griff und die Spalten bleiben untereinander. +## Rückstände der Gesamtprüfung (unveröffentlicht) + +Archivseiten (`/beitraege`, Kategorien, Schlagwörter) sind jetzt gewöhnliche Seiten mit Kopf, Fuß, Sektionen, eigenem CSS und Nach-oben-Knopf; blätternde Beitragslisten tragen Canonical mit Seitenzahl und `rel=prev/next`. Formulare, Beitragslisten und Brotkrumen funktionieren auch in Kopf, Fuß und verknüpften Sektionen, eigene Skripte von Bereichen laufen. Eine zweimal verknüpfte Sektion bekommt beim zweiten Mal eigene Kennungen. Tablet und Telefon trennen sich überall bei 767 px. Dunkle Paletten färben Formularfelder, Fehlermeldungen, Skip-Link und Aufruf-Buttons mit; es gibt Druckstile. **Betrieb:** Sicherung, Wiederherstellung, Archiv-Update und Rückweg laufen auch auf MySQL/MariaDB (`mysqldump` und `mysql` müssen aufrufbar sein), Updates sperren sich gegenseitig (`var/update.lock`), das Archiv wird direkt in eine Datei geladen, `public/.htaccess` behält beim Archiv-Update eingeschaltete HTTPS- und HSTS-Zeilen, und nach dem Update werden `var/update/` (bis auf zwei) und alte Caches aufgeräumt. Der Editor zeigt in der Vorschau eigenes CSS, Bereichsstile und Formulare; frisch eingefügte Kontaktdaten, Formulare, verknüpfte Sektionen und externe Inhalte sind gültig, bis man sie füllt. + +## Plugins (unveröffentlicht) + +Erweiterungen liegen als Ordner unter `plugins/` mit einer `plugin.json`, Twig-Vorlagen und optional Stylesheet und Skript unter `assets/`. Unter **Plugins** (Administrator) werden sie ein- und ausgeschaltet; ihre Bausteine erscheinen dann im Editor unter „Erweiterungen“, mit einem Eigenschaftenpanel aus dem Schema des Manifests. Der Kern prüft die Eigenschaften beim Speichern und beim Rendern, liefert Stylesheet und Skript nur auf Seiten mit dem Baustein aus und lässt die Bausteine eines ausgeschalteten Plugins unangetastet in den Seiten stehen. Ein Plugin kann außerdem Container-Bausteine (mit Liste erlaubter Kinder), Einstellungen für den Betreiber (Formular auf dem Bildschirm „Plugins“), eigene Adressen wie `/vergleich/daten` (Twig-Vorlage, Antwort als html, json, text oder xml), Seitenvorlagen für den Editor und ein Designpaket mitbringen („Designpaket übernehmen“ ersetzt Palette, Schriften, Grundwerte und Buttons; die Inhalte bleiben). PHP-Code bringt ein Plugin nicht mit – deshalb gibt es keine Hooks und keine eigenen Tabellen (ADR 103). Mitgeliefert sind **Bildvergleich** (`plugins/vergleich`: Bildvergleich, Vergleichsgalerie, Vorlage, Adresse, Einstellungen) und das Designpaket **Nachtblau** (`plugins/nachtblau`). Wie man ein Plugin schreibt, steht in `docs/PLUGINS.md`. Migrationen `20260915000100` und `20260916000100` legen die Tabelle `plugin` mit Einstellungen an. + +## Formulare in Schritten, mit Bedingungen und Zusammenfassung (unveröffentlicht) + +Unter **Formulare** gibt es drei neue Feldtypen – **Zahl** (mit Unter-, Obergrenze und Einheit wie m²), **Auswahl (Optionsfelder)** und **Mehrfachauswahl** – und den Typ **Neuer Schritt**: seine Beschriftung ist der Titel des Abschnitts, sein Hilfetext die Einleitung, alle Felder bis zum nächsten Schritt gehören dazu. Jedes Feld und jeder Schritt kann von einer früheren Auswahl abhängen („Nur zeigen, wenn das Feld `immobilienart` einen dieser Werte hat: Wohnung, Haus“); der Server verlangt und speichert es dann nur, wenn die Bedingung zutrifft. Bei Optionsfeldern und Mehrfachauswahl lässt sich je Option ein **Bild aus der Mediathek** wählen; sobald eine Option eines hat, zeigt die Website die Auswahl als anklickbare Bildkarten statt als Kästchen. Unter Zustellung schaltet „Zusammenfassung vor dem Absenden zeigen“ einen letzten Schritt mit allen Angaben und „Ändern“ je Schritt ein. Auf der Website zeigt ein kleines Skript einen Schritt nach dem anderen mit Fortschritt, „Zurück“ und „Weiter“; ohne JavaScript steht das Formular als Ganzes mit Abschnitten auf der Seite. Das Skript kommt nur auf Seiten, deren Formular Schritte, Bedingungen oder die Zusammenfassung hat. Beispiel: der Preisfinder der DEEG-Website (`scripts/seed-immobilien-deeg.php`, Formular `bewertung`). + ## Eigene Absenderadresse je Formular (ab 1.15.1) Unter **Formulare → Zustellung** gibt es neben dem Empfänger ein Feld **Absender**. Bisher ging jede Nachricht von der Empfängeradresse an sich selbst; das scheitert, sobald der Empfänger ein Postfach bei einem anderen Anbieter ist, weil viele Hoster nur zustellen, was von einer Adresse der eigenen Domain kommt. Leer heißt weiterhin: Absender ist der Empfänger. Die Antwortadresse aus dem Formularfeld bleibt davon unberührt. @@ -347,6 +359,10 @@ Der Onepager „Tagespflege Jacqueline Moldovan“ liegt in `var/tagespflege-mol Der Nachbau von maler-drews.com (Malermeister Axel Drews, Gernsbach) liegt in `var/maler-drews.db` mit Medien in `var/maler-drews-media`; die Startkonfiguration `maler-drews` in `.claude/launch.json` nutzt Port 8093. `scripts/seed-maler-drews.php ` baut Gestaltung, Kopf- und Fußbereich, acht Seiten, 31 Beiträge in zwei Kategorien, Menüs, Formular, Einstellungen und Weiterleitungen auf; der Wortlaut der Beiträge, des Impressums und der Datenschutzerklärung steht in `scripts/seed-maler-drews-inhalte.php`. `sh scripts/reset-maler-drews.sh [Bilderordner]` (Standard `var/maler-drews-assets`, Zuordnung in `manifest.json`) wirft die Installation weg und baut sie neu auf; die Schriften Lato 400/700 und Cormorant Garamond 700 holt es nur, wenn die Cormorant-Dateien in `public/assets/fonts` fehlen, weil `cms:fonts:fetch` die geteilte `fonts.css` sonst nur für dieses Design neu schreibt (Kant-IT und Elektro Bernhard brauchen Lato 900). Für den Abruf liegt `var/cacert.pem` (curl.se) bereit, weil das PHP 8.4 auf diesem Rechner keine Stammzertifikate kennt. Die bewussten Abweichungen vom Original stehen im Kopf des Skripts. +### Fünfte lokale Referenzwebsite: DEEG Immobilien & Hausverwaltung + +Der Onepager „DEEG – Immobilien & Hausverwaltung in Freudenstadt“ (Deeg Hausverwaltung und Immobilien GmbH) liegt in `var/immobilien-deeg.db` mit Medien in `var/immobilien-deeg-media`; die Startkonfiguration `immobilien-deeg` in `.claude/launch.json` nutzt Port 8094, `immobilien-deeg-prod` denselben Bestand im Produktionsmodus auf Port 8095 zum Messen. `scripts/seed-immobilien-deeg.php ` baut Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404, Menü, Einstellungen und zwei Formulare auf – `kontakt` und `bewertung` (Bewertungsanfrage mit Auswahl Wohnung, Haus, Mehrfamilienhaus, Grundstück); `sh scripts/reset-immobilien-deeg.sh [Bilderordner]` (Standard `var/immobilien-deeg-assets`: Logo und zwei Architekturfotos aus dem gelieferten Paket) wirft die Installation weg und baut sie neu auf. Die Vorlage nutzt Arial, es gibt keine Schrift zu holen. Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts. + Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`. Die Livesuche prüft `npm run test:search` mit derselben isolierten Pilotkopie: Eingabe ohne Enter, langsame Antworten, Fehler/Wiederholen, Filter, Browserhistorie, Mediathek, Tastaturfokus, mobile Ansicht und axe. Ergebnisse stehen in `reports/live-search/`. Die allgemeine Verwaltungsprüfung ohne erneuten öffentlichen Bewegungstest startet `npm run test:admin -- --admin-only`. diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 08d4bdd..7481236 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -1,5 +1,8 @@ twig: default_path: '%kernel.project_dir%/templates' + # Die Vorlagen der Plugins (@plugins//...) registriert App\Twig\PluginExtension zur + # Laufzeit - nur wenn der Ordner plugins/ existiert; ein fester Pfad hier liesse Twig auf einer + # Installation ohne Plugins abbrechen. strict_variables: true globals: # Reading the decision needs the current request, so the templates ask the gate directly diff --git a/config/services.yaml b/config/services.yaml index a3b2b3c..20d8e5f 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -135,3 +135,11 @@ services: App\EventSubscriber\QueryHeaderSubscriber: arguments: $environment: '%kernel.environment%' + + App\Plugin\PluginRegistry: + arguments: + $projectDir: '%kernel.project_dir%' + + App\Twig\PluginExtension: + arguments: + $loader: '@twig.loader.native_filesystem' diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0419589..f8017d8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,6 +2,90 @@ Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen. +## ADR 104 – Die Rückstände der Gesamtprüfung abgearbeitet: Archiv als Seite, Bereiche vollständig, Palette bis in die Fehlerfarbe, Betrieb auf MySQL (16.09.2026) + +Umut: „Bitte umsetzen“ – die Liste aus ADR 100, die bis dahin nur dokumentiert war. + +**Archivseiten sind Seiten.** `/beitraege`, `/kategorie/…` und `/schlagwort/…` bauen ein Dokument aus Bausteinen (Kopfabschnitt mit Überschrift und Zahl, ein Abschnitt mit blätternder Beitragsliste) und geben es an `renderPage()` – dieselbe Aufbaukette wie jede Seite: Kopf und Fuß mit ihren Stilen, verknüpfte Sektionen, eigenes CSS, Schriften, Stylesheet-Bündel, Nach-oben-Knopf, Einwilligung, Skripte, Richtlinie. Die eigene Vorlage `archive.html.twig` ist weg. Ein Archiv hat den Typ `archive`; Brotkrumen führen „Beiträge → Kategorie“, und das steht als `BreadcrumbList` in den strukturierten Daten. + +**Blättern ist eine Folge von Seiten.** Hat eine Seite eine blätternde Beitragsliste, nennt die Canonical ab Seite 2 die Seitenzahl (`?seite=n`), und ``/`` verbinden die Folge. Vorher hielt eine Suchmaschine Seite 3 für ein Duplikat von Seite 1. + +**Bereiche sind vollständig.** Formulare, Beitragslisten und Brotkrumen in Kopf, Fuß und verknüpften Sektionen werden jetzt wie im Inhalt aufgelöst; die Meldungen eines abgewiesenen Formulars finden ihren Block über die Formularadresse statt über die Block-ID, wo auch immer er steht. Eigene Skripte eines Bereichs laufen als `/assets/bereich/.js` – vorher wurden sie gespeichert, geprüft und nie ausgeführt. + +**Mehrfach verknüpfte Sektionen ohne doppelte IDs.** `PartialRepository::expandRepeatedLinks()` zählt in Renderreihenfolge (Kopf, Inhalt, Fuß, verschachtelt bis zur Grenze) und gibt der zweiten und jeder weiteren Verknüpfung eine Kopie mit angehängten Kennungen (`id-2`) und ohne Sprungziele; ihre Stile werden mitkompiliert. Auch die Editorvorschau macht das. + +**Eine Grenze zwischen Tablet und Telefon: 767 px.** Design-Tokens, Website-Stile, Slider und Galerie sprangen bei 650, der Stilcompiler und die Sichtbarkeit bei 767 – zwischen 651 und 767 galt die Hälfte. Jetzt gilt überall 1024/767, wie es der Editor schon immer sagte. + +**Die Palette reicht bis in die Fehlerfarbe.** `color-scheme`, Skip-Link, Aufruf-Button, die Fehlerfarben der Formulare und der Text auf dunklen Abschnitten kommen aus Variablen; `Theme::css()` leitet aus der Leuchtdichte des Papiers ab, ob das Schema dunkel ist und welches Rot dort steht (`--k-scheme`, `--k-error`, `--k-error-bg`, `--k-error-line`). Buttons dunkeln beim Überfahren in die Tinte statt in Schwarz. Neu: Druckstile – Menü, Einwilligung, Nach-oben und Formularknöpfe bleiben weg, Verweise zeigen ihre Adresse. + +**Betrieb.** Sicherung und Wiederherstellung auf MySQL/MariaDB über `mysqldump --single-transaction --result-file` und `mysql --execute=source`; damit laufen Archiv-Update und Rückweg auch dort. Der Git-Updater sichert über `Backup` (VACUUM INTO bzw. Dump) statt mit einer rohen Dateikopie und spielt beim Rückweg dieselbe Sicherung ein. Eine Sperre (`var/update.lock`, flock) hält den zweiten Klick und den Archiv-Weg neben dem Git-Weg fern. Das Archiv wird direkt in eine Datei geladen (`Download::toFile()`), nicht mehr als Zeichenkette in den Speicher. `public/.htaccess` wird zusammengeführt: eingeschaltete HTTPS- und HSTS-Zeilen bleiben eingeschaltet. Nach einem gelungenen Update räumt `finish()` `var/update/` (bis auf die zwei jüngsten) und die Zwischenspeicher früherer Fassungen auf. Der MySQL-Job der CI prüft jetzt Gesundheit, Sicherung und die Tests. + +**Editor.** Die Vorschau zeigt eigenes CSS, Bereichs- und Sektionsstile und Formulare mit ihren Farben – „sieht in der Vorschau anders aus“ ist keine Ausrede mehr. Kontaktdaten, Formular, verknüpfte Sektion und externer Inhalt sind direkt nach dem Einfügen gültig: leer heißt „noch nichts gewählt“, der Editor sagt es, die Website zeigt nichts. Der Server gibt nach dem Speichern die bereinigte Fassung zurück, und der Editor übernimmt sie als gespeicherten Stand (`EditorHistory.adopt()`), statt sie erst nach dem Neuladen zu zeigen. + +**Kleines.** `legal.label` ist auf 60 Zeichen begrenzt, die verrutschten Docblocks in `PageController` stehen an ihren Methoden. + +**Folgen.** `tests/BacklogTest.php` hält alles fest. Nicht gebaut: eine Wiederherstellung auf MySQL ohne `mysql`-Client (dann liegt der Dump zum Einspielen von Hand bereit), Druckstile für Beiträge mit Bildkopf. + +## ADR 103 – Arbeitspaket H zu Ende: Container, Einstellungen, Adressen, Vorlagen und Designpakete für Plugins – und warum es keine PHP-Hooks gibt (16.09.2026) + +Umut: „Dann mach H.“ Der erste Teil (ADR 102) brachte Manifest, Schalter und geprüfte Bausteine. Offen waren Container, Einstellungen, eigene Routen, Tabellen, Hooks und Templates. + +**Grundentscheidung: ein Plugin bleibt Manifest plus Twig.** Es bringt keinen PHP-Code mit. Das ist kein Zufall, sondern die Antwort auf zwei Punkte des Entwicklungsplans zugleich: Ein Plugin ohne PHP kann nicht als „sandboxed“ bezeichnet werden müssen, weil es gar nichts ausführt, was der Kern nicht ohnehin ausführt (Twig mit `strict_variables`, ohne Zugriff auf Datenbank, Anfrage oder Dateisystem). Und ein Update des Kerns kann ein solches Plugin nicht brechen, solange die vier Übergaben stehen – `props`, `settings`, `children`, `block`. Damit fallen zwei Wünsche des Plans bewusst weg: **Hooks** in Speichern und Veröffentlichen und **eigene Tabellen**. Beides braucht Code, der im Kern läuft. Wer das braucht, baut einen Kernbaustein (ADR 049) oder wartet auf einen dritten Teil mit einer echten Erweiterungsschnittstelle in PHP – dann mit der Ehrlichkeit, dass er Installationscode ist. + +**Container.** `"container": true` am Baustein, optional `"children": [Typen]`. Der Kern rendert die Kinder selbst – mit demselben Kontextbündel wie jeden Kernbaustein – und reicht sie der Vorlage fertig als `children`. So kommt eine Plugin-Vorlage nie an Formulare, Beitragslisten oder die Navigation. Beim Schreiben prüft `DocumentValidator`, dass ein Nicht-Container keine Kinder trägt und ein Container nur die erlaubten; der Editor spiegelt die Regel in `canContain`, also im Werkzeugkasten und beim Ziehen. + +**Einstellungen.** `"settings": {Schlüssel: Schema}` mit denselben sieben Arten wie Eigenschaften. Der Bildschirm „Plugins“ zeigt für jedes aktive Plugin ein Formular daraus; gespeichert wird in der neuen Spalte `plugin.settings` (Migration `20260916000100`), geprüft gegen das Schema wie eine Eigenschaft – aus einem Formular kommen Zahlen und Schalter als Text, das wird umgewandelt. Vorlagen und Adressen bekommen die Werte als `settings`, fehlende Schlüssel mit ihrer Vorgabe. + +**Eigene Adressen.** `"routes": {name: {path, template, type}}`. Der Pfad hat die Form einer Seite (Slugs, bis zu vier Ebenen) und darf nichts belegen, was der Kern beantwortet; die Antwortart ist eine aus html, json, text, xml. `PageController::page()` fragt die Registry, wenn es zu einem Pfad keine veröffentlichte Seite gibt – eine Seite geht also immer vor, die Adresse kommt vor Weiterleitung und 404. Die Vorlage bekommt `settings`, die Abfrageparameter (nur saubere Namen, gekürzt) und die Route; die Antwort trägt dieselben Schutzkopfzeilen wie jede Seite, kein Skript, bei allem außer html `noindex`. Ausgeschaltet: 404. + +**Seitenvorlagen.** `"presets": {name: {label, blocks}}` – fertige Bausteine, die der Editor als Ganzes einfügt, hinter den eingebauten Vorlagen mit demselben Knopf. Jedes Einfügen vergibt frische Kennungen; geprüft wird beim Speichern wie jedes Dokument. Ein Plugin kann so seinen Baustein gleich im passenden Abschnitt anbieten. + +**Designpakete.** `"theme": {colors, fonts, base, buttons}` im Format des Designbereichs. „Designpaket übernehmen“ auf dem Bildschirm „Plugins“ ruft `Theme::save()` – dieselbe Prüfung wie beim Speichern unter Design, dieselben Fehler. Inhalte bleiben unangetastet; genau das ist die Abnahme des Plans („Templatewechsel erhält die Inhalte“), und der Test wechselt auf einer Seite mit Plugin-Baustein das Paket und vergleicht das Dokument davor und danach. Ein Plugin darf nur aus einem Designpaket bestehen (`plugins/nachtblau`); übernehmen geht nur eingeschaltet. + +**Referenz.** `plugins/vergleich` 1.1.0 hat jetzt Einstellungen (Standardbeschriftungen, Griff), einen Container „Vergleichsgalerie“ nur für Bildvergleiche, die Vorlage „Vorher-Nachher-Abschnitt“ und die Adresse `/vergleich/daten` (JSON mit den Einstellungen). `plugins/nachtblau` ist das Designpaket. + +**Folgen.** `docs/PLUGINS.md` beschreibt alles. Nicht gebaut und nicht geplant, solange Plugins ohne PHP bleiben: Hooks, Tabellen, Ressourcen in der Verwaltung, eigene Verwaltungsbildschirme. + +## ADR 102 – Das Pluginsystem, erste Fassung: Manifest, Schalter, geprüfte Bausteine (15.09.2026) + +Umut: „Plugin hätte ich gerne aber umgesetzt. Also Möglichkeit.“ Bis hierher gab es nur den Erhaltungsvertrag aus ADR 011: ein Baustein mit Namensraum wurde aufbewahrt, aber nie gerendert. Jetzt gibt es die Laufzeit dazu – Arbeitspaket H, erster Teil. + +**Ein Plugin ist ein Ordner.** `plugins//plugin.json` beschreibt Titel, Fassung, kleinste KantSites-Fassung und die Bausteine: je Baustein Beschriftung, Symbol, Twig-Vorlage, optional ein Stylesheet und ein Skript unter `assets/`, und ein Schema der Eigenschaften mit sieben Arten (Text, mehrzeilig, Zahl mit Grenzen, an/aus, Auswahl, Adresse, Bild aus der Mediathek). Der Typ des Bausteins ist `.` – dieselbe Form, die der Erhaltungsvertrag seit Schema v4 kennt. `PluginRegistry` liest die Manifeste bei jedem Aufruf (ein Dutzend Dateien, kein Cache nötig) und meldet ein kaputtes Manifest mit Grund statt es zu übergehen; ein solches Plugin lässt sich nicht einschalten. + +**Der Schalter liegt in der Datenbank.** Migration `20260915000100` legt `plugin` an (Name, aktiv, Fassung beim Einschalten, Zeitpunkt). Ob ein Plugin läuft, entscheidet die Installation, nicht der Code – und nicht eine Datei, die ein Update überschreiben könnte. Der Bildschirm „Plugins“ (Administrator) zeigt jeden Ordner mit seinen Bausteinen und schaltet ein und aus; ein ausgeschaltetes Plugin lässt seine Bausteine unangetastet in den Dokumenten (Erhaltungsvertrag wie bisher). + +**Der Kern prüft, die Vorlage vertraut.** Beim Schreiben eines Dokuments prüft `DocumentValidator` die Eigenschaften eines Plugin-Bausteins gegen das Schema – so streng wie bei einem Kernbaustein, aber nur, wenn das Plugin aktiv ist. Beim *Lesen* nicht (`validate(..., writing: true)` nur an den Schreibstellen): ein Plugin, das nach dem Speichern eingeschaltet oder geändert wurde, darf eine gespeicherte Seite nicht unlesbar machen. Dass unpassende Werte trotzdem nie in die Ausgabe gelangen, sichert der Renderer: `kant_plugin_props(block)` prüft beim Rendern erneut und liefert bei einem Fehler null – dann steht ein Kommentar statt des Bausteins. Die Vorlage bekommt genau vier Dinge: die geprüften `props`, `block` (für die `id`), `designClasses` und `editing`, dazu `kant_plugin_media()` für Bilder. Keine Datenbank, keine Einstellungen, keine Anfrage. Unbekannte Eigenschaften in einem gespeicherten Dokument werden ignoriert, damit ein Plugin eine Eigenschaft streichen kann. + +**Dateien nur dort, wo der Baustein steht.** `PageController` sammelt aus allen Dokumenten der Seite die Plugin-Bausteine und verlinkt ihre Stylesheets und Skripte (`/plugin-assets//assets/...?v=`); ein Skript schaltet die Richtlinie auf `script-src 'self'`, sonst bleibt sie bei `'none'`. `PluginAssetController` liefert nur Dateien aktiver Plugins unter `assets/` mit erlaubter Endung, mit Typ, `nosniff` und einem Jahr Haltbarkeit; Manifest und Vorlagen sind nicht erreichbar. Plugins liegen bewusst außerhalb von `public/`. In der Editorvorschau wird das Stylesheet geladen, das Skript nicht – wie bei eigenem Code. + +**Der Editor kennt die Bausteine aus dem Dokumentaufruf.** Der Editor-Endpunkt liefert mit dem Dokument die Definitionen der aktiven Plugins; `registerPlugins()` trägt Beschriftung, Symbol und Vorgaben ein und gibt ihnen die Gruppe „Erweiterungen“ im Werkzeugkasten. Das Eigenschaftenpanel entsteht aus dem Schema mit derselben Feldfabrik wie für Kernbausteine; ein Bild geht über die Mediathek. Ein Baustein, dessen Plugin fehlt, bleibt der bekannte Platzhalter. Design-Tokens (Abstände, Ausrichtung, Ecken) gelten jetzt auch für Plugin-Bausteine – die Klassen sind allgemein, die Prüfung lässt namensraumhaltige Typen durch. + +**Kein Sandkasten, und das steht so da.** Eine Plugin-Vorlage läuft mit den Rechten der Anwendung. Der Entwicklungsplan verlangt, das nicht anders zu nennen; der Bildschirm „Plugins“ sagt es dem Betreiber. + +**Referenzplugin „Bildvergleich“** (`plugins/vergleich`): zwei Bilder übereinander, ein Regler dazwischen – Beschriftungen, Startposition, Ecken als Eigenschaften, ein Stylesheet, ein Skript, das nur `--pos` über das CSSOM setzt (Inline-Stile bleiben durch die Richtlinie verboten). Ohne Skript steht der Vergleich bei der Startposition, das native Bereichsfeld bleibt bedienbar. + +**Updates.** Das Releasearchiv enthält `plugins/`; das Archiv-Update übernimmt den Ordner Datei für Datei wie `public/`, sodass mitgelieferte Plugins aktualisiert werden und ein selbst installiertes stehen bleibt. + +**Nicht in dieser Fassung:** Container-Bausteine, eigene Routen, Einstellungen, Tabellen, Hooks in Speichern und Veröffentlichen, Ressourcen in der Verwaltung, Templates (der zweite Teil von H). Anleitung für Entwickler: `docs/PLUGINS.md`. + +## ADR 101 – Formulare mit Schritten und Bedingungen: der Preisfinder ist ein Formular, kein Plugin (15.09.2026) + +Umut wollte für die DEEG-Website „kein Kontaktformular weiteres, sondern so ein ausgebautes Form“ – den Preisfinder der laufenden Website: erst die Objektart, dann Eckdaten je Objektart, dann Kontakt, zum Schluss eine Zusammenfassung. Und er fragte, ob das ein eigenes Plugin braucht. + +**Warum kein Plugin.** Ein Plugin ist in diesem CMS bis heute nur der Erhaltungsvertrag aus ADR 011: ein Baustein mit Namensraum wird gespeichert, im Editor als Platzhalter gezeigt und öffentlich als Kommentar ausgegeben. Registrierung, Laufzeit, Eigenschaftenpanel und Ressourcenladen sind Arbeitspaket H und nicht gebaut. Ein Preisfinder als Plugin hätte zuerst das ganze Pluginsystem gebraucht – und wäre dann eine Hülle um genau das, was der Formularbaukasten schon kann: Felder prüfen, speichern, verschicken, anzeigen. Wie bei der Beitragsseite (ADR 049) ist die Antwort ein Ausbau des Kerns. Ein Formular, das in Schritten fragt und Felder je nach Antwort zeigt, braucht jede Kundenwebsite. + +**Was dazukam.** Drei Feldtypen: Zahl (mit Unter-, Obergrenze und Einheit), Auswahl als Optionsfelder und Mehrfachauswahl. Ein Feld vom Typ „Neuer Schritt“ eröffnet einen Abschnitt: seine Beschriftung ist der Titel, sein Hilfetext die Einleitung, alle Felder bis zum nächsten Schritt gehören dazu. Jedes Feld und jeder Schritt kann eine Bedingung tragen (`showIf: {field, values}`): es gilt nur, wenn ein *früheres* Auswahlfeld einen der Werte hat. Und eine Einstellung „Zusammenfassung vor dem Absenden“. + +**Der Server entscheidet, nicht das Skript.** `FormRepository::check()` geht die Felder in Reihenfolge durch und merkt sich die gewählten Optionen; ein Feld hinter einer Bedingung, die nicht zutrifft, wird weder verlangt noch geprüft noch gespeichert – auch wenn der Browser einen Wert mitschickt. Ein Schritt hinter einer Bedingung nimmt seine Felder mit. Beim Speichern der Definition wird jede Bedingung geprüft: das Feld muss davor stehen, ein Auswahlfeld sein, und jeder Wert eine seiner Optionen – sonst könnte die Bedingung nie zutreffen, und ein Pflichtfeld dahinter wäre unerreichbar oder immer offen. Optionen dürfen kein Komma enthalten, weil die Verwaltung daran trennt und die Mehrfachauswahl damit gespeichert wird. + +**Ohne Skript vollständig.** Die Vorlage rendert Schritte als `fieldset` mit `legend`, Optionsfelder und Mehrfachauswahl als Gruppen, Zahlen als `type="number"` mit Grenzen; Bedingungen stehen als Datenattribute am Feld. Ohne JavaScript steht das ganze Formular auf der Seite, jeder Schritt ein Abschnitt, alles absendbar – der Server prüft ohnehin selbst. `form-steps.js` kommt nur auf Seiten, deren Formular Schritte, Bedingungen oder die Zusammenfassung hat (`FormRepository::needsScript()`), und die Richtlinie erlaubt es dann; es zeigt einen Schritt nach dem anderen mit Fortschritt, „Zurück“ und „Weiter“, prüft den aktuellen Schritt mit der Browserprüfung, deaktiviert Felder hinter nicht zutreffenden Bedingungen (deaktiviert, nicht nur versteckt – sie werden nicht gesendet und nicht geprüft) und baut als letzten Schritt die Zusammenfassung aus den geltenden Feldern mit „Ändern“ je Schritt. Nach einer Ablehnung durch den Server öffnet es den ersten Schritt mit einem Fehler. Eine Pflicht-Mehrfachauswahl trägt `required` nicht am Kästchen – das hieße „alle ankreuzen“ – sondern als `data-required` an der Gruppe; Skript und Server prüfen sie dort. + +**Bildauswahl.** Umut zeigte den Solar-Rechner von Elektro Bernhard: „Wieviele Personen leben im Haushalt“ als vier Bildkarten, die gewählte mit Rahmen. Optionsfelder und Mehrfachauswahl tragen dafür je Option ein Bild aus der Mediathek (`images: {Option: Kennung}`); sobald eine Option eines hat, rendert die Vorlage die Auswahl als Karten mit Bild und Text. Das native Kästchen bleibt in der Karte – sichtbar oben links, mit Fokusring auf der Karte –, damit Tastatur und Vorlesesoftware dieselbe Auswahl bedienen wie die Maus. Die Seite holt die kleinsten Fassungen aller Optionsbilder eines Formulars mit einer Abfrage (`PageController::optionImages()`); ein gelöschtes Bild lässt die Karte mit Text stehen. In der Verwaltung wählt man das Bild je Option aus einer Liste der Mediathek; ohne Skript ist es eine Zeile „Option = Kennung“. + +**Rückweg.** Bestehende Definitionen bleiben gültig: `present()` ergänzt fehlende Schlüssel (`min`, `max`, `unit`, `images`, `showIf`, `summary`), es gibt keine Migration. Ein Formular ohne Schritte und Bedingungen rendert wie zuvor und lädt kein Skript. Die Höchstzahl der Felder steigt von 25 auf 40; ein Preisfinder mit vier Objektarten braucht sie. + +**Folgen.** Die Editorvorschau zeigt Formulare weiterhin nicht (bekannter Rückstand). Der Preisfinder der DEEG-Website ist damit ein gewöhnliches Formular `bewertung` mit drei Schritten, 20 Feldern und Zusammenfassung, angelegt im Aufbauskript und in der Verwaltung ohne Skript bearbeitbar. Nicht gebaut: Bedingungen auf Zahlenbereiche oder mehrere Felder, Dateiuploads, ein Schritt-für-Schritt-Ablauf ohne JavaScript. + ## ADR 100 – Gesamtprüfung: Rollen wie versprochen, Installer bleibt zu, Einwilligung nur von hier (15.09.2026) **Anlass:** Umut wollte eine umfangreiche Prüfung – Fehler, Durchgängigkeit, Sauberkeit – und ausdrücklich die Frage, ob jemand ohne Administrator etwas verändern kann. Sechs getrennte Durchsichten (Backend, Betrieb, Editor, öffentliche Ausgabe, Dokumentation, Autorisierung jeder Route) lieferten rund sechzig Befunde; die mit Wirkung auf Sicherheit oder Bedienbarkeit sind umgesetzt, der Rest steht in STATUS als Rückstand. diff --git a/docs/AUSBAUPLAN.md b/docs/AUSBAUPLAN.md index 61c5407..5390a0a 100644 --- a/docs/AUSBAUPLAN.md +++ b/docs/AUSBAUPLAN.md @@ -86,6 +86,8 @@ Abnahme: Die Startseite des Piloten ist ohne Code nachbaubar. Keine neue öffent ### D – Formulare — **erledigt am 12.09.2026, ohne Dateiuploads** +**Ergänzt am 15.09.2026 (ADR 101):** Feldtypen Zahl, Optionsfelder und Mehrfachauswahl, Schritte, Bedingungen auf frühere Auswahlfelder und eine Zusammenfassung vor dem Absenden – für den Preisfinder der DEEG-Website, als Ausbau des Kerns statt als Plugin. Dateiuploads bleiben offen. + *Braucht: nichts; das größte eigenständige Paket.* Umgesetzt: Feldtypen Text, E-Mail, Telefon, Mehrzeiliges, Auswahl und Einwilligung, serverseitige Validierung mit Meldung je Feld, Einwilligungszeitpunkt, E-Mail-Versand mit Antwortadresse, gespeicherte Einsendungen in der Verwaltung und Spamschutz aus Honeypot, signiertem Zeitstempel und Rate-Limit. **Offen geblieben:** Dateiuploads (eigener nicht öffentlicher Ablageort und Typprüfung, Begründung in ADR 017), Löschen und Umbenennen von Formularen, Export und Aufbewahrungsfrist für Einsendungen. Der tatsächliche Versand ist ungeprüft und gehört zu Paket J. @@ -139,7 +141,7 @@ Umut hat den Umfang von G erweitert. Vier Punkte, in dieser Reihenfolge: Abnahme: Kopf- und Fußbereich sind im Editor gestaltbar und öffentlich sichtbar. Eine verknüpfte Sektion auf zwei Seiten ändert sich an beiden Stellen gleichzeitig. Ein Textelement lässt sich formatiert und als HTML bearbeiten, und keine Auszeichnung außerhalb der Positivliste übersteht das Speichern. Eigenes CSS wirkt, eigenes Skript wirkt nur für Administratoren gesetzt und nur auf der veröffentlichten Seite. Die Verwaltung ist ohne Anleitung bedienbar. -### H – Plugins und Templates +### H – Plugins und Templates — **erledigt am 16.09.2026** (ADR 102 und 103: Manifest, Schalter, geprüfte Bausteine, Dateien je Seite, Editoranbindung, Container, Einstellungen, eigene Adressen, Seitenvorlagen, Designpakete; Referenzplugins „Bildvergleich“ und „Nachtblau“. Bewusst nicht: PHP-Hooks und eigene Tabellen – Plugins bleiben Manifest plus Twig, ADR 103) *Braucht: den Erhaltungsvertrag (erledigt).* diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md new file mode 100644 index 0000000..443e5bf --- /dev/null +++ b/docs/PLUGINS.md @@ -0,0 +1,183 @@ +# Plugins schreiben + +Ein Plugin bringt eigene Bausteine, Vorlagen, Adressen und Designpakete in den Editor und auf die +Website. Es ist ein Ordner unter `plugins/` mit einer `plugin.json`, Twig-Vorlagen und optional +Stylesheets und Skripten. Eingeschaltet und eingestellt wird es in der Verwaltung unter **Plugins** +(Administrator). Hintergrund und Grenzen: ADR 102 und ADR 103 in `docs/ARCHITECTURE.md`. + +**Ein Plugin bringt keinen PHP-Code mit.** Alles ist Manifest plus Twig. Deshalb gibt es keine +Hooks in Speichern oder Veröffentlichen und keine eigenen Tabellen – und deshalb kann ein Update +des Kerns ein Plugin nicht brechen, solange die Übergaben unten stehen. + +## Der Ordner + +``` +plugins/ + vergleich/ ← Name des Plugins: Kleinbuchstaben und Ziffern, mit Buchstabe beginnend + plugin.json ← das Manifest + blocks/bildvergleich.html.twig + blocks/galerie.html.twig + routes/daten.json.twig + assets/bildvergleich.css ← nur unter assets/ liegt, was die Website ausliefert + assets/bildvergleich.js +``` + +Die mitgelieferten Plugins sind die Vorlagen zum Abschreiben: `plugins/vergleich` (Bausteine, +Container, Einstellungen, Adresse, Seitenvorlage) und `plugins/nachtblau` (nur ein Designpaket). + +## Das Manifest + +```json +{ + "name": "vergleich", + "title": "Bildvergleich", + "description": "Vorher und nachher in einem Bild.", + "version": "1.1.0", + "requires": "1.17.0", + "settings": { + "labelBefore": { "kind": "text", "label": "Standardbeschriftung vorher", "default": "Vorher" } + }, + "blocks": { + "bildvergleich": { + "label": "Bildvergleich", + "icon": "⇔", + "template": "blocks/bildvergleich.html.twig", + "css": "assets/bildvergleich.css", + "script": "assets/bildvergleich.js", + "props": { + "before": { "kind": "media", "label": "Bild vorher" }, + "position": { "kind": "number", "label": "Startposition (%)", "min": 5, "max": 95, "default": 50 } + } + }, + "galerie": { + "label": "Vergleichsgalerie", + "template": "blocks/galerie.html.twig", + "container": true, + "children": ["vergleich.bildvergleich"], + "props": { "columns": { "kind": "select", "label": "Spalten", "choices": { "2": "Zwei", "3": "Drei" } } } + } + }, + "presets": { + "abschnitt": { "label": "Vorher-Nachher-Abschnitt", "blocks": [ { "id": "s", "type": "section", "children": [ ] } ] } + }, + "routes": { + "daten": { "path": "/vergleich/daten", "template": "routes/daten.json.twig", "type": "json" } + }, + "theme": { "colors": [ ], "fonts": [ ], "base": { }, "buttons": { } } +} +``` + +- `name` muss dem Ordnernamen entsprechen. Der Typ eines Bausteins ist `name.baustein`, hier + `vergleich.bildvergleich`; so heißt er im Dokument und in der Ausgabe. +- `version` in der Form `1.2.3`. Sie hängt an den Adressen der Dateien (`?v=1.1.0`), damit der + Browser nach einem Update die neue Fassung holt. +- `requires` (optional): die kleinste KantSites-Fassung. Passt sie nicht, erscheint das Plugin in + der Verwaltung mit dem Grund und lässt sich nicht einschalten. +- Ein Manifest braucht mindestens einen Baustein, eine Adresse, eine Vorlage oder ein + Designpaket. Fehlt eine genannte Datei, ist das Manifest ungültig. + +### Eigenschaften und Einstellungen (Schema) + +Eigenschaften (`props` eines Bausteins) und Einstellungen (`settings` des Plugins) haben dasselbe +Schema: eine Art (`kind`), eine Beschriftung (`label`), optional `help` und `default`. + +| Art | Feld | Prüfung | Weitere Schlüssel | +|---|---|---|---| +| `text` | einzeilig | Text bis `maxLength` (Standard 200) | `maxLength` | +| `textarea` | mehrzeilig | Text bis `maxLength` (Standard 2000) | `maxLength` | +| `number` | Zahl | zwischen `min` und `max` | `min`, `max` | +| `boolean` | Kästchen | an oder aus | – | +| `select` | Liste | einer der Werte | `choices`: `{"wert": "Beschriftung"}` | +| `url` | einzeilig | lokaler Pfad, `#sprungmarke`, `https://`, `mailto:`, `tel:` | `maxLength` | +| `media` | Bild aus der Mediathek | Kennung eines Bildes oder leer | – | + +Höchstens 30 Eigenschaften je Baustein. Unbekannte Eigenschaften in einem gespeicherten Dokument +werden ignoriert (so kann ein Plugin eine Eigenschaft streichen), bekannte müssen passen, sonst +lässt sich die Seite nicht speichern. Einstellungen bearbeitet der Administrator auf dem Bildschirm +„Plugins“; sie werden genauso geprüft. + +### Container + +`"container": true` macht einen Baustein zum Behälter. `"children"` (optional) zählt auf, was +hinein darf – Kernbausteine wie `text` oder Bausteine des Plugins; ohne die Liste darf alles hinein. +Der Editor zeigt die Regel im Werkzeugkasten, der Server prüft sie beim Speichern. + +### Seitenvorlagen (`presets`) + +Fertige Bausteine, die der Editor als Ganzes einfügt – hinter den eingebauten Vorlagen mit demselben +Knopf. `blocks` ist eine Liste von Bausteinen wie in einem Dokument (höchstens 64 KiB); Kennungen +vergibt der Editor bei jedem Einfügen neu, du kannst also feste schreiben. + +### Eigene Adressen (`routes`) + +`path` hat die Form einer Seite (`/name` oder `/name/unter/seite`, Slugs) und darf nichts belegen, +was der Kern beantwortet (`/admin`, `/media`, `/assets`, `/formular`, `/beitraege` …). `type` ist +`html`, `json`, `text` oder `xml`. Eine veröffentlichte Seite mit derselben Adresse geht vor. +Ausgeschaltet antwortet die Adresse mit 404; alles außer `html` trägt `noindex`. + +### Designpaket (`theme`) + +`colors`, `fonts`, `base` und optional `buttons` im Format des Designbereichs (siehe die +Aufbauskripte unter `scripts/` oder `plugins/nachtblau`). „Designpaket übernehmen“ ersetzt die +Palette der Website – dieselbe Prüfung wie beim Speichern unter Design, die Inhalte bleiben. + +## Die Vorlagen + +### Bausteine + +Die Vorlage eines Bausteins bekommt genau sechs Dinge: + +- `props` – die geprüften Eigenschaften, jede vorhanden, fehlende mit ihrem Vorgabewert +- `settings` – die Einstellungen des Plugins, jede vorhanden +- `children` – bei einem Container die fertig gerenderten Kinder (leer sonst); ausgeben mit + `{{ children }}` +- `block` – der Baustein selbst; `block.id` gehört als `id` an das Wurzelelement, sonst greifen + eigenes CSS und die Editorauswahl nicht +- `designClasses` – Klassen aus Sichtbarkeit, Design-Tokens und Einblendungen, an die `class` + des Wurzelelements anhängen +- `editing` – `true` in der Editorvorschau, für Hinweise wie „bitte ein Bild wählen“ + +Dazu eine Funktion: `kant_plugin_media(kennung)` liefert ein Bild der Mediathek als +`{id, title, alt, variants: [{src, width, height}, …]}` (kleinste Fassung zuerst) oder `null`. + +```twig +{% set bild = props.before ? kant_plugin_media(props.before) : null %} +
+ {% if bild %}{{ props.alt ?: settings.labelBefore }}{% endif %} + {{ children }} +
+``` + +### Adressen + +Die Vorlage einer Adresse bekommt `settings`, `query` (die Abfrageparameter mit sauberen Namen, +gekürzt) und `route`. Für `json` und `xml` schreibt sie die Antwort selbst, etwa mit `json_encode`. + +Was keine Vorlage bekommt: Datenbank, Einstellungen der Website, Anfrage. Sie läuft mit den +Rechten der Anwendung – ein Sandkasten ist das nicht, und so wird es auch nicht genannt. + +## Stylesheet und Skript + +Beide werden nur auf Seiten geladen, auf denen der Baustein vorkommt, über +`/plugin-assets//assets/...`. Die Content-Security-Policy erlaubt Skripte vom eigenen +Ursprung, sobald die Seite eines braucht; Inline-Stile und Inline-Skripte bleiben verboten. Ein +Skript setzt Werte deshalb über das CSSOM (`element.style.setProperty(...)`), nie über ein +`style`-Attribut in der Vorlage. In der Editorvorschau wird das Stylesheet geladen, das Skript +nicht – die Vorlage muss auch ohne Skript ein brauchbares Bild abgeben. + +## Was der Kern übernimmt + +- Speichern, Veröffentlichen, Fassungen, Undo/Redo – der Baustein ist ein gewöhnlicher Block. +- Eigenes CSS je Block, Design-Tokens (Abstände, Ausrichtung, Ecken), Sichtbarkeit je Gerät, + Einblendungen – alles über `designClasses` und die `id`. +- Der Editor: Eintrag im Werkzeugkasten unter „Erweiterungen“, Eigenschaftenpanel aus dem + Schema, Container-Regeln, Vorlagen, Vorschau über den Server. +- Der Erhaltungsvertrag: ist das Plugin aus oder fehlt es, bleibt der Baustein im Dokument, der + Editor zeigt einen Platzhalter, die Website einen HTML-Kommentar. Nichts geht verloren. + +## Updates + +Mitgelieferte Plugins liegen im Releasearchiv und werden beim Archiv-Update Datei für Datei +aktualisiert. Ein selbst installiertes Plugin bleibt dabei stehen, weil sein Ordner im Archiv +nicht vorkommt. Beim Git-Update ist ein fremder Ordner unter `plugins/` nicht von Git verwaltet; +`git checkout` lässt ihn in Ruhe. Die Einstellungen liegen in der Datenbank und überleben beides. diff --git a/docs/QUALITY.md b/docs/QUALITY.md index 8de02fd..cf9e0f8 100644 --- a/docs/QUALITY.md +++ b/docs/QUALITY.md @@ -1,5 +1,9 @@ # Qualitätsbericht – erster ausführbarer Prototyp +## Ergänzung: DEEG Immobilien & Hausverwaltung gemessen, 15.09.2026 (1.17.0) + +Lighthouse **13.4.1** (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den lokalen Produktionsmodus der fünften Referenzwebsite „DEEG – Immobilien & Hausverwaltung in Freudenstadt“ (`var/immobilien-deeg.db`, Startkonfiguration `immobilien-deeg-prod`, Port 8095), drei Adressen: `/`, `/impressum`, `/datenschutz`. Ergebnis: **Leistung, Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100**; Startseite LCP 1,8 s mobil und 0,5 s Desktop (das Startfoto als `img` im Hero), CLS 0, TBT 0 ms. Ladebudget der Startseite 157 KB mit 6 Anfragen und 11 Datenbankabfragen. Berichte unter `reports/lighthouse-immobilien-deeg/`. Voraussetzung für die 100 bei Barrierefreiheit: Blau mit weißer Schrift als `#0076b6` statt Logo-Blau `#0081c7`, kleine blaue Schrift `#006aa3`, Copyright-Zeile mit eigener Farbe. Nach dem Umbau der Bewertungsanfrage zum dreistufigen Preisfinder (ADR 101, zweites Skript `form-steps.js` auf der Startseite) erneut gemessen: `/` mobil und Desktop weiterhin in allen fünf Kategorien 100, Ladebudget 161 KB mit 7 Anfragen. + ## Ergänzung: Ihr Malerservice Drews gemessen, 15.09.2026 (1.16.0 + zwei unveröffentlichte Korrekturen) Lighthouse **13.4.1** (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den lokalen Produktionsmodus der vierten Referenzwebsite „Ihr Malerservice Drews“ (`var/maler-drews.db`, Port 8092), sechs Adressen: `/`, `/aktuelles`, `/unsere-projekte`, `/kunstmalerei`, `/kontakt`, `/friedrichsbad-baden-baden`. Ergebnis: **Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100**, Leistung 98–100 – Startseite 99 mobil und 98 Desktop, Kunstmalerei 99/99, Projekte und Beitrag 99 mobil, alle übrigen 100. Der Rest zur 100 ist überall das Foto hinter dem Seitenkopf als CSS-Hintergrund (LCP mobil 1,6–2,1 s, Desktop 0,6–1,0 s; CLS ≤ 0,01, TBT ≤ 7 ms). Berichte unter `reports/lighthouse-maler-drews/`. diff --git a/docs/STATUS.md b/docs/STATUS.md index 5da4c7d..ca66711 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,85 @@ # Projektstatus +## Fassung 1.18.0 veröffentlicht (16.09.2026) + +**Anlass:** Umut: „Bau eine neue Version bitte, Commit, Push und Release.“ Der Stand aus DEEG-Nachbau, Formularschritten, Pluginsystem und den Rückständen der Gesamtpruefung geht als 1.18.0 heraus. + +**Vorher gefunden und behoben:** Der komplette `phpunit`-Lauf in einem Prozess starb reproduzierbar nach 80 Sekunden mit „Maximum execution time exceeded“, an wechselnden Stellen. Ursache war nicht ein Test, sondern `TaskWorkerSubscriber`: `set_time_limit()` **setzt** die Grenze, es verlängert sie nicht. Auf einem Prozess ohne Grenze – Konsole, Testlauf, Hoster mit `max_execution_time = 0` – machte `set_time_limit($budget + 60)` aus „unbegrenzt“ achtzig Sekunden, und der Prozess starb später irgendwo weit weg vom Aufgabenplan. Derselbe Fall stand im Takt der Verwaltung (`TaskController::tick()`, 120 Sekunden). Beide Stellen heben die Grenze jetzt nur an und senken sie nie. Einzeln liefen alle Testdateien immer durch, deshalb war das bisher nie aufgefallen. + +**Geprüft:** neuer Test in `tests/SchedulerTest.php` („der Worker kürzt einen Prozess ohne Zeitgrenze nicht“): faellige Aufgabe läuft, `max_execution_time` bleibt 0. `phpunit` 572 Tests, 4768 Zusicherungen in **einem** Prozess grün (vorher Abbruch), `npm test` 34, Twig-Lint mit `plugins/`, php-cs-fixer. Danach Commit, Tag `v1.18.0`, Push nach `origin` und Releasearchiv. + +## Rückstände der Gesamtprüfung abgearbeitet (16.09.2026) + +**Anlass:** Umut: „Bitte umsetzen“ zur Liste aus ADR 100. ADR 104 beschreibt jeden Punkt; hier die Kurzfassung. + +- **Archivseiten** laufen durch `renderPage()` (eigenes Dokument aus Kopfabschnitt und blätternder Beitragsliste): Kopf/Fuß mit Stilen, Sektionen, eigenes CSS, Bündel, Nach-oben, Einwilligung, Skripte. `templates/archive.html.twig` gelöscht. +- **Formulare, Beitragslisten, Brotkrumen in Bereichen und Sektionen** werden aufgelöst; Meldungen eines abgewiesenen Formulars finden den Block über die Formularadresse. Skripte in Bereichen laufen als `/assets/bereich/.js`. +- **Doppelte Block-IDs** bei mehrfach verknüpfter Sektion: `PartialRepository::expandRepeatedLinks()` erzeugt Kopien mit eigenen Kennungen (Website und Editorvorschau). +- **Breakpoints** überall 1024/767 (design.css neu gebaut, site.css, enhancements.css, preview.css, editor.css, Editortext). +- **Farben**: `color-scheme`, Skip-Link, Aufruf-Button, Fehlerfarben, `.tone-ink`-Text aus der Palette; `Theme::css()` setzt `--k-scheme` und die Fehlerfarben nach Leuchtdichte des Papiers; Formularbutton dunkelt in die Tinte. Druckstile neu. +- **Betrieb**: Sicherung/Wiederherstellung auf MySQL (mysqldump/mysql), Git-Updater sichert und rollt über `Backup`, Sperre `var/update.lock` für beide Wege, Archiv als Datei geladen, `.htaccess` zusammengeführt (HTTPS/HSTS bleiben an), `var/update/` und alte Caches nach dem Update aufgeräumt, CI-MySQL-Job mit `cms:health`, `cms:backup` und `phpunit`. +- **SEO**: Canonical mit `?seite=n` und `rel=prev/next` bei blätternden Beitragslisten; Druckstile. +- **Editor**: Vorschau mit eigenem CSS, Bereichs-/Sektionsstilen und Formularen; Kontaktdaten, Formular, verknüpfte Sektion und Embed direkt nach dem Einfügen gültig (leer = nichts gewählt); bereinigter Rich Text sofort im Editor (`EditorHistory.adopt()`). +- **Kleines**: `legal.label` begrenzt, Docblocks in `PageController` gerückt. + +**Geprüft:** neue `tests/BacklogTest.php` (7 Tests, 55 Zusicherungen: Archiv mit Fuß, Sektion, Nach-oben, Canonical/prev/next, Kategorie mit BreadcrumbList; blätternde Liste auf einer Seite; Formular im Fuß samt 422-Wiederanzeige; Sektion zweimal verknüpft; leere Bausteine; dunkle Palette; Sperre und .htaccess-Zusammenführung). Bestehende Tests angepasst: leerer Kontaktblock ist gültig, Schrittfolge des Updaters mit „Sperre“, `ReleaseOnDisk` kennt `toFile()`. Komplette Kette grün: `phpunit` 571 Tests (PHP 8.4), `npm test` 34, Twig-Lint mit `plugins/`, php-cs-fixer auf allen geänderten Dateien, `npm run test:editor` bestanden (Vorschau mit Formularen und eigenem CSS), DEEG `npm run test:browser` bestanden (Startseite 168 KB, 11 Anfragen – die vier Optionsbilder des Preisfinders kamen dazu), Lighthouse `/` Desktop überall 100, mobil Leistung 99 (die vier Piktogramme laden mit; Rest 100). Nicht committet. + +## Arbeitspaket H zu Ende: Container, Einstellungen, Adressen, Vorlagen, Designpakete (16.09.2026) + +**Anlass:** Umut: „Dann mach H.“ Zweiter Teil des Pluginsystems (ADR 103). Grundentscheidung: ein Plugin bleibt Manifest plus Twig, ohne PHP – deshalb keine Hooks und keine eigenen Tabellen, dafür alles, was sich ohne Installationscode sicher übergeben lässt. + +**Gebaut.** `PluginRegistry` liest jetzt auch `settings` (Schema wie Eigenschaften, gespeichert in der neuen Spalte `plugin.settings`, Migration `20260916000100`), `container`/`children` am Baustein, `routes` (Pfad wie eine Seite, Vorlage, Antwortart html/json/text/xml, Kernadressen gesperrt), `presets` (Bausteinlisten bis 64 KiB) und `theme` (Palette, Schriften, Grundwerte, Buttons). `block.html.twig` rendert die Kinder eines Containers selbst und reicht sie der Vorlage als `children`, dazu `settings`; `DocumentValidator` weist Kinder in Nicht-Containern und nicht erlaubte Kinder ab. `PageController::page()` beantwortet eine Plugin-Adresse, wenn keine Seite den Pfad hat (vor Weiterleitung und 404), mit Einstellungen und gesäuberten Abfrageparametern, Schutzkopfzeilen, ohne Skript, außer html mit `noindex`. Editor: `registerPlugins(list, presets)` kennt Container und erlaubte Kinder (`canContain`), `insertPreset('plugin:…')` fügt Vorlagen mit frischen Kennungen ein, der Werkzeugkasten zeigt sie hinter den eingebauten. Bildschirm „Plugins“: Einstellungsformular je aktivem Plugin, Adressen verlinkt, „Designpaket übernehmen“ (`Theme::save()`, Inhalte bleiben), beide Wege in der Rollenmatrix. Referenz: `plugins/vergleich` 1.1.0 (Einstellungen, Vergleichsgalerie als Container nur für Bildvergleiche, Vorlage „Vorher-Nachher-Abschnitt“, Adresse `/vergleich/daten`), neu `plugins/nachtblau` als reines Designpaket. Alle fünf lokalen Installationen migriert. `docs/PLUGINS.md` beschreibt das vollständige Manifest. + +**Geprüft:** `tests/PluginTest.php` jetzt 8 Tests: Einstellungen (Vorgaben, Umwandlung aus dem Formular, Abweisung, Wirkung in Vorlage und Adresse, 404 bei ausgeschaltetem Plugin), Container (Kinder gerendert, Stylesheet einmal, fremdes Kind und Kinder im Nicht-Container abgewiesen, Editor-Definitionen und Vorlagen), Designpaket (übernommen, Buttons, Dokument unverändert, nur eingeschaltet, Fehler ohne Paket). `editor-model.test.mjs` prüft registrierte Container, Vorgaben, `canContain` und Vorlagen mit frischen Kennungen (34 grün). `EditorSecurityTest` mit den neuen Routen grün. `npm run test:editor` um Einstellungen speichern, die Adresse `/vergleich/daten`, die Plugin-Vorlage im Werkzeugkasten und den Container erweitert (Bildvergleich landet in der Galerie, ein Text daneben) und bestanden; ein Lauf davor blieb beim Neuladen des Editors bei „Wird geladen“ hängen, der nächste lief ohne Änderung durch – der Check protokolliert jetzt jede Konsolenfehlermeldung, damit so etwas beim nächsten Mal einen Grund hat. Komplette Kette grün: `phpunit` 565 Tests (PHP 8.4), `npm test` 34, Twig-Lint mit `plugins/`, php-cs-fixer auf allen neuen und geänderten Dateien. Nicht committet. + +## Pluginsystem, erste Fassung – Manifest, Schalter, geprüfte Bausteine, Referenzplugin „Bildvergleich“ (15.09.2026) + +**Anlass:** Umut: „Plugin hätte ich gerne aber umgesetzt. Also Möglichkeit. … Dann Plugin so wie du es gesagt hast, hört sich gut an.“ Arbeitspaket H, erster Teil (ADR 102). + +**Gebaut.** `src/Plugin/PluginRegistry.php` liest `plugins//plugin.json` (Titel, Fassung, `requires`, Bausteine mit Beschriftung, Symbol, Vorlage, Stylesheet, Skript und Eigenschaftenschema mit sieben Arten), meldet kaputte Manifeste mit Grund und hält den Schalter in der neuen Tabelle `plugin` (Migration `20260915000100`, in `TestSchema`). `DocumentValidator` prüft die Eigenschaften eines Plugin-Bausteins beim Schreiben gegen das Schema (`validate(..., writing: true)` an allen Schreibstellen), beim Lesen nicht – ein später eingeschaltetes Plugin macht keine Seite unlesbar. `templates/blocks/block.html.twig` rendert Bausteine aktiver Plugins über `@plugins/...` mit den erneut geprüften `props` (`src/Twig/PluginExtension.php`: `kant_plugin_block`, `kant_plugin_props`, `kant_plugin_media`); passt etwas nicht, bleibt der Kommentar. `PluginAssetController` liefert nur Dateien aktiver Plugins unter `assets/` mit erlaubter Endung; `PageController` verlinkt sie je Seite und öffnet die Richtlinie für ein Skript nur dann. Design-Tokens gelten jetzt auch für Plugin-Typen. Editor: der Dokumentaufruf liefert die Definitionen (`plugins`), `registerPlugins()` in `editor-model.js` trägt sie ein (Gruppe „Erweiterungen“, Vorgaben, Schema v4), `renderPluginFields()` in `editor.js` baut das Panel aus dem Schema, ein Bild über die Mediathek; die Vorschau lädt das Stylesheet, kein Skript. Verwaltung: Bildschirm „Plugins“ (Administrator) mit Ein/Aus, CSRF, in Navigation und Rollenmatrix. Release/Update: `plugins/` im Archiv und beim Archiv-Update Datei für Datei übernommen. Entwicklerdoku `docs/PLUGINS.md`. + +**Referenzplugin `plugins/vergleich`:** Baustein `vergleich.bildvergleich` – zwei Bilder aus der Mediathek, ein Regler dazwischen (natives Bereichsfeld, Skript setzt nur `--pos` über das CSSOM), Beschriftungen, Startposition, Ecken. Ohne Skript steht der Vergleich bei der Startposition. + +**Geprüft:** `tests/PluginTest.php` (5 Tests, 50 Zusicherungen): Manifest lesen und schalten (Schalter überlebt eine neue Registry), drei kaputte Manifeste mit Grund und ohne Schalter, Eigenschaftenprüfung nur bei aktivem Plugin und nur beim Schreiben, Rendering mit Stylesheet/Skript/CSP gegen Kommentar bei ausgeschaltetem Plugin, Asset-Route (Typ, `immutable`, 404 für Manifest, Vorlagen, `..` und ausgeschaltete Plugins), Design-Tokens und eigenes CSS am Plugin-Baustein. `EditorSecurityTest` mit den Plugin-Routen grün (38 Tests), `npm test` 33 grün, Twig-Lint aller Vorlagen einschließlich `plugins/`. Die vier lokalen Installationen sind migriert. `npm run test:editor` um ein Plugin-Szenario erweitert und bestanden: Bildschirm „Plugins“ mit axe (die Überschriftenfolge dort war zuerst falsch, h3 unter h1), Einschalten, Baustein aus dem Werkzeugkasten, Panel aus dem Schema, zwei Bilder über die Mediathek, Vorschau mit Stylesheet und ohne Skript, Speichern und Veröffentlichen, Website mit Skript und `script-src 'self'`, Ausschalten lässt den Baustein im Dokument und zeigt den Kommentar (`reports/editor/plugins.png`, `plugin-block.png`). Danach die komplette Kette: `phpunit` zuerst mit drei Befunden – der feste Twig-Pfad `paths` in `twig.yaml` brach jede Installation ohne Ordner `plugins/` (Installer-Tests), und die Vollständigkeitsprüfung des Archiv-Updaters kannte `plugins/` nicht. Jetzt registriert `PluginExtension` den Namensraum `@plugins` zur Laufzeit nur, wenn der Ordner existiert, und der Test führt `plugins/` als Datei-für-Datei-Ordner wie `public/`. Danach `phpunit` vollständig grün (562 Tests, 4664 Zusicherungen, PHP 8.4), Twig-Lint mit `plugins/`, php-cs-fixer auf allen neuen und geänderten Dateien. + +**Nicht gebaut:** Container-Bausteine, eigene Routen und Einstellungen, Tabellen, Hooks, Ressourcen in der Verwaltung, Templates. Kein Sandkasten – Plugin-Vorlagen laufen mit den Rechten der Anwendung, so steht es auf dem Bildschirm. Nicht committet. + +## Formulare mit Schritten, Bedingungen und Zusammenfassung – der DEEG-Preisfinder (15.09.2026) + +**Anlass:** Umut: „Plugins funktionieren noch nicht? Was fehlt denn? Ich hätte gerne kein Kontaktformular weiteres, sondern so ein ausgebautes Form.“ Dann: „Okay, dann erweitere den Kern.“ Gemeint ist der Preisfinder der laufenden DEEG-Website: Objektart wählen, Eckdaten je Objektart, Kontakt, Zusammenfassung. + +**Kern (ADR 101):** Drei neue Feldtypen – Zahl (Unter-/Obergrenze, Einheit), Auswahl als Optionsfelder, Mehrfachauswahl. Ein Feld „Neuer Schritt“ eröffnet einen Abschnitt (Beschriftung = Titel, Hilfetext = Einleitung). Jedes Feld und jeder Schritt kann eine Bedingung tragen: gilt nur, wenn ein früheres Auswahlfeld einen der genannten Werte hat. Einstellung „Zusammenfassung vor dem Absenden“. Höchstens 40 statt 25 Felder. Der Server prüft die Bedingungen selbst (`FormRepository::check()`): ein Feld, das nicht galt, wird weder verlangt noch gespeichert, auch wenn der Browser einen Wert schickt; beim Speichern der Definition muss jede Bedingung auf ein früheres Auswahlfeld mit passenden Optionen zeigen. Bestehende Formulare bleiben ohne Migration gültig (`present()` ergänzt die Schlüssel). + +**Öffentlich:** `templates/blocks/form.html.twig` rendert Schritte als `fieldset`/`legend`, Gruppen für Options- und Mehrfachauswahl, `type="number"` mit Grenzen und Einheit, Bedingungen als `data-show-if`/`data-show-values`. Ohne JavaScript steht das Formular vollständig auf der Seite. Neu `public/assets/form-steps.js` (nur auf Seiten, deren Formular es braucht, `FormRepository::needsScript()`; die CSP erlaubt es dann): Fortschritt, „Zurück“/„Weiter“ mit Browserprüfung je Schritt, Felder hinter nicht zutreffenden Bedingungen deaktiviert statt nur versteckt, Zusammenfassung als letzter Schritt mit „Ändern“, nach einer Serverablehnung der erste Schritt mit Fehler. `site.css`: Stile für Schritte, Fortschritt, Gruppen, Zahl mit Einheit, Zusammenfassung; `.block-form [hidden]` schlägt jetzt `display:grid`/`inline-flex`. E-Mail: Schritte als Zwischenüberschrift, Felder, die nicht galten, bleiben weg. + +**Bildauswahl (Nachtrag):** Umut meinte mit „Upload“ eine Bildauswahl wie im Solar-Rechner von Elektro Bernhard (Bildkarten statt Kästchen). Optionsfelder und Mehrfachauswahl tragen jetzt je Option ein Bild aus der Mediathek; sobald eine Option eines hat, wird die Auswahl auf der Website zu anklickbaren Karten mit Bild und Text, das Kästchen bleibt darin für Tastatur und Vorlesesoftware. Im DEEG-Preisfinder hat die Objektart vier Piktogramme (`var/immobilien-deeg-assets/option-*.png`, mit GD gezeichnete Platzhalter, in der Mediathek gegen Fotos tauschbar). Test `testAChoiceWithPicturesBecomesClickableCards`, dazu zwei abgelehnte Zuordnungen. + +**Verwaltung:** Feldkarte mit Untergrenze/Obergrenze/Einheit (bei Zahl), Optionen bei Liste/Optionsfeldern/Mehrfachauswahl, „Bild je Option“ als Liste der Mediathek, Bedingung („Nur zeigen, wenn das Feld … einen dieser Werte hat“); Zustellung mit Schalter „Zusammenfassung vor dem Absenden zeigen“; Vorschau zeigt Schritte, Optionsfelder, Kästchen, Einheit und Bedingungen. Alles ohne Skript bedienbar. + +**DEEG:** Das Formular `bewertung` im Aufbauskript ist jetzt der Preisfinder: Schritt 1 Objektart (Wohnung, Haus, Mehrfamilienhaus, Grundstück) und Anlass (Verkaufen, Vermieten); Schritt 2 Eckdaten je Objektart – Wohnfläche, Grundstücksfläche, Zimmer, Wohneinheiten, Baujahr, Zustand, Einbauküche, Aufzug, Parkmöglichkeit, Bebaubarkeit, jeweils hinter Bedingungen; Schritt 3 Lage und Kontakt; dann die Zusammenfassung. Die Feldliste folgt dem Preisfinder von immobiliendeeg.de (Wohnung-Seite, 15.09.2026). Das Kontaktformular bleibt, wie es war. + +**Geprüft:** neue `tests/FormStepsTest.php` (5 Tests, 75 Zusicherungen): Normalisierung beim Speichern, zehn abgelehnte Definitionen mit Begründung, Bedingungen in `check()` (Grundstück ohne Wohnfläche, Wohnung mit Pflicht, Zahl mit Komma, Grenzen, fremde Optionen), Rendering samt Skript und CSP (und ein einfaches Formular weiter ohne Skript, `script-src 'none'`), POST mit Speicherung nur der geltenden Felder und 422-Wiederanzeige mit erhaltenen Kreuzen. Im Browser (Port 8094): drei Schritte durchgespielt, Felder je Objektart, Zusammenfassung mit allen Werten, Absenden mit Erfolgsmeldung, Einsendung in `form_submission` mit leeren Feldern für alles, was nicht galt; Grundstück zeigt nur zwei Eckdatenfelder, „Weiter“ hält bei leerem Pflichtfeld. Komplette Kette: `phpunit` 556 grün (PHP 8.4), `npm test` 33 grün, `npm run test:editor` vollständig bestanden (darunter die axe-Prüfung der neuen Formularverwaltung), Twig-Lint ohne Befund, php-cs-fixer auf den geänderten Dateien (die vier Befunde in `install.php` und den drei älteren Aufbauskripten sind vorbestehend). DEEG nach dem Umbau: `npm run test:browser` bestanden (Startseite jetzt 2 Skripte – Einblendungen und Formular –, 161 KB), Lighthouse `/` mobil und Desktop weiterhin überall 100. + +**Browsercheck angepasst:** `scripts/browser-check.mjs` erlaubte bisher höchstens ein Skript je Seite. Das war schon vor dem Formularskript zu eng – Slider, Reiter, Einblendungen, Bildbetrachter und Nach-oben sind je eine eigene Datei –, fiel aber erst jetzt auf, weil keine Referenzseite zwei davon lud. Neu: höchstens drei Skripte, alle aus `/assets/` des eigenen Ursprungs, und weiterhin keins, wenn die Richtlinie keins erlaubt. + +**Nicht gebaut:** Bedingungen auf Zahlenbereiche oder mehrere Felder, Dateiuploads, Schritt-für-Schritt ohne JavaScript, Formulare in der Editorvorschau. Nicht committet. + +## DEEG Immobilien & Hausverwaltung nachgebaut, Anfrage als Formular (15.09.2026) + +**Anlass:** Umut lieferte `Immobilien-DEEG-Website.zip` (Onepager für die Deeg Hausverwaltung und Immobilien GmbH, Freudenstadt) und wollte ihn „nach dem Prinzip wie die anderen Seiten mit Kontaktformular“ aufbauen; dazu die Frage, ob die Bewertungsanfrage ein eigenes Plugin braucht. + +**Antwort auf die Plugin-Frage:** Nein. Ein Plugin ist in diesem CMS ein Baustein mit Namensraum, den der Kern aufbewahrt, aber nicht rendert (ADR zum Erhaltungsvertrag, ADR 049); eine Laufzeit dafür gibt es erst mit Phase 4. Die Anfrage ist deshalb das Formular `bewertung` (Auswahl Wohnung/Haus/Mehrfamilienhaus/Grundstück – die vier Objektarten des Preisfinders der laufenden Website –, Straße und Ort, Name, E-Mail, Telefon, Hinweise, Einwilligung), der Kontakt das Formular `kontakt` (mit Auswahl Verkauf/Vermietung/Verwaltung). Beide verschicken serverseitig und liegen unter Formulare in der Verwaltung; die Vorlage verlinkte dafür noch auf www.immobiliendeeg.de. + +**Nachbau (`scripts/seed-immobilien-deeg.php`, `scripts/reset-immobilien-deeg.sh`).** Fünfte Installation `var/immobilien-deeg.db`, `var/immobilien-deeg-media`, Bildquellen `var/immobilien-deeg-assets` (Logo, zwei Architekturfotos aus dem Paket), Startkonfigurationen `immobilien-deeg` (Port 8094) und `immobilien-deeg-prod` (Port 8095, Produktionsmodus zum Messen). Startseite mit Hero (Foto, Schild, Bildzeile, blauer Aufruf zur Bewertung), Leistungsleiste, drei Leistungskarten mit Akkordeon, blauem Bewertungsabschnitt mit Formularkarte, Über uns mit „SEIT 1994“, Angebotsband (Link auf www.immobiliendeeg.de/immobilien/, die Immobilien-Datenbank ist nicht Teil des Pakets), Kontakt mit Telefon, E-Mail, Adresse, Bürozeiten und Formularkarte; Kopf mit heller Leiste, Logo, Menü und blauem Knopf „Immobilie bewerten“; Fuß mit Logo, Rechtszeile und Agenturlink. Impressum mit Register und Vertretung vom Impressum der laufenden Website (HRB 430747, Amtsgericht Stuttgart, Furkan Kurt); USt-ID, Aufsicht nach § 34c GewO und Bildlizenz des übernommenen Fotos als „NOCH ZU PRÜFEN“. Datenschutz als eigene Fassung ohne Cookies und Fremddienste. Schrift Arial wie die Vorlage, nichts zu holen. + +**Für 100 geändert, nicht für die Optik:** Blau als Fläche mit weißer Schrift trägt `#0076b6` (4,9:1 statt 4,2:1 beim Logo-Blau), kleine blaue Schrift auf Weiß `#006aa3`; das Logo-Blau `#0081c7` bleibt für Linien und große Überschriften. Der Button-Baustein hängt immer „↗“ an, die Vorlage kommt ausdrücklich ohne Pfeile aus – am Button `& span{display:none}`. Die Copyright-Zeile trägt ihre Farbe selbst, weil der Fuß kleinen Text sonst auf 3,8:1 aufhellt. + +**Browsercheck angepasst (`scripts/browser-check.mjs`):** Vor Überlauf- und axe-Prüfung spielt das Skript alle laufenden Einblendungen zu Ende (`document.getAnimations().forEach(a => a.finish())`). Vorher hing das Ergebnis vom Zeitpunkt ab: mitten in einer Einblendung rechnete axe den Kontrast gegen die halb durchsichtige Farbe (drei Scheinbefunde am Hero), und ein von rechts hereingleitender Block zählte als horizontaler Überlauf. + +**Geprüft:** `npm run test:links` gegen Port 8094: 3 Seiten, 14 Adressen, kein toter Verweis. `npm run test:browser` (`PHP_BINARY=C:/php84/php.exe`): sechs Seiten-/Viewport-Prüfungen mit axe, Tastatur, ohne JavaScript bestanden, keine redaktionellen Befunde; Ladebudget der Startseite 157 KB, 6 Anfragen, 11 Abfragen. Lighthouse 13.4.1 (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den Produktionsmodus auf Port 8095 für `/`, `/impressum`, `/datenschutz`: **Leistung, Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100** (Startseite LCP 1,8 s mobil / 0,5 s Desktop, CLS 0, TBT 0 ms); Berichte in `reports/lighthouse-immobilien-deeg/`. Kontaktformular einmal abgeschickt: Erfolgsmeldung, Eingang in `form_submission`. Screenshots in 1440 und 390 px gegen die Vorlage verglichen. php-cs-fixer auf dem Skript ohne Befund. + +**Nicht geprüft:** der Editor auf den DEEG-Dokumenten im Browser, E-Mail-Zustellung (lokal `sendmail://default`, der Eingang bleibt trotzdem gespeichert), Firefox/Safari, ein echtes Touchgerät, das Hosting. Nicht committet. + ## 1.17.0 veröffentlicht (15.09.2026) Tag `v1.17.0` (Commit `d07154e`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.17.0 – Adresse mit oder ohne www aus `SITE_ORIGIN`, Rollen wie die Rollenhilfe sie verspricht, Befunde der Gesamtprüfung (ADR 099, ADR 100). diff --git a/docs/UPDATE.md b/docs/UPDATE.md index 360016b..c60162b 100644 --- a/docs/UPDATE.md +++ b/docs/UPDATE.md @@ -133,3 +133,7 @@ Der Archiv-Updater erhält die Konfiguration auch bei diesem Übergang. Der erst In alten Git-Installationen war `.env` noch versioniert. Beim erstmaligen Wechsel kann ein alter Git-Updater diese neue Schutzlogik noch nicht besitzen. Nutze dafür den Archiv-Updater. Bei einem manuellen Git-Checkout zuerst `.env` und gegebenenfalls `.env.local` außerhalb des Checkout-Verzeichnisses sichern, beide nach dem Checkout wiederherstellen und erst danach Setup/Migration/Cache starten. Ab 1.7.0 erhält der Git-Updater die Dateien selbst vor dem ersten neuen PHP-Prozess. `.env` nicht erneut ins Repository aufnehmen. Bei FTP-Updates die `.env` aus dem neuen Archiv überspringen. Die Vorlage im Archiv ist ausschließlich für Erstinstallationen bestimmt. Ein Rückweg benötigt den alten Code plus die beiden gesicherten Konfigurationsdateien. + +## MySQL und MariaDB (seit 16.09.2026, ADR 104) + +Sicherung und Wiederherstellung brauchen auf einer MySQL-Installation die Clients `mysqldump` und `mysql` im Pfad des PHP-Prozesses (auf geteiltem Hosting beim Anbieter erfragen; ein voller Pfad lässt sich nicht eintragen, aber `mysqldump.phar`/`mysql.phar` im Projektstamm würden gefunden). Die Sicherung schreibt `datenbank.sql` mit `--single-transaction`; die Wiederherstellung spielt sie mit `mysql --execute=source` ein. Fehlt der Client zum Einspielen, sagt die Wiederherstellung das und nennt den Pfad des Dumps. Der Archiv-Updater und der Git-Updater sichern vorher über denselben Weg und spielen beim Rückweg dieselbe Sicherung ein. Beide Wege sperren sich gegenseitig über `var/update.lock`; ein zweiter Aufruf bekommt die Meldung „Ein anderes Update läuft gerade“. diff --git a/migrations/Version20260915000100.php b/migrations/Version20260915000100.php new file mode 100644 index 0000000..121630c --- /dev/null +++ b/migrations/Version20260915000100.php @@ -0,0 +1,39 @@ +createTable('plugin'); + $plugin->addColumn('name', 'string', ['length' => 40]); + $plugin->addColumn('active', 'boolean', ['default' => false]); + $plugin->addColumn('activated_at', 'datetime_immutable', ['notnull' => false]); + // Die Fassung, mit der es zuletzt eingeschaltet wurde - damit die Verwaltung sagen kann, + // dass ein Plugin seit dem Einschalten aktualisiert wurde. + $plugin->addColumn('version', 'string', ['length' => 32, 'default' => '']); + $plugin->setPrimaryKey(['name']); + } + + public function down(Schema $schema): void + { + $schema->dropTable('plugin'); + } +} diff --git a/migrations/Version20260916000100.php b/migrations/Version20260916000100.php new file mode 100644 index 0000000..3ed741d --- /dev/null +++ b/migrations/Version20260916000100.php @@ -0,0 +1,32 @@ +getTable('plugin')->addColumn('settings', 'text', ['default' => '{}']); + } + + public function down(Schema $schema): void + { + $schema->getTable('plugin')->dropColumn('settings'); + } +} diff --git a/plugins/vergleich/assets/bildvergleich.css b/plugins/vergleich/assets/bildvergleich.css new file mode 100644 index 0000000..f2f6472 --- /dev/null +++ b/plugins/vergleich/assets/bildvergleich.css @@ -0,0 +1,23 @@ +/* Bildvergleich. Die Position kommt als --pos (Prozent) vom Skript; ohne Skript gilt die Mitte. */ +.block-bildvergleich{--pos:50%;margin:0} +.bildvergleich-buehne{position:relative;margin:0;overflow:hidden;user-select:none;-webkit-user-select:none} +.bildvergleich-rund .bildvergleich-buehne{border-radius:12px} +.bildvergleich-buehne img{display:block;width:100%;height:auto;border-radius:0} +.bildvergleich-vorher{position:absolute;inset:0;height:100%;object-fit:cover;clip-path:inset(0 calc(100% - var(--pos)) 0 0)} +.bildvergleich-linie{position:absolute;top:0;bottom:0;left:var(--pos);width:2px;margin-left:-1px;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.25);pointer-events:none} +.bildvergleich-linie::after{content:'⇔';position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:44px;height:44px;border-radius:50%;background:#fff;color:#182d2b;display:grid;place-items:center;font-size:20px;box-shadow:0 4px 14px rgba(0,0,0,.3)} +.bildvergleich-etikett{position:absolute;top:14px;padding:6px 10px;border-radius:6px;background:rgba(0,0,0,.55);color:#fff;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;pointer-events:none} +.bildvergleich-etikett-vorher{left:14px} +.bildvergleich-etikett-nachher{right:14px} +.bildvergleich-regler{position:absolute;inset:0;display:block;cursor:ew-resize} +.bildvergleich-regler input{position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:ew-resize} +.bildvergleich-regler input:focus-visible{opacity:1;appearance:none;-webkit-appearance:none;background:transparent;outline:3px solid #fff;outline-offset:-6px} +.block-bildvergleich .block-unavailable{margin:0} +/* Griff als Punkt statt Pfeile (Plugin-Einstellung). */ +.bildvergleich-griff-punkt .bildvergleich-linie::after{content:'';width:22px;height:22px} +/* Die Vergleichsgalerie: ein Raster aus Bildvergleichen. */ +.block-vergleichsgalerie{margin:0} +.vergleichsgalerie-titel{margin:0 0 18px} +.vergleichsgalerie-raster{display:grid;gap:20px;grid-template-columns:repeat(2,minmax(0,1fr))} +.vergleichsgalerie-3 .vergleichsgalerie-raster{grid-template-columns:repeat(3,minmax(0,1fr))} +@media(max-width:767px){.vergleichsgalerie-raster,.vergleichsgalerie-3 .vergleichsgalerie-raster{grid-template-columns:1fr}} diff --git a/plugins/vergleich/assets/bildvergleich.js b/plugins/vergleich/assets/bildvergleich.js new file mode 100644 index 0000000..6876973 --- /dev/null +++ b/plugins/vergleich/assets/bildvergleich.js @@ -0,0 +1,14 @@ +/** + * Bildvergleich: der Regler setzt --pos auf der Bühne. Ohne dieses Skript steht der Vergleich + * bei der gespeicherten Startposition; Tastatur und Vorlesesoftware bedienen das native + * Bereichsfeld in beiden Fällen. + */ +(() => { + for (const block of document.querySelectorAll('[data-bildvergleich]')) { + const range = block.querySelector('input[type="range"]'); + if (!range) continue; + const apply = () => block.style.setProperty('--pos', `${range.value}%`); + range.addEventListener('input', apply); + apply(); + } +})(); diff --git a/plugins/vergleich/blocks/bildvergleich.html.twig b/plugins/vergleich/blocks/bildvergleich.html.twig new file mode 100644 index 0000000..034d51a --- /dev/null +++ b/plugins/vergleich/blocks/bildvergleich.html.twig @@ -0,0 +1,24 @@ +{# Bildvergleich: das Nachher-Bild liegt unten, das Vorher-Bild darüber und wird per clip-path + auf den linken Teil beschnitten. Der Regler ist ein natives Bereichsfeld – ohne Skript bleibt + er stehen, beide Bilder sind trotzdem zu sehen. Die Vorlage bekommt nur, was der Kern geprüft + hat: `props` nach dem Schema, `settings` des Plugins, `block.id`, `designClasses`. #} +{% set vorher = props.before ? kant_plugin_media(props.before) : null %} +{% set nachher = props.after ? kant_plugin_media(props.after) : null %} +{% set labelVorher = props.labelBefore ?: settings.labelBefore|default('Vorher') %} +{% set labelNachher = props.labelAfter ?: settings.labelAfter|default('Nachher') %} +
+{% if vorher and nachher %} + {% set gross = nachher.variants|last %} +
+ {{ props.alt ?: nachher.alt }} + {% set grossVorher = vorher.variants|last %} + + + + + +
+{% else %} +

{{ editing ? 'Bildvergleich: bitte unter Inhalt ein Bild für vorher und eines für nachher wählen.' : '' }}

+{% endif %} +
diff --git a/plugins/vergleich/blocks/galerie.html.twig b/plugins/vergleich/blocks/galerie.html.twig new file mode 100644 index 0000000..d8d021e --- /dev/null +++ b/plugins/vergleich/blocks/galerie.html.twig @@ -0,0 +1,10 @@ +{# Die Vergleichsgalerie: ein Container. Seine Kinder kommen fertig gerendert als `children` + herein - der Kern hat sie mit demselben Kontext gerendert wie jeden anderen Baustein. #} +
+ {% if props.title %}

{{ props.title }}

{% endif %} + {% if children|trim %} +
{{ children }}
+ {% elseif editing %} +

Vergleichsgalerie: Bildvergleiche hier hineinziehen.

+ {% endif %} +
diff --git a/plugins/vergleich/plugin.json b/plugins/vergleich/plugin.json new file mode 100644 index 0000000..3954e63 --- /dev/null +++ b/plugins/vergleich/plugin.json @@ -0,0 +1,63 @@ +{ + "name": "vergleich", + "title": "Bildvergleich", + "description": "Vorher und nachher in einem Bild: ein Regler schiebt zwischen zwei Aufnahmen – für Renovierungen, Malerarbeiten, Sanierungen.", + "version": "1.1.0", + "requires": "1.17.0", + "settings": { + "labelBefore": { "kind": "text", "label": "Standardbeschriftung vorher", "default": "Vorher", "maxLength": 40 }, + "labelAfter": { "kind": "text", "label": "Standardbeschriftung nachher", "default": "Nachher", "maxLength": 40 }, + "handle": { "kind": "select", "label": "Griff des Reglers", "choices": { "pfeile": "Pfeile", "punkt": "Punkt" }, "default": "pfeile" } + }, + "blocks": { + "bildvergleich": { + "label": "Bildvergleich", + "description": "Zwei Bilder übereinander, ein Regler dazwischen.", + "icon": "⇔", + "template": "blocks/bildvergleich.html.twig", + "css": "assets/bildvergleich.css", + "script": "assets/bildvergleich.js", + "props": { + "before": { "kind": "media", "label": "Bild vorher" }, + "after": { "kind": "media", "label": "Bild nachher" }, + "labelBefore": { "kind": "text", "label": "Beschriftung vorher", "help": "Leer: die Standardbeschriftung aus den Plugin-Einstellungen.", "maxLength": 40 }, + "labelAfter": { "kind": "text", "label": "Beschriftung nachher", "maxLength": 40 }, + "alt": { "kind": "text", "label": "Was die Bilder zeigen", "help": "Ein Satz für Vorlesesoftware, zum Beispiel „Fassade vor und nach dem Anstrich“.", "maxLength": 200 }, + "position": { "kind": "number", "label": "Startposition des Reglers (%)", "min": 5, "max": 95, "default": 50 }, + "rounded": { "kind": "boolean", "label": "Abgerundete Ecken", "default": true } + } + }, + "galerie": { + "label": "Vergleichsgalerie", + "description": "Mehrere Bildvergleiche nebeneinander im Raster.", + "icon": "⊞", + "template": "blocks/galerie.html.twig", + "css": "assets/bildvergleich.css", + "container": true, + "children": ["vergleich.bildvergleich"], + "props": { + "columns": { "kind": "select", "label": "Spalten am Schreibtisch", "choices": { "2": "Zwei", "3": "Drei" }, "default": "2" }, + "title": { "kind": "text", "label": "Überschrift der Galerie", "maxLength": 120 } + } + } + }, + "presets": { + "abschnitt": { + "label": "Vorher-Nachher-Abschnitt", + "description": "Eine Sektion mit Überschrift, kurzem Text und einem Bildvergleich.", + "blocks": [ + { + "id": "vergleich-abschnitt", "type": "section", "layout": { "tone": "paper", "width": "normal" }, + "children": [ + { "id": "vergleich-titel", "type": "heading", "props": { "text": "Vorher und nachher", "level": 2 } }, + { "id": "vergleich-text", "type": "text", "props": { "text": "Ziehen Sie den Regler und sehen Sie den Unterschied." } }, + { "id": "vergleich-bild", "type": "vergleich.bildvergleich", "props": { "before": "", "after": "", "labelBefore": "", "labelAfter": "", "alt": "", "position": 50, "rounded": true } } + ] + } + ] + } + }, + "routes": { + "daten": { "path": "/vergleich/daten", "template": "routes/daten.json.twig", "type": "json" } + } +} diff --git a/plugins/vergleich/routes/daten.json.twig b/plugins/vergleich/routes/daten.json.twig new file mode 100644 index 0000000..768f209 --- /dev/null +++ b/plugins/vergleich/routes/daten.json.twig @@ -0,0 +1,3 @@ +{# Eine eigene Adresse des Plugins: die Einstellungen als JSON - fuer eine App oder ein Skript, + das die Beschriftungen kennen will. Die Vorlage bekommt `settings`, `query` und `route`. #} +{{ {plugin: 'vergleich', labels: {before: settings.labelBefore, after: settings.labelAfter}, handle: settings.handle, echo: query}|json_encode(constant('JSON_UNESCAPED_UNICODE') b-or constant('JSON_UNESCAPED_SLASHES'))|raw }} diff --git a/public/assets/admin.css b/public/assets/admin.css index 7aeb17d..a1e63a9 100644 --- a/public/assets/admin.css +++ b/public/assets/admin.css @@ -864,6 +864,18 @@ details.card > summary:hover { background: #fafbf8 } .form-preview-check { display: flex; align-items: flex-start; gap: 9px } .form-preview-box { flex: none; width: 18px; height: 18px; margin-top: 2px; border: 1px solid var(--form-border, currentColor); border-radius: 4px; background: var(--form-bg, #fff) } .form-preview-help { font-size: 12px } +.form-preview-step { padding-top: 6px; border-top: 1px solid var(--line); display: grid; gap: 2px } +.form-preview-step-title { font-weight: 700; font-size: 15px } +.form-preview-radio { border-radius: 50% } +.form-preview-number { display: flex; align-items: center; justify-content: flex-end; opacity: .7 } +.form-preview-conditional { padding-left: 10px; border-left: 2px dashed var(--line) } +.field-card-number { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 12px } +.field-card-condition { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 16px } +.field-card-condition .field-help { grid-column: 1 / -1 } +.field-card-images { grid-column: 1 / -1 } +.field-image-pickers { display: grid; gap: 6px; margin-top: 6px } +.field-image-picker { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); gap: 10px; align-items: center; font-size: 13px } +.field-image-picker select { margin: 0 } .form-preview-empty { margin: 0; font-size: 13px } .form-preview + .contrast-note { margin-top: 10px } .form-preview .cms-button { justify-self: start; pointer-events: none } diff --git a/public/assets/design.css b/public/assets/design.css index 5afd7f4..a1323bc 100644 --- a/public/assets/design.css +++ b/public/assets/design.css @@ -89,7 +89,7 @@ .kant .d-tablet-radius-small{border-radius:8px} .kant .d-tablet-radius-large{border-radius:24px} } -@media(max-width:650px){ +@media(max-width:767px){ .kant .d-mobile-align-left{text-align:left;margin-inline:0} .kant .d-mobile-align-center{text-align:center;margin-inline:auto} .kant .d-mobile-align-right{text-align:right;margin-inline:auto 0} diff --git a/public/assets/editor-model.js b/public/assets/editor-model.js index 75ce127..d52cacf 100644 --- a/public/assets/editor-model.js +++ b/public/assets/editor-model.js @@ -30,10 +30,35 @@ export function whyNot(type) { } export const PLUGIN_TYPE = /^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/; +/** + * Die Bausteine der aktiven Plugins, vom Server mit dem Dokument geliefert. Sie bekommen + * Beschriftung, Symbol, Vorgaben und eine eigene Gruppe im Werkzeugkasten; was das Dokument + * enthält, aber hier nicht steht, bleibt ein Baustein einer fehlenden Erweiterung. + */ +export const PLUGIN_BLOCKS = {}; +/** Die Seitenvorlagen der aktiven Plugins: id => {label, blocks}. */ +export const PLUGIN_PRESETS = {}; +export function registerPlugins(list, presets = []) { + for (const key of Object.keys(PLUGIN_BLOCKS)) { delete PLUGIN_BLOCKS[key]; delete labels[key]; delete icons[key]; } + for (const key of Object.keys(PLUGIN_PRESETS)) delete PLUGIN_PRESETS[key]; + for (const preset of presets || []) if (typeof preset?.id === 'string' && Array.isArray(preset.blocks)) PLUGIN_PRESETS[preset.id] = preset; + const types = []; + for (const definition of list || []) { + if (!PLUGIN_TYPE.test(definition?.type || '')) continue; + PLUGIN_BLOCKS[definition.type] = definition; + labels[definition.type] = definition.label; + icons[definition.type] = definition.icon || '⬡'; + types.push(definition.type); + } + const group = CATEGORIES.find(category => category.name === 'Erweiterungen'); + if (group) group.types = types; + else if (types.length) CATEGORIES.push({ name: 'Erweiterungen', hint: 'Bausteine der eingeschalteten Plugins.', types }); +} +export const pluginDefinition = block => PLUGIN_BLOCKS[block?.type] ?? null; export const isPluginBlock = block => typeof block?.type === 'string' && PLUGIN_TYPE.test(block.type); export const blockLabel = block => labels[block?.type] ?? (isPluginBlock(block) ? `Baustein ${block.type}` : 'Unbekannt'); export const blockIcon = block => icons[block?.type] ?? '❔'; -export const isContainer = block => ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'].includes(block?.type); +export const isContainer = block => ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'].includes(block?.type) || PLUGIN_BLOCKS[block?.type]?.container === true; // Nothing may be dropped into a plugin block: the core does not know its layout contract. // Containers that accept only one kind of child, mirrored by the server side validator. // --------------------------------------------------------------------------------------------- @@ -68,8 +93,10 @@ export const REQUIRE = { column: ['row', 'section'], accordionitem: ['accordion' */ export const ROOT_NEEDS_CONTAINER = ['row']; -export const canContain = (parent, block) => isContainer(parent) && !isPluginBlock(parent) - && (ALLOW[parent.type] === undefined || ALLOW[parent.type].includes(block?.type)) +// Ein Plugin-Container nimmt auf, was sein Manifest erlaubt; ein Plugin ohne Definition nichts. +const allowedIn = parent => PLUGIN_BLOCKS[parent?.type] ? (PLUGIN_BLOCKS[parent.type].children ?? undefined) : ALLOW[parent?.type]; +export const canContain = (parent, block) => isContainer(parent) && (!isPluginBlock(parent) || PLUGIN_BLOCKS[parent.type]?.container === true) + && (allowedIn(parent) === undefined || allowedIn(parent).includes(block?.type)) && (block?.type === undefined || REQUIRE[block.type] === undefined || REQUIRE[block.type].includes(parent.type)); export const clone = value => structuredClone(value); @@ -86,6 +113,11 @@ export function locate(blocks, id, parent = null) { export function createBlock(type) { if (!Object.hasOwn(labels, type)) throw new Error('Unbekannter Block.'); const block = { id: `b_${crypto.randomUUID().replaceAll('-', '')}`, type }; + if (PLUGIN_BLOCKS[type]) { + block.props = Object.fromEntries(Object.entries(PLUGIN_BLOCKS[type].props || {}).map(([key, schema]) => [key, schema.default ?? ''])); + if (PLUGIN_BLOCKS[type].container) block.children = []; + return block; + } if (isContainer(block)) block.children = []; if (type === 'accordion') block.children = [createBlock('accordionitem')]; // A single tab is not a tab group, and the script needs at least two to enhance anything. @@ -121,6 +153,7 @@ export function createBlock(type) { /** Raises the document schema to the version that knows this block type. */ export function upgradeForType(document, type) { if (type === 'gallery') upgradeDocument(document, 3); + if (PLUGIN_BLOCKS[type]) upgradeDocument(document, 4); // Die Fassung, die der Server fuer die Eigenschaften verlangt, mit denen createBlock den // Baustein anlegt - nicht die, in der der Typ zuerst auftauchte. Sonst steht ein neuer // Baustein sofort mit 422 in der Vorschau und laesst sich nicht speichern. @@ -436,8 +469,19 @@ export function insertRow(document, columns, selected) { return place(document, buildRow(columns), target.parentId, target.index); } +/** Frische Kennungen für eine Vorlage aus einem Plugin - jedes Einfügen ist ein eigener Baustein. */ +const freshIds = blocks => blocks.map(block => ({ ...structuredClone(block), id: `b_${crypto.randomUUID().replaceAll('-', '')}`, ...(Array.isArray(block.children) ? { children: freshIds(block.children) } : {}) })); + export function insertPreset(document, name, selected) { if (name === 'hero') return insertHero(document, selected); + if (PLUGIN_PRESETS[name]) { + const blocks = freshIds(PLUGIN_PRESETS[name].blocks); + const top = blockPath(document.blocks, selected)[0]; + const index = top ? document.blocks.findIndex(block => block.id === top.id) + 1 : document.blocks.length; + document.blocks.splice(index, 0, ...blocks); + upgradeDocument(document, SCHEMA_VERSION); + return blocks[0].id; + } if (!['intro', 'columns', 'cta'].includes(name)) throw new Error('Unbekannte Vorlage.'); const section = createBlock('section'); const heading = text => { const block = createBlock('heading'); block.props.text = text; return block; }; @@ -471,6 +515,14 @@ export class EditorHistory { this.mergeKey = null; this.saved = JSON.stringify(document); } + /** Uebernimmt die vom Server bereinigte Fassung als aktuellen Stand, der Verlauf bleibt. */ + adopt(document) { + this.past.push(this.document); + if (this.past.length > 100) this.past.shift(); + this.document = clone(document); + this.future = []; + this.mergeKey = null; + } change(mutator, mergeKey = null) { const next = clone(this.document); const result = mutator(next); diff --git a/public/assets/editor.css b/public/assets/editor.css index 6fb0414..3787808 100644 --- a/public/assets/editor.css +++ b/public/assets/editor.css @@ -35,7 +35,7 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)} #media-status{font-size:12px;margin-block:15px} .media-dialog> .panel-hint{margin-top:20px} .media-details>p{font-size:12px} -@media(max-width:650px){.media-dialog{padding:18px}.media-tools,.media-layout{grid-template-columns:1fr}.media-details{padding:20px 0 0;border-left:0;border-top:1px solid var(--line)}.media-grid{max-height:280px;grid-template-columns:repeat(2,minmax(0,1fr))}.media-footer{align-items:stretch;flex-direction:column}.media-heading{gap:8px}} +@media(max-width:767px){.media-dialog{padding:18px}.media-tools,.media-layout{grid-template-columns:1fr}.media-details{padding:20px 0 0;border-left:0;border-top:1px solid var(--line)}.media-grid{max-height:280px;grid-template-columns:repeat(2,minmax(0,1fr))}.media-footer{align-items:stretch;flex-direction:column}.media-heading{gap:8px}} /* ---- shell ------------------------------------------------------------------------------- */ html:has(>.editor-body){overflow:hidden;height:100%} diff --git a/public/assets/editor.js b/public/assets/editor.js index 22f951d..b6ae17f 100644 --- a/public/assets/editor.js +++ b/public/assets/editor.js @@ -8,7 +8,7 @@ import { preview, rowPreview } from './library-previews.js'; import { decorateContainers } from './preview-containers.js'; import { initMotion } from './motion.js'; import { initSliders } from './slider.js'; -import { VISIBILITY, setVisibility } from './editor-model.js'; +import { VISIBILITY, setVisibility, registerPlugins, pluginDefinition, PLUGIN_PRESETS } from './editor-model.js'; // Mirrors DocumentValidator::ICONS; the server rejects anything outside this list. const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check', 'people', 'music', 'home']; @@ -243,7 +243,7 @@ function renderProperties() { const button = document.createElement('button'); button.type = 'button'; button.className = 'crumb'; button.textContent = item.type ? blockLabel(item) : 'Seite'; button.addEventListener('click', () => choose(item.id)); $('#breadcrumbs').append(button); } - if (block && isPluginBlock(block)) { + if (block && isPluginBlock(block) && !pluginDefinition(block)) { // The core has no schema for a plugin block, so it offers structure actions only and never // touches its stored properties. const note = document.createElement('p'); note.className = 'panel-note'; @@ -337,6 +337,8 @@ function renderContent(block, panel) { field('Beschreibung für Suchmaschinen', 'description', history.document.description, { multiline: true, maxLength: 320 }); return; } + const plugin = pluginDefinition(block); + if (plugin) { renderPluginFields(block, panel, plugin, field); return; } if (block.type === 'text') { // Formatted text with a toolbar and an HTML view. The document moves to v12 on the first // formatted save; until then it keeps plain text semantics and stays escaped when rendered. @@ -665,7 +667,7 @@ function renderLegacyTokens(block, panel) { // Token based values from documents written before schema v10. Shown only while a block // still carries them, so older pages stay editable without cluttering the new panel. const hint = document.createElement('p'); hint.className = 'design-scope'; - hint.textContent = `${devices[device]} · ${device === 'desktop' ? 'Basis für alle Geräte' : device === 'tablet' ? 'bis 1024 px; gilt auch für Mobil' : 'bis 650 px'}`; + hint.textContent = `${devices[device]} · ${device === 'desktop' ? 'Basis für alle Geräte' : device === 'tablet' ? 'bis 1024 px; gilt auch für Mobil' : 'bis 767 px'}`; const help = document.createElement('p'); help.className = 'field-help'; help.textContent = 'Oben die Vorschaugröße wählen. Ohne eigenen Wert gilt die Einstellung des größeren Geräts oder des Themes.'; panel.append(hint, help); for (const [key, token] of Object.entries(designTokens)) { @@ -714,6 +716,35 @@ function renderLegacyEffects(block, panel) { const note = document.createElement('p'); note.className = 'field-help'; note.textContent = 'Einblendungen starten, sobald das Element im Bildschirm erscheint. Mit „Bewegung testen“ lässt sich das in der Vorschau prüfen. Die Einstellung „Bewegung reduzieren“ wird berücksichtigt.'; panel.append(note); } +/** + * Die Felder eines Plugin-Bausteins, aus dem Schema seines Manifests erzeugt. Dieselbe + * Feldfabrik wie für Kernbausteine; nur ein Bild braucht die Mediathek. + */ +function renderPluginFields(block, panel, plugin, field) { + if (plugin.description) { const hint = document.createElement('p'); hint.className = 'panel-hint'; hint.textContent = plugin.description; panel.append(hint); } + for (const [key, schema] of Object.entries(plugin.props || {})) { + const value = block.props?.[key] ?? schema.default; + const options = { help: schema.help || undefined }; + if (schema.kind === 'textarea') field(schema.label, key, value, { ...options, multiline: true, maxLength: schema.maxLength }); + else if (schema.kind === 'number') field(schema.label, key, value, { ...options, number: true, min: schema.min, max: schema.max }); + else if (schema.kind === 'boolean') field(schema.label, key, value === true, { ...options, checkbox: true }); + else if (schema.kind === 'select') field(schema.label, key, value, { ...options, choices: schema.choices }); + else if (schema.kind === 'media') { + const wrap = document.createElement('div'); wrap.className = 'plugin-media-field'; + const label = document.createElement('p'); label.className = 'field-label'; label.textContent = schema.label; wrap.append(label); + const preview = createMediaPreview({ mediaId: value || '' }); wrap.append(preview.element); + const set = id => { history.change(doc => { const current = locate(doc.blocks, block.id)?.block; if (current) (current.props ??= {})[key] = id; }); renderProperties(); changed(); }; + const pick = document.createElement('button'); pick.type = 'button'; pick.className = 'secondary'; pick.textContent = value ? 'Anderes Bild wählen' : 'Bild aus Mediathek wählen'; + pick.addEventListener('click', () => openMedia({ multiple: false, onChoose: items => { if (items[0]?.id) set(items[0].id); } })); + wrap.append(pick); + if (value) { const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'text-button danger-text'; remove.textContent = 'Bild entfernen'; remove.addEventListener('click', () => set('')); wrap.append(remove); } + if (schema.help) { const help = document.createElement('p'); help.className = 'field-help'; help.textContent = schema.help; wrap.append(help); } + panel.append(wrap); + } + else field(schema.label, key, value, { ...options, maxLength: schema.maxLength }); + } +} + function chooseMedia(multiple) { const target = selected; openMedia({ multiple, onChoose: items => { @@ -1012,6 +1043,9 @@ async function load() { if (!designSchema) await loadDesignSchema(); history = new EditorHistory(data.document); ({ version, revisionId, publishedRevisionId, csrfToken } = data); + // Die Bausteine der eingeschalteten Plugins kommen mit dem Dokument; der Werkzeugkasten + // bekommt dafür eine eigene Gruppe. + registerPlugins(data.plugins || [], data.presets || []); buildLibrary(); buildPluginPresets(); selected = null; propertyPanel = 'content'; collapsed.clear(); conflict = false; notify(); changed(); resizePreview(); } catch (error) { notify(error.message, true); } finally { busy = false; controls(); } @@ -1024,7 +1058,13 @@ async function save(publish = false) { try { const data = await request(publish ? '/publish' : '/draft', publish ? { expectedVersion: version, revisionId } : { expectedVersion: version, document: snapshot }); ({ version, revisionId, publishedRevisionId, csrfToken } = data); - history.markSaved(snapshot); + // Der Server bereinigt Rich Text beim Speichern. Weicht seine Fassung ab, uebernimmt der + // Editor sie - als gespeicherter Stand, ohne den Verlauf zu verlieren. + if (!publish && data.document && JSON.stringify(data.document) !== JSON.stringify(snapshot) && JSON.stringify(history.document) === JSON.stringify(snapshot)) { + history.adopt(data.document); + history.markSaved(data.document); + renderTree(); renderProperties(); schedulePreview(); + } else history.markSaved(snapshot); notify(); } catch (error) { notify(error.message, true); } finally { busy = false; controls(); } @@ -1101,6 +1141,23 @@ function markInsertTarget() { // editor describes in its own hint was not the structure the buttons followed. // --------------------------------------------------------------------------------------------- +/** Die Seitenvorlagen der Plugins, hinter den eingebauten, mit demselben Knopf. */ +function buildPluginPresets() { + const presets = $('#presets'); + presets.querySelectorAll('[data-plugin-preset]').forEach(button => button.remove()); + for (const preset of Object.values(PLUGIN_PRESETS)) { + const button = document.createElement('button'); button.type = 'button'; button.dataset.preset = preset.id; button.dataset.pluginPreset = ''; + button.title = preset.description ? `${preset.description} (${preset.plugin})` : preset.plugin; + const icon = document.createElement('span'); icon.setAttribute('aria-hidden', 'true'); icon.textContent = '⬡'; + button.append(icon, document.createTextNode(` ${preset.label}`)); + presets.append(button); + } + controls(); +} + +/** Die Zeichnung für einen Plugin-Baustein im Werkzeugkasten: ein Steckstück. */ +const PLUGIN_PREVIEW = ''; + function buildLibrary() { const library = $('#library'); library.replaceChildren(); @@ -1128,7 +1185,7 @@ function buildLibrary() { const picture = document.createElement('span'); picture.className = 'library-preview'; // Drawn, not written: the shapes come from this editor and carry no untrusted text. - picture.innerHTML = columns ? rowPreview(columns) : preview(type); + picture.innerHTML = columns ? rowPreview(columns) : (preview(type) || PLUGIN_PREVIEW); const text = document.createElement('span'); text.className = 'library-name'; text.textContent = name; diff --git a/public/assets/enhancements.css b/public/assets/enhancements.css index 2944c46..267ab77 100644 --- a/public/assets/enhancements.css +++ b/public/assets/enhancements.css @@ -1,6 +1,6 @@ .block-gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin-block:24px}.block-gallery>.block-image{min-width:0;margin:0}.block-gallery img{width:100%;aspect-ratio:4/3;object-fit:cover}.block-image figcaption{font-size:14px;line-height:1.5;padding:12px 0;color:inherit}.fx-shadow-none{box-shadow:none}.fx-shadow-soft{box-shadow:0 8px 24px #142e251f}.fx-shadow-strong{box-shadow:0 16px 40px #142e2540}.block-image{border-radius:12px}.block-image img{border-radius:inherit}.block-gallery:empty{min-height:32px}.fx-hover-zoom{overflow:hidden}.fx-hover-zoom img{transform-origin:center}.tone-ink figure{color:var(--paper)} @media(max-width:1024px){.block-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}} -@media(max-width:650px){.block-gallery{grid-template-columns:minmax(0,1fr);gap:16px}} +@media(max-width:767px){.block-gallery{grid-template-columns:minmax(0,1fr);gap:16px}} .fx-hover-lift,.fx-hover-zoom img{transition:transform .25s ease}.fx-hover-lift:hover,.fx-hover-lift:focus-within{transform:translateY(-4px)}.fx-hover-zoom:hover img,.fx-hover-zoom:focus-within img{transform:scale(1.035)} diff --git a/public/assets/form-editor.js b/public/assets/form-editor.js index 8e7098b..e15722a 100644 --- a/public/assets/form-editor.js +++ b/public/assets/form-editor.js @@ -13,6 +13,7 @@ import { buttonSample } from './button-design.js'; const root = document.querySelector('[data-form-schema]'); if (root) { const schema = JSON.parse(root.dataset.formSchema); + const bilder = JSON.parse(root.dataset.formImages || '[]'); const defaults = { fieldBorderColor: schema.base.color, fieldBackgroundColor: '#ffffff', @@ -63,18 +64,37 @@ if (root) { if (!label) continue; shown++; const type = card.querySelector('[data-field-type]').value; + const help = card.querySelector('[data-field-help]').value.trim(); + if (type === 'step') { + // Ein Schritt ist eine Zwischenüberschrift in der Vorschau, mit seiner Einleitung. + const step = el('div', 'form-preview-step'); + step.append(el('span', 'form-preview-step-title', label)); + if (help) step.append(el('span', 'form-preview-help', help)); + preview.append(step); + continue; + } const required = type === 'consent' || card.querySelector('[data-field-required]').checked; - const field = el('div', 'form-preview-field'); + const showIf = card.querySelector('[data-field-showif]').value.trim(); + const field = el('div', `form-preview-field${showIf ? ' form-preview-conditional' : ''}`); + const options = () => card.querySelector('[data-field-options-input]').value.split(',').map(option => option.trim()).filter(Boolean); if (type === 'consent') { const check = el('span', 'form-preview-check'); check.append(el('span', 'form-preview-box'), document.createTextNode(`${label} *`)); field.append(check); + } else if (type === 'radio' || type === 'checkboxes') { + field.append(el('span', 'form-preview-label', label + (required ? ' *' : ''))); + for (const option of options()) { + const check = el('span', 'form-preview-check'); + check.append(el('span', `form-preview-box${type === 'radio' ? ' form-preview-radio' : ''}`), document.createTextNode(option)); + field.append(check); + } } else { field.append(el('span', 'form-preview-label', label + (required ? ' *' : ''))); - field.append(el('span', `form-preview-input${type === 'textarea' ? ' form-preview-area' : ''}${type === 'select' ? ' form-preview-select' : ''}`, type === 'select' ? '— bitte wählen —' : '')); + const unit = type === 'number' ? card.querySelector('[data-field-unit]').value.trim() : ''; + field.append(el('span', `form-preview-input${type === 'textarea' ? ' form-preview-area' : ''}${type === 'select' ? ' form-preview-select' : ''}${type === 'number' ? ' form-preview-number' : ''}`, type === 'select' ? '— bitte wählen —' : unit)); } - const help = card.querySelector('[data-field-help]').value.trim(); if (help) field.append(el('span', 'form-preview-help', help)); + if (showIf) field.append(el('span', 'form-preview-help', `Nur wenn ${showIf} = ${card.querySelector('[data-field-showvalues]').value.split(',').map(v => v.trim()).filter(Boolean).join(' oder ') || '…'}`)); preview.append(field); } if (!shown) preview.append(el('p', 'form-preview-empty', 'Noch kein Feld mit Beschriftung.')); @@ -95,16 +115,52 @@ if (root) { const note = el('p', 'contrast-note'); note.hidden = true; note.setAttribute('role', 'status'); preview.after(note); + // Statt Kennungen zu tippen: je Option eine Liste der Mediathek. Das Textfeld bleibt das + // gespeicherte Original und wird aus den Listen geschrieben; ohne Skript tippt man hinein. + const renderImagePickers = card => { + const area = card.querySelector('[data-field-images-input]'); + const options = card.querySelector('[data-field-options-input]').value.split(',').map(o => o.trim()).filter(Boolean); + let box = card.querySelector('[data-image-pickers]'); + if (!box) { + box = el('div', 'field-image-pickers'); box.dataset.imagePickers = ''; + area.hidden = true; area.after(box); + } + if (box.dataset.signature === options.join('|')) return; + const current = Object.fromEntries(area.value.split('\n').filter(l => l.includes('=')).map(l => l.split('=').map(x => x.trim()))); + box.dataset.signature = options.join('|'); + box.replaceChildren(); + for (const option of options) { + const row = el('label', 'field-image-picker'); + row.append(el('span', 'field-image-picker-option', option)); + const select = document.createElement('select'); + select.append(new Option('Kein Bild', '')); + for (const bild of bilder) select.append(new Option(bild.title, bild.id, false, current[option] === bild.id)); + select.dataset.option = option; + select.addEventListener('change', () => { + const map = {}; + for (const picker of box.querySelectorAll('select')) if (picker.value) map[picker.dataset.option] = picker.value; + area.value = Object.entries(map).map(([o, id]) => `${o} = ${id}`).join('\n'); + renderPreview(); + }); + row.append(select); box.append(row); + } + }; + const refresh = () => { cards().forEach((card, index) => { card.querySelector('[data-field-index]').textContent = String(index + 1); const type = card.querySelector('[data-field-type]').value; - card.querySelector('[data-field-options]').hidden = type !== 'select'; + card.querySelector('[data-field-options]').hidden = !['select', 'radio', 'checkboxes'].includes(type); + card.querySelector('[data-field-images]').hidden = !['radio', 'checkboxes'].includes(type); + if (['radio', 'checkboxes'].includes(type)) renderImagePickers(card); + card.querySelector('[data-field-number]').hidden = type !== 'number'; const remove = card.querySelector('[data-remove-field]'); remove.hidden = false; remove.setAttribute('aria-label', `Feld ${index + 1} entfernen`); const required = card.querySelector('[data-field-required]'); // Eine Einwilligung ist immer Pflicht - das Kästchen sagt es, statt es zu verstecken. - if (type === 'consent') { required.checked = true; required.disabled = true; } else required.disabled = false; + if (type === 'consent') { required.checked = true; required.disabled = true; } + else if (type === 'step') { required.checked = false; required.disabled = true; } + else required.disabled = false; }); renderPreview(); }; diff --git a/public/assets/form-steps.js b/public/assets/form-steps.js new file mode 100644 index 0000000..3d3373d --- /dev/null +++ b/public/assets/form-steps.js @@ -0,0 +1,198 @@ +/** + * Formulare mit Schritten, Bedingungen und Zusammenfassung. + * + * Ohne dieses Skript steht das Formular vollständig da: jeder Schritt ein Abschnitt, jedes Feld + * sichtbar, der Server prüft beim Absenden, welche Felder galten. Das Skript ändert nur die + * Darstellung: ein Schritt nach dem anderen, Felder hinter einer Bedingung nur bei passender + * Antwort, und vor dem Absenden eine Zusammenfassung der Eingaben. Es wird nur auf Seiten + * geladen, deren Formular eines davon braucht. + */ +(() => { + const el = (tag, className, text) => { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + }; + + const setup = form => { + const rules = [...form.querySelectorAll('[data-show-if]')]; + const steps = [...form.querySelectorAll('[data-step]')]; + const submit = form.querySelector('.cms-button[type="submit"]'); + const wantsSummary = form.hasAttribute('data-summary') && steps.length > 0 && submit; + if (!rules.length && !steps.length) return; + + // Die gewählten Werte eines Feldes - nur, solange es gilt. Ein Feld in einem ausgeblendeten + // Schritt zählt nicht, auch wenn dort noch etwas angekreuzt ist. + const valuesOf = name => [...form.querySelectorAll(`[name="${name}"], [name="${name}[]"]`)] + .filter(input => !input.closest('[data-inactive]')) + .filter(input => input.type === 'checkbox' || input.type === 'radio' ? input.checked : true) + .map(input => input.value) + .filter(value => value !== ''); + + // Bedingungen in Dokumentreihenfolge: ein Feld hängt nur von früheren Feldern ab, also steht + // deren Zustand schon fest, wenn es an der Reihe ist. + const apply = () => { + for (const rule of rules) { + const wanted = rule.dataset.showValues.split('|'); + const have = valuesOf(rule.dataset.showIf); + rule.toggleAttribute('data-inactive', !wanted.some(value => have.includes(value))); + } + // Was nicht gilt, wird nicht geprüft und nicht gesendet: deaktiviert, nicht nur versteckt. + for (const input of form.querySelectorAll('input, select, textarea')) { + if (input.type === 'hidden') continue; + input.disabled = input.closest('[data-inactive]') !== null; + } + }; + form.addEventListener('change', () => { apply(); if (steps.length) refreshNav(); }); + apply(); + + if (!steps.length) return; + + // Die Zusammenfassung ist ein eigener, letzter Schritt - erst hier steht der Absendeknopf. + let summary = null; + if (wantsSummary) { + summary = el('div', 'form-step form-summary'); + summary.setAttribute('data-step', ''); + const title = el('p', 'form-step-title', 'Zusammenfassung'); + title.tabIndex = -1; + summary.append(title, el('p', 'form-step-intro', 'Bitte prüfen Sie Ihre Angaben. Über „Ändern“ kommen Sie zum jeweiligen Schritt zurück.')); + summary.append(el('div', 'form-summary-body')); + steps[steps.length - 1].after(summary); + steps.push(summary); + } + + const progress = el('ol', 'form-progress'); + progress.setAttribute('aria-label', 'Schritte des Formulars'); + steps.forEach((step, index) => { + const item = el('li', 'form-progress-step'); + item.append(el('span', 'form-progress-number', String(index + 1)), el('span', 'form-progress-title', step.querySelector('.form-step-title')?.textContent.trim() || `Schritt ${index + 1}`)); + progress.append(item); + }); + form.insertBefore(progress, steps[0]); + + const nav = el('div', 'form-step-nav'); + const back = el('button', 'button cms-button form-step-back', 'Zurück'); + back.type = 'button'; + const next = el('button', 'button cms-button form-step-next', 'Weiter'); + next.type = 'button'; + nav.append(back, next); + (submit || form).before(nav); + if (submit) nav.append(submit); + + const visible = () => steps.map((step, index) => step.hasAttribute('data-inactive') ? -1 : index).filter(index => index >= 0); + let current = 0; + + const labelOf = field => field.dataset.label || ''; + const valueOf = field => { + const inputs = [...field.querySelectorAll('input, select, textarea')].filter(input => !input.disabled); + const picked = inputs.filter(input => input.type === 'checkbox' || input.type === 'radio' ? input.checked : input.value.trim() !== ''); + if (!picked.length) return ''; + const unit = field.querySelector('.form-unit')?.textContent || ''; + return picked.map(input => { + if (input.type === 'checkbox' && input.value === 'ja' && field.querySelector('.check-row label')) return 'Ja'; + if (input.tagName === 'SELECT') return input.selectedOptions[0]?.textContent || input.value; + return input.value + (unit ? ` ${unit}` : ''); + }).join(', '); + }; + + const renderSummary = () => { + const body = summary.querySelector('.form-summary-body'); + body.replaceChildren(); + steps.forEach((step, index) => { + if (step === summary || step.hasAttribute('data-inactive')) return; + const block = el('section', 'form-summary-step'); + const head = el('div', 'form-summary-head'); + head.append(el('h4', 'form-summary-title', step.querySelector('.form-step-title')?.textContent.trim() || `Schritt ${index + 1}`)); + const change = el('button', 'form-summary-change', 'Ändern'); + change.type = 'button'; + change.setAttribute('aria-label', `${head.firstChild.textContent} ändern`); + change.addEventListener('click', () => show(index)); + head.append(change); + block.append(head); + const list = el('dl', 'form-summary-list'); + for (const field of step.querySelectorAll('[data-field]')) { + if (field.closest('[data-inactive]')) continue; + const value = valueOf(field); + list.append(el('dt', '', labelOf(field)), el('dd', value ? '' : 'form-summary-empty', value || '—')); + } + block.append(list); + body.append(block); + }); + }; + + const refreshNav = () => { + const order = visible(); + const position = order.indexOf(current); + const last = position === order.length - 1; + back.hidden = position <= 0; + next.hidden = last; + if (submit) submit.hidden = !last; + progress.querySelectorAll('li').forEach((item, index) => { + item.hidden = steps[index].hasAttribute('data-inactive'); + item.classList.toggle('is-done', order.indexOf(index) < position); + if (index === current) item.setAttribute('aria-current', 'step'); else item.removeAttribute('aria-current'); + }); + }; + + const show = (index, focus = true) => { + current = index; + steps.forEach((step, i) => { step.hidden = i !== index; }); + if (summary && steps[index] === summary) renderSummary(); + refreshNav(); + if (focus) { + steps[index].querySelector('.form-step-title')?.focus({ preventScroll: true }); + if (progress.getBoundingClientRect().top < 0) progress.scrollIntoView({ block: 'start' }); + } + }; + + // Erst der Schritt selbst prüft seine Felder: der Browser meldet das erste ungültige Feld, + // eine Pflicht-Mehrfachauswahl ohne Kreuz meldet die Gruppe. + const valid = step => { + for (const group of step.querySelectorAll('[data-required]')) { + if (group.closest('[data-inactive]')) continue; + const note = group.querySelector('.field-error[data-live]'); + if (!group.querySelector('input:checked')) { + if (!note) group.append(Object.assign(el('p', 'field-error', 'Bitte wähle mindestens eine Option.'), { dataset: { live: '' } })); + else note.hidden = false; + group.classList.add('has-error'); + group.querySelector('input')?.focus(); + return false; + } + if (note) note.remove(); + group.classList.remove('has-error'); + } + for (const input of step.querySelectorAll('input, select, textarea')) { + if (input.disabled || input.checkValidity()) continue; + input.reportValidity(); + return false; + } + return true; + }; + + next.addEventListener('click', () => { + if (!valid(steps[current])) return; + const order = visible(); + const following = order[order.indexOf(current) + 1]; + if (following !== undefined) show(following); + }); + back.addEventListener('click', () => { + const order = visible(); + const previous = order[order.indexOf(current) - 1]; + if (previous !== undefined) show(previous); + }); + // Enter in einem Feld geht zum nächsten Schritt, statt das Formular vorzeitig abzuschicken. + form.addEventListener('keydown', event => { + if (event.key !== 'Enter' || event.target.tagName === 'TEXTAREA' || event.target.tagName === 'BUTTON') return; + if (!next.hidden) { event.preventDefault(); next.click(); } + }); + + // Nach einer Ablehnung durch den Server: der erste Schritt mit einem Fehler, sonst der erste. + const failed = steps.findIndex(step => step.querySelector('.has-error')); + const order = visible(); + show(failed >= 0 ? failed : (order[0] ?? 0), false); + form.classList.add('form-steps-ready'); + }; + + document.querySelectorAll('form.block-form').forEach(setup); +})(); diff --git a/public/assets/preview.css b/public/assets/preview.css index cb91d88..74dc5bd 100644 --- a/public/assets/preview.css +++ b/public/assets/preview.css @@ -22,4 +22,4 @@ .editor-dragging .editor-empty{background:#e6f1e9;border-color:#467858} .editor-add-content{min-height:0;margin-top:12px;padding:10px;border-width:1px;background:#f3f8f4} .block-accordion.editor-container>.editor-add-content{margin-top:16px} -@media(max-width:650px){.editor-empty{padding:16px 10px}.editor-empty button{min-height:44px}} +@media(max-width:767px){.editor-empty{padding:16px 10px}.editor-empty button{min-height:44px}} diff --git a/public/assets/site.css b/public/assets/site.css index d32076c..eef2c51 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -5,13 +5,13 @@ h1,h2,h3,h4,h5,h6,p,a{overflow-wrap:break-word} 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} +:root{color-scheme:var(--k-scheme,light);--paper:#f7f8f4;--ink:#182d2b;--muted:#50625d;--line:#d7dfd7;--mint:#e5f0b6;--accent:#315b49;--error:var(--k-error,#8b2f22);--error-bg:var(--k-error-bg,#fff0eb);--error-line:var(--k-error-line,#e2b2a7);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)} /* Über dem Inhalt: der Kopf nimmt keinen Platz ein, die erste Sektion beginnt unter ihm. Farbe und Schatten kommen aus der Gestaltung des Kopfbereichs, auch die für den gescrollten Zustand. */ .site-header.is-overlay{position:absolute;top:0;left:0;right:0;z-index:40;border-bottom:0;background:transparent;box-shadow:none} -.site-header.is-overlay.is-sticky{position:fixed}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:none;font-weight:800}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500}h2{font-size:clamp(32px,4vw,49px);font-weight:500}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color: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}} +.site-header.is-overlay.is-sticky{position:fixed}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:none;font-weight:800}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500}h2{font-size:clamp(32px,4vw,49px);font-weight:500}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:var(--paper);padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px} +@media(max-width:767px){.shell{width:calc(100% - 36px)}.site-header nav{width:100%;justify-content:space-between;gap:12px;font-size:12px}.site-header nav .nav-link{padding:10px}.nav-link span{margin-left:6px}.block-section,.block-section:first-child{padding-block:48px}h1{font-size:49px}.block-text{font-size:16px}.site-footer{align-items:flex-start;flex-direction:column;gap:10px}.site-footer p{margin:0}.block-row{gap:16px}} /* Menus. Submenus open with details/summary, so the navigation works without JavaScript. */ .menu-list{list-style:none;display:flex;flex-wrap:wrap;align-items:center;gap:4px 22px;margin:0;padding:0} @@ -27,7 +27,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);text-decoration:none;width:fit-content} .post-more::after{content:'→';transition:transform .2s} -.post-more:hover::after{transform:translateX(4px)}.post-list-wechsel .post-more::after{content:'\2192'}.post-list-wechsel .post-card:nth-child(even) .post-image{order:2}.post-list-wechsel .post-card:nth-child(even) .post-body{order:1}@media(max-width: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-more:hover::after{transform:translateX(4px)}.post-list-wechsel .post-more::after{content:'\2192'}.post-list-wechsel .post-card:nth-child(even) .post-image{order:2}.post-list-wechsel .post-card:nth-child(even) .post-body{order:1}@media(max-width:767px){.post-list-wechsel .post-card{grid-template-columns:1fr;gap:18px;padding:26px 0}.post-list-wechsel .post-card:nth-child(even) .post-image{order:0}.post-list-wechsel .post-card:nth-child(even) .post-body{order:0}.post-list-wechsel .post-card h3{font-size:22px}}.post-list-kompakt{gap:0}.post-list-kompakt .post-card{flex-direction:row;align-items:baseline;justify-content:space-between;gap:18px;background:transparent;border:0;border-bottom:1px solid var(--line);border-radius:0;overflow:visible}.post-list-kompakt .post-body{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 18px;padding:16px 2px}.post-list-kompakt .post-meta,.post-list-kompakt .post-terms{margin:0}.post-list-kompakt .post-excerpt{flex:1 1 100%;margin:0} .post-card{display:flex;flex-direction:column;background:var(--paper);border:1px solid var(--line);border-radius:18px;overflow:hidden}.post-card>.post-body{display:grid;gap:10px;padding:20px 22px 22px} .post-image{display:block;position:relative;overflow:hidden;text-decoration:none}.post-view{position:absolute;inset:0;display:grid;place-items:center;background:color-mix(in srgb,var(--ink) 55%,transparent);color:#fff;font-weight:700;font-size:15px;letter-spacing:.4px;opacity:0}.post-card:hover .post-view,.post-card:focus-within .post-view{opacity:1}.post-image img{width:100%;height:auto;aspect-ratio:16/10;object-fit:cover;border-radius:0;display:block;background:var(--ink)} .kant h1 mark,.kant h2 mark,.kant h3 mark{background:transparent;color:var(--accent);padding:0} @@ -64,7 +64,7 @@ p{hyphens:auto} .quote-rating{margin:0;letter-spacing:3px} .block-logos{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));align-items:center;gap:24px} .block-logos img{max-height:52px;width:auto;margin-inline:auto;display:block} -.kant .block-cta .button{background:var(--accent);color:#fff;border-color:var(--accent)}.kant .block-cta .button:hover{background:color-mix(in srgb,var(--accent) 86%,#000)}.block-cta{display:flex;flex-wrap:wrap;gap:20px 34px;align-items:center;justify-content:space-between;padding:32px;border:1px solid currentColor;border-radius:14px} +.kant .block-cta .button{background:var(--accent);color:var(--paper);border-color:var(--accent)}.kant .block-cta .button:hover{background:color-mix(in srgb,var(--accent) 86%,var(--ink))}.block-cta{display:flex;flex-wrap:wrap;gap:20px 34px;align-items:center;justify-content:space-between;padding:32px;border:1px solid currentColor;border-radius:14px} .kant .block-cta h2{margin:0 0 6px;font-size:26px;letter-spacing:-.6px} .block-cta p{margin:0;line-height:1.6} .block-contact{display:grid;gap:14px;font-style:normal} @@ -102,23 +102,67 @@ p{hyphens:auto} .kant .cms-button.d-desktop-align-center{display:flex;width:fit-content;margin-inline:auto;justify-self:center} .kant .cms-button.d-desktop-align-right{display:flex;width:fit-content;margin-inline:auto 0;justify-self:end} @media(max-width:1024px){.kant .cms-button.d-tablet-align-left{display:inline-flex;width:auto;justify-self:start;margin-inline:0}.kant .cms-button.d-tablet-align-center{display:flex;width:fit-content;margin-inline:auto;justify-self:center}.kant .cms-button.d-tablet-align-right{display:flex;width:fit-content;margin-inline:auto 0;justify-self:end}} -@media(max-width:650px){.kant .cms-button.d-mobile-align-left{display:inline-flex;width:auto;justify-self:start;margin-inline:0}.kant .cms-button.d-mobile-align-center{display:flex;width:fit-content;margin-inline:auto;justify-self:center}.kant .cms-button.d-mobile-align-right{display:flex;width:fit-content;margin-inline:auto 0;justify-self:end}} +@media(max-width:767px){.kant .cms-button.d-mobile-align-left{display:inline-flex;width:auto;justify-self:start;margin-inline:0}.kant .cms-button.d-mobile-align-center{display:flex;width:fit-content;margin-inline:auto;justify-self:center}.kant .cms-button.d-mobile-align-right{display:flex;width:fit-content;margin-inline:auto 0;justify-self:end}} .form-field{display:grid;gap:7px} .form-field label{font-weight:600;font-size:14px} .form-field input,.form-field textarea,.form-field select{width:100%;padding:12px 13px;border:1px solid var(--form-border,currentColor);border-radius:8px;background:var(--form-bg,#fff);color:inherit;font:inherit;min-height:46px} .form-field textarea{min-height:140px;line-height:1.6;resize:vertical} -.has-error input,.has-error textarea,.has-error select{border-width:2px;border-color:#8b2f22} -.field-error{margin:0;font-size:13px;font-weight:600;color:#8b2f22} +.has-error input,.has-error textarea,.has-error select{border-width:2px;border-color:var(--error)} +.field-error{margin:0;font-size:13px;font-weight:600;color:var(--error)} .field-note{margin:0;font-size:13px;opacity:.8} -.form-error-summary{margin:0;padding:14px 18px;border-radius:8px;background:#fff0eb;color:#8b2f22;border:1px solid #e2b2a7;font-weight:600} +.form-error-summary{margin:0;padding:14px 18px;border-radius:8px;background:var(--error-bg);color:var(--error);border:1px solid var(--error-line);font-weight:600} .form-success{margin:0;padding:16px 20px;border-radius:8px;background:color-mix(in srgb,var(--accent) 10%,var(--paper));color:var(--accent);border:1px solid color-mix(in srgb,var(--accent) 35%,var(--paper));font-weight:600} .form-missing{margin:0;padding:14px 18px;border:1px dashed currentColor;border-radius:8px} .check-row{display:flex;align-items:flex-start;gap:11px} .check-row input{width:20px;min-height:20px;flex-shrink:0;margin-top:3px} .check-row label{font-weight:400;line-height:1.55} -.required-mark{color:#8b2f22} +.required-mark{color:var(--error)} /* Off screen rather than display:none so a filled value still proves automation. */ .form-trap{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden} +/* Schritte, Bedingungen, Zusammenfassung. Ohne Skript: Abschnitte untereinander, alles sichtbar. */ +.form-step{display:grid;gap:20px;margin:0;padding:0;border:0;min-width:0} +/* Ein Schritt und der Absendeknopf sind Raster bzw. inline-flex - das hidden-Attribut muss trotzdem gelten. */ +.block-form [hidden]{display:none!important} +.form-step-title{padding:0;margin:0;font-weight:600;font-size:18px;line-height:1.3} +.form-step-title:focus{outline:none} +.form-step-intro{margin:-8px 0 0;font-size:14px;opacity:.85} +.form-choice{display:grid;gap:10px;margin:0;padding:0;border:0;min-width:0} +.form-choice legend{padding:0;margin-bottom:2px;font-weight:600;font-size:14px} +.form-choice .check-row{align-items:center} +.form-choice .check-row input{margin-top:0} +/* Bildauswahl: jede Option eine Karte, das Kästchen bleibt für Tastatur und Vorlesesoftware. */ +.form-choice-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:12px} +.form-choice-card{position:relative} +.form-choice-card input{position:absolute;top:10px;left:10px;width:20px;height:20px;margin:0;z-index:1} +.form-choice-card label{display:grid;gap:8px;padding:8px;border:2px solid var(--form-border,currentColor);border-radius:10px;background:var(--form-bg,#fff);cursor:pointer;font-weight:600;font-size:14px;text-align:center;transition:border-color .2s,box-shadow .2s} +.form-choice-card label:hover{border-color:var(--kb-fill,var(--accent))} +.form-choice-card input:checked+label{border-color:var(--kb-fill,var(--accent));box-shadow:0 0 0 2px var(--kb-fill,var(--accent))} +.form-choice-card input:focus-visible+label{outline:3px solid var(--kb-fill,var(--accent));outline-offset:3px} +.form-choice-picture{display:block;aspect-ratio:4/3;border-radius:6px;overflow:hidden;background:color-mix(in srgb,currentColor 6%,transparent)} +.form-choice-picture img{display:block;width:100%;height:100%;object-fit:cover} +.form-choice-text{display:block;padding:0 4px 4px} +.form-number{display:flex;align-items:center;gap:10px} +.form-number input{flex:1 1 auto;min-width:0} +.form-unit{flex:none;font-size:14px;opacity:.8} +[data-inactive]{display:none!important} +.form-progress{display:flex;flex-wrap:wrap;gap:8px 18px;list-style:none;margin:0;padding:0;font-size:13px} +.form-progress-step{display:flex;align-items:center;gap:8px;opacity:.55} +.form-progress-step[aria-current]{opacity:1;font-weight:600} +.form-progress-step.is-done{opacity:.85} +.form-progress-number{display:grid;place-items:center;width:24px;height:24px;border-radius:50%;border:1px solid currentColor;font-size:12px} +.form-progress-step[aria-current] .form-progress-number,.form-progress-step.is-done .form-progress-number{background:var(--kb-fill,var(--accent));color:var(--kb-ink,#fff);border-color:transparent} +.form-step-nav{display:flex;flex-wrap:wrap;gap:12px;align-items:center} +.form-step-nav .form-step-back{--kb-fill:transparent;--kb-ink:currentColor;--kb-hover-fill:transparent;--kb-hover-ink:currentColor;border:1px solid currentColor;background:transparent;color:inherit} +.form-summary-body{display:grid;gap:18px} +.form-summary-step{padding:16px 18px;border:1px solid var(--form-border,currentColor);border-radius:8px} +.form-summary-head{display:flex;justify-content:space-between;align-items:baseline;gap:12px;margin-bottom:8px} +.form-summary-title{margin:0;font-size:15px;font-weight:600} +.form-summary-change{border:0;padding:0;background:none;color:inherit;font:inherit;font-size:14px;text-decoration:underline;cursor:pointer} +.form-summary-list{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.4fr);gap:6px 16px;margin:0;font-size:14px} +.form-summary-list dt{font-weight:600} +.form-summary-list dd{margin:0;overflow-wrap:anywhere} +.form-summary-empty{opacity:.6} +@media(max-width:767px){.form-summary-list{grid-template-columns:1fr;gap:2px 0}.form-summary-list dd{margin-bottom:8px}} .block-breadcrumb ol{list-style:none;display:flex;flex-wrap:wrap;gap:6px 10px;margin:0;padding:0;font-size:13px} .block-breadcrumb li+li::before{content:'›';margin-right:10px;opacity:.6} @@ -129,7 +173,7 @@ p{hyphens:auto} for light backgrounds. Inside a dark tone they won on specificity, so text in a dark column was unreadable - 1.87:1 in the seeded footer. These two selectors match that specificity and come later, so the dark tone wins where it has to. */ -.tone-ink .block-column .block-text{color:#dae3da} +.tone-ink .block-column .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))} /* The consent question. Fixed to the bottom, but it never covers the whole page: the content stays readable while the decision is open, because a wall would make "accept" the only way @@ -263,7 +307,7 @@ body.consent-open{padding-bottom:320px} .slider-play[data-playing=true]::before{width:8px;height:11px;border:0;border-left:3px solid currentColor;border-right:3px solid currentColor;margin-left:0} .slider-play:disabled{opacity:.4;cursor:default} .slider-controls[hidden]{display:none} -@media(max-width:600px){.slider-controls .slider-prev,.slider-controls .slider-next{display:none}.slider-control-dots{right:54px;bottom:14px}.slider-controls .slider-play{right:14px;bottom:12px}} +@media(max-width:767px){.slider-controls .slider-prev,.slider-controls .slider-next{display:none}.slider-control-dots{right:54px;bottom:14px}.slider-controls .slider-play{right:14px;bottom:12px}} .slide-desktop-1{grid-auto-columns:100%}.slide-desktop-2{grid-auto-columns:calc(50% - 10px)} .slide-desktop-3{grid-auto-columns:calc(33.333% - 14px)}.slide-desktop-4{grid-auto-columns:calc(25% - 15px)} @@ -273,7 +317,7 @@ body.consent-open{padding-bottom:320px} .slide-tablet-3{grid-auto-columns:calc(33.333% - 14px)}.slide-tablet-4{grid-auto-columns:calc(25% - 15px)} .slide-tablet-5{grid-auto-columns:calc(20% - 16px)}.slide-tablet-6{grid-auto-columns:calc(16.666% - 17px)} } -@media (max-width:650px){ +@media (max-width:767px){ .slide-mobile-1{grid-auto-columns:100%}.slide-mobile-2{grid-auto-columns:calc(50% - 10px)} .slide-mobile-3{grid-auto-columns:calc(33.333% - 14px)}.slide-mobile-4{grid-auto-columns:calc(25% - 15px)} .slide-mobile-5{grid-auto-columns:calc(20% - 16px)}.slide-mobile-6{grid-auto-columns:calc(16.666% - 17px)} @@ -387,7 +431,7 @@ body.consent-open{padding-bottom:320px} den Zusatz gewann diese Regel und ein breiter oder voller Abschnitt blieb so schmal wie alle. */ .kant .width-wide>.section-inner{width:min(1600px,calc(100% - 64px));max-width:none} .kant .width-full>.section-inner{width:100%;max-width:none;margin-inline:0} -@media (max-width:650px){.kant .width-wide>.section-inner{width:calc(100% - 36px)}} +@media (max-width:767px){.kant .width-wide>.section-inner{width:calc(100% - 36px)}} /* A section that is a flex container centres its content box; the inner container must then be allowed to grow, or a full height section would show a strip in the middle. */ @@ -493,3 +537,17 @@ html.kant-top-armed.kant-top-shown .scroll-top{opacity:1;visibility:visible;tran /* Soziale Profile: eine Symbolreihe, Farbe und Abstand vom Block, Größe als Klasse. */ .block-social{display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin:0;padding:0;list-style:none}.block-social.social-vertical{flex-direction:column;align-items:flex-start}.block-social a{display:inline-flex;align-items:center;gap:10px;color:inherit;text-decoration:none;line-height:1;transition:transform .2s,background-color .2s,opacity .2s}.block-social a:hover{transform:translateY(-2px)}.block-social svg{display:block;flex:none}.block-social.social-labels a{font-size:.875em;font-weight:600}.social-shape-circle a,.social-shape-square a{padding:.5em;background:color-mix(in srgb,currentColor 10%,transparent)}.social-shape-circle a{border-radius:999px}.social-shape-square a{border-radius:.35em}.social-shape-circle a:hover,.social-shape-square a:hover{background:color-mix(in srgb,currentColor 18%,transparent)}.social-labels.social-shape-circle a,.social-labels.social-shape-square a{padding-inline:.8em}.social-size-12 svg{width:12px;height:12px}.social-size-16 svg{width:16px;height:16px}.social-size-20 svg{width:20px;height:20px}.social-size-24 svg{width:24px;height:24px}.social-size-32 svg{width:32px;height:32px}.social-size-40 svg{width:40px;height:40px}.social-size-48 svg{width:48px;height:48px}.social-size-56 svg{width:56px;height:56px}.social-size-64 svg{width:64px;height:64px}.social-size-80 svg{width:80px;height:80px}.social-size-96 svg{width:96px;height:96px}.social-size-120 svg{width:120px;height:120px}.social-size-160 svg{width:160px;height:160px}.social-size-200 svg{width:200px;height:200px}.social-size-240 svg{width:240px;height:240px} @media(prefers-reduced-motion:reduce){html.motion-respect .block-social a{transition:none}html.motion-respect .block-social a:hover{transform:none}} + +/* Druck: eine Seite, die man mitnimmt. Kein Kopf mit Menü, keine Einwilligung, kein Nach-oben, + keine Formularknöpfe; Verweise zeigen ihre Adresse, Bilder bleiben auf der Seite. */ +@media print{ + .kant{background:#fff;color:#000} + .site-header nav,.menu-toggle,.skip-link,.scroll-top,.consent-bar,.consent-banner,.block-form .cms-button,.form-step-nav,.form-progress,.post-pager,.lightbox,.slider-controls,.slider-dots,.tabs-nav,.cf-turnstile{display:none!important} + .site-header,.site-footer{border:0} + .site-header.is-sticky,.site-header.is-overlay,.site-header.is-overlay.is-sticky{position:static;background:transparent;box-shadow:none} + a[href^="http"]::after{content:" (" attr(href) ")";font-size:.85em;color:#444} + .block-section{padding:16px 0;break-inside:avoid} + img{max-width:100%;break-inside:avoid} + .tone-ink,.tone-mint{background:transparent;color:#000} + [data-step][hidden],.block-form [hidden]{display:block!important} +} diff --git a/scripts/browser-check.mjs b/scripts/browser-check.mjs index 3f0bd34..39e19d4 100644 --- a/scripts/browser-check.mjs +++ b/scripts/browser-check.mjs @@ -80,10 +80,13 @@ try { assert.ok(html < 150000, `${path}: HTML ${html} Bytes, Obergrenze 150000`); assert.ok(styles < 120000, `${path}: CSS ${styles} Bytes, Obergrenze 120000`); // 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. + // ihrem eigenen Kopf: nur wenn die Seite Skripte erlaubt (Reiter, Slider, Einblendungen, + // Formular mit Schritten, eigener Code), darf überhaupt eines kommen. Jede Fähigkeit + // bringt genau eine kleine Datei mit; drei davon sind die Grenze, und alle vom eigenen Ursprung. 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(erlaubt ? scripts <= 3 : 0 === scripts, `${path}: ${scripts} Skript(e) geladen, erlaubt ${erlaubt ? 3 : 0}`); + const fremd = [...transferred.keys()].filter(url => (transferred.get(url).type.includes('javascript')) && !url.startsWith(origin + '/assets/')); + assert.deepEqual(fremd, [], `${path}: Skript von außerhalb /assets/`); 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`); @@ -113,7 +116,12 @@ try { // 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.ok(darfSkripte ? laufend <= 3 : 0 === laufend, `${path}: ${laufend} Skript(e), erlaubt ${darfSkripte ? 3 : 0}`); + // Einblendungen zu Ende spielen, bevor gemessen wird: mitten in einer Einblendung ist ein + // Block halb durchsichtig oder verschoben. axe rechnet den Kontrast dann gegen die + // verwaschene Farbe, und ein von der Seite hereingleitender Block zählt als Überlauf - + // beides Befunde, die kein Besucher je sieht. + await page.evaluate(() => document.getAnimations().forEach(animation => animation.finish())); assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'Horizontal overflow'); const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'best-practice']).analyze(); diff --git a/scripts/build-design.mjs b/scripts/build-design.mjs index d0b1faa..c4e07fd 100644 --- a/scripts/build-design.mjs +++ b/scripts/build-design.mjs @@ -7,7 +7,7 @@ import { readFile, writeFile } from 'node:fs/promises'; */ export function designCss(tokens) { const rules = device => Object.entries(tokens).flatMap(([key, token]) => Object.entries(token.values).map(([value, [, css]]) => `.kant .d-${device}-${key}-${value}{${css}}`)).join('\n'); - return `/* Generated from design-tokens.json by npm run build:design. */\n${rules('desktop')}\n@media(max-width:1024px){\n${rules('tablet')}\n}\n@media(max-width:650px){\n${rules('mobile')}\n}\n`; + return `/* Generated from design-tokens.json by npm run build:design. */\n${rules('desktop')}\n@media(max-width:1024px){\n${rules('tablet')}\n}\n@media(max-width:767px){\n${rules('mobile')}\n}\n`; } if (process.argv[1]?.replaceAll('\\', '/').endsWith('/build-design.mjs')) { const tokens = JSON.parse(await readFile(new URL('../public/assets/design-tokens.json', import.meta.url), 'utf8')); diff --git a/scripts/build-release.php b/scripts/build-release.php index 38e5ba1..8c9e458 100644 --- a/scripts/build-release.php +++ b/scripts/build-release.php @@ -67,7 +67,7 @@ echo "Fassung {$version}\n"; // migrations.php und cli-config.php gehoeren dazu: ohne die erste findet Doctrine beim // Einrichten keine Migrationsliste und bricht mit "configuration file does not exist" ab - genau // dort, wo eine frische Installation ihre Tabellen anlegen will. -$include = ['bin', 'config', 'migrations', 'public', 'src', 'templates', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION', 'README.md', '.env.example']; +$include = ['bin', 'config', 'migrations', 'plugins', 'public', 'src', 'templates', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION', 'README.md', '.env.example']; $skip = ['.git', '.github', '.claude', '.gitattributes', '.gitignore', '.env.lock', '.php-cs-fixer.cache', 'node_modules', 'var', 'tests', 'scripts', 'reports', 'phpunit.xml.dist', '.php-cs-fixer.dist.php', 'package.json', 'package-lock.json', 'AGENTS.md', 'ENTWICKLUNGSPLAN.md', 'docs', 'composer.phar', '.venv', 'vendor', 'phpunit.cache', 'migrations.php.bak']; $copy = static function (string $from, string $to) use (&$copy, $skip): int { diff --git a/scripts/editor-check.mjs b/scripts/editor-check.mjs index 7e9027f..a566c47 100644 --- a/scripts/editor-check.mjs +++ b/scripts/editor-check.mjs @@ -37,7 +37,12 @@ try { activePage = page; const errors = []; page.on('pageerror', error => { errors.push(error.message); console.error('Editor script error:', error.message); }); - page.on('console', message => { if (message.type() === 'error' && /Content Security Policy/.test(message.text())) errors.push(message.text()); }); + page.on('console', message => { + if (message.type() === 'error' && /Content Security Policy/.test(message.text())) errors.push(message.text()); + // Jede Fehlermeldung der Konsole ins Protokoll: ein Editor, der bei „Wird geladen“ stehen + // bleibt, sagt sonst nichts darüber, warum. + if (message.type() === 'error') console.error('Console error:', message.text()); + }); page.on('dialog', dialog => dialog.accept()); // Jede angeforderte Skriptdatei mitschreiben. `public/.htaccess` laesst .js ein Jahr lang als // `immutable` zwischenspeichern; eine Datei ohne Fassungskennung waere damit nach einem Update @@ -839,6 +844,102 @@ try { await page.waitForFunction(() => document.querySelector('#property-title')?.value === 'Parallel gespeicherter Titel'); checks.push({ name: 'conflict retains local edits, JSON export, explicit server reload', passed: true }); + // --- Plugins: einschalten, Baustein einfuegen, Eigenschaften aus dem Schema, Vorschau ------ + // Das Referenzplugin "Bildvergleich" liegt im Repository. Aus ist es zuerst; eingeschaltet + // erscheint sein Baustein im Werkzeugkasten, das Panel entsteht aus dem Manifest, und die + // Vorschau laedt sein Stylesheet - das Skript nicht, wie bei eigenem Code. + await page.goto(origin + '/admin/plugins'); + await axe('plugins'); + await page.locator('article').filter({ hasText: 'Bildvergleich' }).getByRole('button', { name: 'Einschalten', exact: true }).click(); + await page.locator('.notice.ok').filter({ hasText: 'eingeschaltet' }).waitFor(); + await page.screenshot({ path: 'reports/editor/plugins.png', fullPage: true }); + await page.goto(origin + '/admin/pages/test-home'); + await tab('Struktur'); + await page.locator('#structure [data-block-id="headline"]').waitFor(); + await treeSelect('headline'); + await addFromLibrary('[data-add="vergleich.bildvergleich"]'); + await page.locator('#properties-title').filter({ hasText: 'Bildvergleich' }).waitFor(); + await page.getByLabel('Beschriftung vorher', { exact: true }).fill('Alt'); + await page.getByLabel('Startposition des Reglers (%)', { exact: true }).fill('30'); + for (const label of ['Bild vorher', 'Bild nachher']) { + await page.locator('.plugin-media-field').filter({ hasText: label }).getByRole('button', { name: 'Bild aus Mediathek wählen', exact: true }).click(); + await page.locator('#media-dialog [data-media-id]').first().click(); + await page.locator('#media-insert').click(); + await page.locator('.plugin-media-field').filter({ hasText: label }).getByRole('button', { name: 'Anderes Bild wählen', exact: true }).waitFor(); + } + await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor(); + await page.waitForFunction(() => { + const doc = document.querySelector('#preview').contentDocument; + return Boolean(doc.querySelector('.block-bildvergleich input[type="range"][value="30"]')) && Boolean(doc.querySelector('link[href^="/plugin-assets/vergleich/assets/bildvergleich.css"]')) && !doc.querySelector('script[src^="/plugin-assets/"]'); + }); + await page.screenshot({ path: 'reports/editor/plugin-block.png', fullPage: true }); + await page.locator('#save').click(); + await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor(); + await page.locator('#publish').click(); await page.locator('#confirm-publish').click(); + await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor(); + { + const antwort = await page.request.get(origin + '/'); + const html = await antwort.text(); + assert.match(html, /class="block-bildvergleich bildvergleich-rund/, 'Plugin-Baustein auf der Website'); + assert.match(html, /{% endif %} - -{% set consentOpen = not editing|default(false) and consentGate.needsBanner(app.request) %} - - - {% if areas.header|default(null) %} -
- {% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.header.schemaVersion|default(1) >= 12, richHeadings: areas.header.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %} -
- {% endif %} -
-
-
- {% if term %}

{{ term.kind == 'category' ? 'Kategorie' : 'Schlagwort' }}

{% endif %} -

{{ heading }}

-

{{ result.total }} {{ result.total == 1 ? 'Beitrag' : 'Beiträge' }}{% if result.pages > 1 %} · Seite {{ result.page }} von {{ result.pages }}{% endif %}

-
-
-
-
- {% include 'blocks/post-list.html.twig' with {posts: result.posts} only %} - {% if result.pages > 1 %} - - {% endif %} -
-
-
- {% if areas.footer|default(null) %} -
- {% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.footer.schemaVersion|default(1) >= 12, richHeadings: areas.footer.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %} -
- {% endif %} - {% if not editing|default(false) and consentGate.needsBanner(app.request) %} - {% include 'consent-banner.html.twig' with {consent: consentGate.state(app.request), back: app.request.pathInfo} only %} - {% endif %} - - diff --git a/templates/blocks/block.html.twig b/templates/blocks/block.html.twig index eff711b..95d9db0 100644 --- a/templates/blocks/block.html.twig +++ b/templates/blocks/block.html.twig @@ -79,11 +79,15 @@ {{ props.buttonText }} {% elseif type == 'contact' %} + {% if not (props.phone|default('') or props.email|default('') or props.address|default('')) %} + {% if editing|default(false) %}

Kontaktdaten: Telefon, E-Mail oder Adresse unter Inhalt eintragen.

{% endif %} + {% else %}
{% if props.phone|default('') %}{% include 'blocks/icon.html.twig' with {name: 'phone'} only %}{{ props.phone }}{% endif %} {% if props.email|default('') %}{% include 'blocks/icon.html.twig' with {name: 'mail'} only %}{{ props.email }}{% endif %} {% if props.address|default('') %}{% include 'blocks/icon.html.twig' with {name: 'pin'} only %}{{ props.address }}{% endif %}
+ {% endif %} {% elseif type == 'divider' %}
{% elseif type == 'spacer' %} @@ -114,7 +118,9 @@ {# A linked section: its blocks come from the partial, resolved by the controller. One change there reaches every page that links it, which is the difference to a copied section. #} {% set section = sections[props.partial]|default(null) %} - {% if section %} + {% if props.partial|default('') == '' %} + {% if editing|default(false) %}

Verknüpfte Sektion: unter Inhalt die Adresse einer Sektion eintragen.

{% endif %} + {% elseif section %}
{% for child in section.blocks %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
@@ -135,9 +141,13 @@ {% endif %} {% elseif type == 'form' %} + {% if props.form|default('') == '' %} + {% if editing|default(false) %}

Formular: unter Inhalt die Adresse eines Formulars eintragen (siehe Verwaltung → Formulare).

{% endif %} + {% else %}
- {% include 'blocks/form.html.twig' with (forms[block.id]|default({form: null, sent: false, errors: {}, values: {}, blockId: block.id, page: {path: ''}, issued: 0, issuedSignature: ''})) only %} + {% include 'blocks/form.html.twig' with (forms[block.id]|default({form: null, sent: false, errors: {}, values: {}, optionImages: {}, blockId: block.id, page: {path: ''}, issued: 0, issuedSignature: ''})) only %}
+ {% endif %} {% elseif type == 'tabs' %} {# Without the script this is an anchor list followed by every panel: complete and readable. tabs.js turns it into a tab widget; it only changes presentation, never content. #} @@ -213,6 +223,9 @@ {% endfor %} {% elseif type == 'embed' %} + {% if props.url|default('') == '' %} + {% if editing|default(false) %}

Externer Inhalt: unter Inhalt Adresse, Titel und Anbieter eintragen.

{% endif %} + {% else %} {# Content from a third party. Until the group is granted, nothing of it is requested - no iframe, no image, no preconnect. That is the whole point of the consent question: the placeholder is not a nicety, it is what keeps the promise. @@ -241,6 +254,7 @@ {% endif %} {% endif %} + {% endif %} {% elseif type == 'postlist' %}
{% set feed = postLists[block.id]|default([]) %} @@ -264,6 +278,21 @@
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
+{% elseif kant_plugin_block(type) %} + {# Ein Baustein eines aktiven Plugins. Die Vorlage bekommt nur, was der Kern geprüft hat: + die Eigenschaften nach dem Schema des Manifests. Passen sie nicht, bleibt der Platz leer. #} + {% set plugin = kant_plugin_block(type) %} + {% set pluginProps = kant_plugin_props(block) %} + {% if pluginProps is null %} + + {% if editing|default(false) %}

Baustein „{{ plugin.label }}“: die gespeicherten Eigenschaften passen nicht zum Plugin.

{% endif %} + {% else %} + {# Ein Container rendert seine Kinder hier, mit demselben Kontext wie jeder Kernbaustein, und + reicht sie der Vorlage fertig gerendert als `children` - so kommt eine Plugin-Vorlage nie + an Formulare, Beitragslisten oder die Navigation heran. #} + {% set childrenHtml %}{% if plugin.container %}{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}{% endif %}{% endset %} + {% include plugin.template with {block: block, props: pluginProps, designClasses: designClasses, editing: editing|default(false), plugin: plugin, children: childrenHtml, settings: kant_plugin_settings(plugin.plugin)} only %} + {% endif %} {% else %} {# A block whose plugin is not available. Its stored content stays in the document; the core diff --git a/templates/blocks/form.html.twig b/templates/blocks/form.html.twig index 67eb651..fe24526 100644 --- a/templates/blocks/form.html.twig +++ b/templates/blocks/form.html.twig @@ -1,10 +1,14 @@ -{# Server rendered, no JavaScript. Errors are announced and each message sits at its field. #} +{# Server rendered, no JavaScript needed. Errors are announced and each message sits at its field. + Schritte sind fieldsets: ohne Skript ein langes Formular mit Abschnitten, mit form-steps.js ein + Schritt nach dem anderen. Bedingungen stehen als Datenattribute am Feld; der Server prüft sie + beim Absenden selbst und verlangt nichts, was gar nicht galt. #} {% if form is null %}

Dieses Formular ist nicht mehr vorhanden.

{% elseif sent %}

{{ form.settings.success }}

{% else %} -
+{% set hasSteps = form.fields|filter(f => f.type == 'step')|length > 0 %} + @@ -21,15 +25,49 @@ {% set error = errors[field.name]|default(null) %} {% set value = values[field.name]|default('') %} {% set described = (field.help ? id ~ '-help ' : '') ~ (error ? id ~ '-error' : '') %} -
- {% if field.type == 'consent' %} + {% set condition %}{% if field.showIf|default(null) %} data-show-if="{{ field.showIf.field }}" data-show-values="{{ field.showIf.values|join('|') }}"{% endif %}{% endset %} + {% set requiredMark %}{% if field.required %} (Pflichtfeld){% endif %}{% endset %} + {% if field.type == 'step' %} + {% if not loop.first and form.fields[0:loop.index0]|filter(f => f.type == 'step')|length > 0 %}{% endif %} +
+ {{ field.label }} + {% if field.help %}

{{ field.help }}

{% endif %} + {% elseif field.type == 'consent' %} +
- {% else %} - + {% if field.help %}

{{ field.help }}

{% endif %} + {% if error %}

{{ error }}

{% endif %} +
+ {% elseif field.type == 'radio' or field.type == 'checkboxes' %} + {# Eine Gruppe: die Beschriftung ist die legend, jede Option hat ihr eigenes Feld. Bei + der Mehrfachauswahl steht required nicht am Kästchen - das hieße „alle ankreuzen“ - + sondern an der Gruppe; Skript und Server prüfen es dort. #} + {# Hat eine Option ein Bild, wird die ganze Auswahl zur Bildauswahl: jede Option eine + Karte mit Bild und Text, das Kästchen bleibt darin für Tastatur und Vorlesesoftware. #} + {% set bildauswahl = field.images|default({})|length > 0 %} +
+ {{ field.label }}{{ requiredMark }} + {% set picked = field.type == 'checkboxes' ? value|split(', ') : [value] %} + {% if bildauswahl %}
{% endif %} + {% for option in field.options %} + {% set bild = optionImages[field.images[option]|default('')]|default(null) %} +
+ + +
+ {% endfor %} + {% if bildauswahl %}
{% endif %} + {% if field.help %}

{{ field.help }}

{% endif %} + {% if error %}

{{ error }}

{% endif %} +
+ {% else %} +
+ {% if field.type == 'textarea' %} @@ -39,6 +77,14 @@ {% for option in field.options %}{% endfor %} + {% elseif field.type == 'number' %} +
+ + {% if field.unit|default('') %}{% endif %} +
{% else %} {% endif %} - {% endif %} - {% if field.help %}

{{ field.help }}

{% endif %} - {% if error %}

{{ error }}

{% endif %} -
+ {% if field.help %}

{{ field.help }}

{% endif %} + {% if error %}

{{ error }}

{% endif %} +
+ {% endif %} {% endfor %} + {% if hasSteps %}{% endif %} {% if turnstileKey|default('') %} {# Cloudflare Turnstile. Braucht JavaScript; ohne Skript bleibt das Formular unabsendbar, und genau deshalb ist die Prüfung eine Entscheidung und keine Voreinstellung. #} diff --git a/templates/page.html.twig b/templates/page.html.twig index 6230749..96f4886 100644 --- a/templates/page.html.twig +++ b/templates/page.html.twig @@ -9,6 +9,7 @@ {{ seoTitle }} {% if canonical|default(null) %}{% endif %} + {% if pagination|default(null) %}{% if pagination.page > 1 %}{% endif %}{% if pagination.page < pagination.pages %}{% endif %}{% endif %} {% if page.robots|default('index') != 'index' %}{% endif %} @@ -49,16 +50,20 @@ {% if page.document.schemaVersion >= 2 %}{% endif %} {% if page.document.schemaVersion >= 3 %}{% endif %} {% endif %} + {% for href in pluginStyles|default([]) %}{% endfor %} {% if hasPosts|default(false) %} {% endif %} {% if needsTabs|default(false) %}{% endif %} + {% if needsFormScript|default(false) and not editing|default(false) %}{% endif %} {% if needsSlider|default(false) %}{% endif %} {% if needsMotion|default(false) %}{% endif %} {% if needsScrolled|default(false) %}{% endif %} {% if needsZoom|default(false) %}{% endif %} + {% if not editing|default(false) %}{% for src in pluginScripts|default([]) %}{% endfor %}{% endif %} {# Own scripts of this page, as a file. Never rendered in the editor preview: the preview must show the layout, not run code that could change the page under the editor's hands. #} {% if pageScript|default(null) and not editing|default(false) %}{% endif %} + {% if not editing|default(false) %}{% for src in areaScripts|default([]) %}{% endfor %}{% endif %} {# A data block, not a script: CSP script-src does not apply to application/ld+json. #} {% for block in structuredData|default([]) %} {% endfor %} @@ -78,7 +83,7 @@ instead of being written into this template. #} {% if areas.header|default(null) %}
- {% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.header.schemaVersion|default(1) >= 12, richHeadings: areas.header.schemaVersion|default(1) >= 17, eagerImage: eagerImage|default(null), inHeader: true} only %}{% endfor %} + {% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: areas.header.schemaVersion|default(1) >= 12, richHeadings: areas.header.schemaVersion|default(1) >= 17, eagerImage: eagerImage|default(null), inHeader: true} only %}{% endfor %}
{% endif %}
@@ -94,7 +99,7 @@
{% if areas.footer|default(null) %}
- {% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.footer.schemaVersion|default(1) >= 12, richHeadings: areas.footer.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %} + {% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: areas.footer.schemaVersion|default(1) >= 12, richHeadings: areas.footer.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %}
{% endif %} {% if not editing|default(false) and consentGate.needsBanner(app.request) %} diff --git a/tests/ArchiveUpdateTest.php b/tests/ArchiveUpdateTest.php index 47b4aca..ebe6a51 100644 --- a/tests/ArchiveUpdateTest.php +++ b/tests/ArchiveUpdateTest.php @@ -256,7 +256,9 @@ final class ArchiveUpdateTest extends TestCase // public/ wird Datei für Datei zusammengeführt statt ersetzt, die drei anderen sind // Beiwerk, das eine laufende Installation nicht liest. - $anders = ['public', 'README.md', 'LICENSE', '.env.example']; + // plugins/ ebenso Datei für Datei: mitgelieferte Plugins werden aktualisiert, ein vom + // Betreiber installiertes bleibt stehen (ADR 102). + $anders = ['public', 'plugins', 'README.md', 'LICENSE', '.env.example']; foreach ($liste($mit[1]) as $eintrag) { self::assertContains( $eintrag, diff --git a/tests/BacklogTest.php b/tests/BacklogTest.php new file mode 100644 index 0000000..92dd68e --- /dev/null +++ b/tests/BacklogTest.php @@ -0,0 +1,308 @@ +path = tempnam(sys_get_temp_dir(), 'kant-backlog-'); + $this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->path)); + TestSchema::build($this->db); + $this->db->insert('website', ['id' => 'default', 'name' => 'KantSites', 'default_locale' => 'de', 'theme' => '{}']); + $this->db->insert('page', ['id' => 'page-1', 'slug' => 'home', 'path' => 'home', 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]); + $media = new MediaLibrary($this->db, $this->path.'-media'); + $this->site = new SiteSettings($this->db, $media); + $this->pages = new PageRepository($this->db, new DocumentValidator(), $this->site); + $this->partials = new PartialRepository($this->db, new DocumentValidator()); + $this->posts = new PostRepository($this->db, $media, $this->site); + foreach (DemoContent::areas() as $kind => $area) { + $this->partials->create($kind, 'default', $area['name'], $area['document']); + } + } + + protected function tearDown(): void + { + $this->db->close(); + gc_collect_cycles(); + @unlink($this->path); + } + + private function publishHome(array $document): void + { + $document['schemaVersion'] = 23; + $version = (int) ($this->pages->findForEditor('page-1')['version'] ?? 0); + $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, $version), $version + 1); + } + + private function post(string $slug, string $title, string $date, array $categories = []): string + { + $id = $this->pages->createPage($slug, null, 'post', DemoContent::starter($title, 'post')); + $this->posts->saveMetadata($id, ['publishedAt' => $date], $categories, [], $this->pages->findForEditor($id)['version']); + $seite = $this->pages->findForEditor($id); + $this->pages->publish($id, $seite['draft_revision_id'], $seite['version']); + + return $id; + } + + public function testArchivesRunThroughThePageChainWithFooterSectionsAndPagination(): void + { + for ($i = 1; $i <= 12; ++$i) { + $this->post('beitrag-'.$i, 'Beitrag '.$i, sprintf('2026-01-%02d', $i), ['Projekte']); + } + // Der Fuß verknüpft eine Sektion mit eigenem CSS - das Archiv muss beides zeigen. + $sektion = DemoContent::starter('Fußsektion'); + $sektion['schemaVersion'] = 23; + $sektion['blocks'] = [['id' => 'fuss-hinweis', 'type' => 'text', 'props' => ['text' => 'Aus der Fußsektion'], 'css' => '& p{letter-spacing:0.3em}']]; + $this->partials->create('section', 'fusssektion', 'Fußsektion', $sektion); + $footer = $this->partials->findByKind('footer'); + $doc = $footer['document']; + $doc['schemaVersion'] = 23; + $doc['blocks'][] = ['id' => 'fuss-ref', 'type' => 'partialref', 'props' => ['partial' => 'fusssektion']]; + $this->partials->save($footer['id'], $doc, $footer['version']); + $this->site->save(['country' => 'DE', 'homePath' => 'home', 'scrollTop' => '1', 'scrollTopLabel' => 'Nach oben']); + + $antwort = $this->request('/beitraege'); + $html = $antwort->getContent(); + self::assertSame(200, $antwort->getStatusCode()); + self::assertStringContainsString('', $html); + self::assertStringContainsString('', $html); + self::assertStringNotContainsString('rel="prev"', $html); + self::assertStringContainsString('12 Beiträge', $html); + + $zwei = $this->request('/beitraege?seite=2')->getContent(); + self::assertStringContainsString('', $zwei); + self::assertStringContainsString('', $zwei); + self::assertStringNotContainsString('rel="next"', $zwei); + self::assertStringContainsString('Beitrag 1<', $zwei, 'Die ältesten Beiträge stehen auf der zweiten Seite.'); + + $kategorie = $this->request('/kategorie/projekte')->getContent(); + self::assertStringContainsString('Kategorie', $kategorie); + self::assertStringContainsString('', $kategorie); + self::assertStringContainsString('BreadcrumbList', $kategorie, 'Die Spur Beiträge → Kategorie steht in den strukturierten Daten.'); + self::assertSame(404, $this->request('/kategorie/leer')->getStatusCode()); + } + + public function testAPaginatedPostListOnAPageGetsCanonicalAndPrevNext(): void + { + for ($i = 1; $i <= 5; ++$i) { + $this->post('n-'.$i, 'Notiz '.$i, sprintf('2026-02-%02d', $i)); + } + $document = DemoContent::starter('Neues'); + $document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => ['count' => 2, 'category' => '', 'tag' => '', 'layout' => 'liste', 'paginate' => true]]; + $this->publishHome($document); + $eins = $this->request('/')->getContent(); + self::assertStringContainsString('', $eins); + self::assertStringContainsString('', $eins); + $drei = $this->request('/?seite=3')->getContent(); + self::assertStringContainsString('', $drei); + self::assertStringContainsString('', $drei); + self::assertStringNotContainsString('rel="next"', $drei); + } + + public function testAFormInTheFooterWorksLikeOneOnThePage(): void + { + (new FormRepository($this->db))->save('kontakt', 'Kontakt', [ + ['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true], + ['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Einverstanden'], + ], ['recipient' => 'info@example.org', 'buttonColor' => '#123456'], 0); + $footer = $this->partials->findByKind('footer'); + $doc = $footer['document']; + $doc['schemaVersion'] = 23; + $doc['blocks'][] = ['id' => 'fuss-formular', 'type' => 'form', 'props' => ['form' => 'kontakt']]; + $this->partials->save($footer['id'], $doc, $footer['version']); + $this->publishHome(DemoContent::starter('Start')); + + $html = $this->request('/')->getContent(); + self::assertStringContainsString('request('/formular/kontakt', 'POST', ['page' => 'home', 'block' => 'fuss-formular', 'issued' => (string) $issued, 'issuedSignature' => hash_hmac('sha256', (string) $issued, 'test-only'), 'email' => '', 'datenschutz' => 'ja']); + self::assertSame(422, $antwort->getStatusCode()); + self::assertStringContainsString('Dieses Feld wird benötigt.', $antwort->getContent()); + } + + public function testASectionLinkedTwiceRendersWithoutDuplicateIds(): void + { + $sektion = DemoContent::starter('Band'); + $sektion['schemaVersion'] = 23; + $sektion['blocks'] = [['id' => 'band-sektion', 'type' => 'section', 'anchor' => 'band', 'children' => [['id' => 'band-text', 'type' => 'text', 'props' => ['text' => 'Zweimal da'], 'style' => ['desktop' => ['color' => '#112233']]]]]]; + $this->partials->create('section', 'band', 'Band', $sektion); + $document = DemoContent::starter('Doppelt'); + $document['blocks'][] = ['id' => 'ref-1', 'type' => 'partialref', 'props' => ['partial' => 'band']]; + $document['blocks'][] = ['id' => 'ref-2', 'type' => 'partialref', 'props' => ['partial' => 'band']]; + $this->publishHome($document); + $html = $this->request('/')->getContent(); + self::assertSame(1, substr_count($html, 'id="band-text"')); + self::assertSame(1, substr_count($html, 'id="band-text-2"'), 'Die zweite Verknüpfung bekommt eigene Kennungen.'); + self::assertSame(2, substr_count($html, 'Zweimal da')); + self::assertSame(1, substr_count($html, 'id="band"'), 'Das Sprungziel gibt es nur einmal.'); + self::assertStringContainsString('#band-text{', $html); + self::assertStringContainsString('#band-text-2{', $html, 'Auch die Kopie hat ihren Stil.'); + } + + public function testFreshlyInsertedBlocksAreValidAndRenderNothingPublicly(): void + { + $document = DemoContent::starter('Leer'); + $document['blocks'][] = ['id' => 'k', 'type' => 'contact', 'props' => ['phone' => '', 'email' => '', 'address' => '']]; + $document['blocks'][] = ['id' => 'f', 'type' => 'form', 'props' => ['form' => '']]; + $document['blocks'][] = ['id' => 's', 'type' => 'partialref', 'props' => ['partial' => '']]; + $document['blocks'][] = ['id' => 'e', 'type' => 'embed', 'props' => ['group' => 'external', 'title' => '', 'provider' => '', 'url' => '', 'hint' => '', 'height' => 400]]; + $this->publishHome($document); + $html = $this->request('/')->getContent(); + foreach (['id="k"', 'id="f"', 'id="s"', 'id="e"', 'nicht mehr vorhanden', 'block-unavailable'] as $spur) { + self::assertStringNotContainsString($spur, $html, $spur.' darf öffentlich nicht auftauchen.'); + } + // Mit Inhalt gelten die alten Regeln weiter. + $document['blocks'][array_key_last($document['blocks'])]['props']['url'] = 'http://unsicher.example'; + try { + $this->publishHome($document); + self::fail('Eine Adresse ohne HTTPS bleibt abgelehnt.'); + } catch (InvalidDocument) { + } + $document = DemoContent::starter('Legal'); + $document['blocks'][] = ['id' => 'l', 'type' => 'legal', 'props' => ['label' => str_repeat('x', 61)]]; + try { + $this->publishHome($document); + self::fail('Die Beschriftung der Rechtszeile ist begrenzt.'); + } catch (InvalidDocument) { + } + } + + public function testADarkPaletteSwitchesTheColourSchemeAndTheErrorColours(): void + { + $theme = new Theme($this->db); + $theme->save( + [['key' => 'nacht', 'name' => 'Nacht', 'value' => '#101a2b'], ['key' => 'hell', 'name' => 'Hell', 'value' => '#f4f1ea']], + [['key' => 'body', 'name' => 'Text', 'family' => 'Arial', 'source' => 'system', 'weights' => [400, 700], 'fallback' => 'sans-serif']], + ['fontFamily' => 'body', 'headingFontFamily' => 'body', 'fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '@hell', 'backgroundColor' => '@nacht', 'linkColor' => '@hell', 'containerWidth' => '1180px', 'headingScale' => 1.25, 'linkUnderline' => true], + ); + $this->publishHome(DemoContent::starter('Dunkel')); + $html = $this->request('/')->getContent(); + self::assertStringContainsString('--k-scheme:dark', $html); + self::assertStringContainsString('--k-error:#ff9b8a', $html); + self::assertStringContainsString('--k-error-bg:color-mix(in srgb,#ff9b8a 12%,#101a2b)', $html); + self::assertGreaterThan(0.5, Theme::luminance('#f4f1ea')); + self::assertLessThan(0.1, Theme::luminance('#101a2b')); + // Die Stylesheets kennen keine festen Fehlerfarben und kein festes Weiß mehr. + $css = (string) file_get_contents(dirname(__DIR__).'/public/assets/site.css'); + self::assertStringNotContainsString('color:#8b2f22', $css); + self::assertStringNotContainsString('color:white', $css); + self::assertStringContainsString('color-scheme:var(--k-scheme,light)', $css); + self::assertStringContainsString('@media print', $css); + self::assertStringNotContainsString('max-width:650px', $css, 'Eine Grenze zwischen Tablet und Telefon: 767 px.'); + self::assertStringNotContainsString('max-width:650px', (string) file_get_contents(dirname(__DIR__).'/public/assets/design.css')); + } + + public function testUpdatesLockEachOtherOutAndTheHtaccessKeepsItsEnabledLines(): void + { + $root = sys_get_temp_dir().'/kant-lock-'.bin2hex(random_bytes(4)); + mkdir($root.'/var', 0o775, true); + mkdir($root.'/public', 0o775, true); + try { + $updater = new Updater($root, 'sqlite:///'.$root.'/var/cms.db', new CommandRunner(), $this->db); + $erste = $updater->lock(); + self::assertIsResource($erste); + self::assertNull($updater->lock(), 'Solange die erste Sperre steht, bekommt niemand eine zweite.'); + flock($erste, LOCK_UN); + fclose($erste); + $zweite = $updater->lock(); + self::assertIsResource($zweite); + fclose($zweite); + + // Die alte .htaccess hat HTTPS und HSTS eingeschaltet, das Release liefert sie auskommentiert. + $alt = "RewriteEngine On\nRewriteCond %{HTTPS} !=on\nRewriteCond %{HTTP:X-Forwarded-Proto} !=https\nRewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]\nHeader always set Strict-Transport-Security \"max-age=31536000\"\n"; + $neu = "RewriteEngine On\n# neu im Release\n#RewriteCond %{HTTPS} !=on\n#RewriteCond %{HTTP:X-Forwarded-Proto} !=https\n#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]\n#Header always set Strict-Transport-Security \"max-age=31536000; includeSubDomains\"\n"; + file_put_contents($root.'/public/.htaccess', $alt); + file_put_contents($root.'/var/neu.htaccess', $neu); + $merge = new \ReflectionMethod(ArchiveUpdate::class, 'mergeHtaccess'); + $archiv = (new \ReflectionClass(ArchiveUpdate::class))->newInstanceWithoutConstructor(); + $merge->invoke($archiv, $root.'/var/neu.htaccess', $root.'/public/.htaccess'); + $ergebnis = (string) file_get_contents($root.'/public/.htaccess'); + self::assertStringContainsString("# neu im Release\nRewriteCond %{HTTPS} !=on\nRewriteCond %{HTTP:X-Forwarded-Proto} !=https\nRewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]\nHeader always set Strict-Transport-Security \"max-age=31536000; includeSubDomains\"", $ergebnis, 'Die neue Datei, aber mit den eingeschalteten Zeilen.'); + // Waren sie aus, bleiben sie aus. + file_put_contents($root.'/public/.htaccess', $neu); + $merge->invoke($archiv, $root.'/var/neu.htaccess', $root.'/public/.htaccess'); + self::assertSame($neu, (string) file_get_contents($root.'/public/.htaccess')); + } finally { + @unlink($root.'/public/.htaccess'); + @unlink($root.'/var/neu.htaccess'); + @unlink($root.'/var/update.lock'); + @rmdir($root.'/public'); + @rmdir($root.'/var'); + @rmdir($root); + } + } + + private function request(string $url, string $method = 'GET', array $body = []): Response + { + $previous = []; + foreach (['APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->path), 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media', 'FONT_DIRECTORY' => $this->path.'-fonts', 'ASSET_DIRECTORY' => $this->path.'-assets'] as $key => $value) { + $previous[$key] = [$_SERVER[$key] ?? null, $_ENV[$key] ?? null]; + $_SERVER[$key] = $_ENV[$key] = $value; + } + $kernel = new Kernel('test', false); + try { + $request = Request::create($url, $method, $body); + $response = $kernel->handle($request); + $kernel->terminate($request, $response); + + return $response; + } finally { + $kernel->shutdown(); + foreach ($previous as $key => [$server, $env]) { + if (null === $server) { + unset($_SERVER[$key]); + } else { + $_SERVER[$key] = $server; + } + if (null === $env) { + unset($_ENV[$key]); + } else { + $_ENV[$key] = $env; + } + } + } + } +} diff --git a/tests/CmsTest.php b/tests/CmsTest.php index 462607e..fcb72bb 100644 --- a/tests/CmsTest.php +++ b/tests/CmsTest.php @@ -1969,7 +1969,6 @@ final class CmsTest extends TestCase yield 'iconcard with unsafe link' => $wrap(['id' => 'x', 'type' => 'iconcard', 'props' => ['icon' => 'app', 'title' => 'T', 'text' => '', 'href' => 'javascript:alert(1)']]); yield 'rating out of range' => $wrap(['id' => 'x', 'type' => 'quote', 'props' => ['text' => 'T', 'author' => 'A', 'role' => '', 'rating' => 6]]); yield 'rating not a number' => $wrap(['id' => 'x', 'type' => 'quote', 'props' => ['text' => 'T', 'author' => 'A', 'role' => '', 'rating' => '5']]); - yield 'empty contact block' => $wrap(['id' => 'x', 'type' => 'contact', 'props' => ['phone' => '', 'email' => '', 'address' => '']]); yield 'invalid phone' => $wrap(['id' => 'x', 'type' => 'contact', 'props' => ['phone' => 'ruf mich an', 'email' => '', 'address' => '']]); yield 'invalid email' => $wrap(['id' => 'x', 'type' => 'contact', 'props' => ['phone' => '', 'email' => 'keine-adresse', 'address' => '']]); yield 'unknown spacer size' => $wrap(['id' => 'x', 'type' => 'spacer', 'props' => ['size' => 'gigantic']]); diff --git a/tests/EditorSecurityTest.php b/tests/EditorSecurityTest.php index 0eb26a4..031e9d6 100644 --- a/tests/EditorSecurityTest.php +++ b/tests/EditorSecurityTest.php @@ -160,6 +160,8 @@ final class EditorSecurityTest extends WebTestCase ['publisher', 'GET', '/admin/system'], ['publisher', 'POST', '/admin/system/wartung'], ['publisher', 'POST', '/admin/system/sicherung'], ['publisher', 'GET', '/admin/aufgaben'], ['publisher', 'POST', '/admin/aufgaben/ausfuehren'], ['publisher', 'GET', '/admin/benutzer'], ['publisher', 'POST', '/admin/benutzer/neu'], + // Ein Plugin bringt Code auf die Website: Administrator. + ['publisher', 'GET', '/admin/plugins'], ['publisher', 'POST', '/admin/plugins/vergleich/an'], ['publisher', 'POST', '/admin/plugins/vergleich/einstellungen'], ['publisher', 'POST', '/admin/plugins/nachtblau/design'], ['publisher', 'POST', '/admin/pages/p1/delete'], // Alles, was sofort auf der Website steht: Veroeffentlicher. ['editor', 'GET', '/admin/settings'], ['editor', 'POST', '/admin/settings/redirects'], ['editor', 'POST', '/admin/settings/rechtliches'], ['editor', 'POST', '/admin/settings/not-found'], @@ -180,7 +182,7 @@ final class EditorSecurityTest extends WebTestCase $crawler = $client->request('GET', '/admin'); self::assertResponseIsSuccessful(); $links = $crawler->filter('.nav-item')->each(static fn ($node): string => (string) $node->attr('href')); - foreach (['/admin/partials', '/admin/menus', '/admin/forms', '/admin/theme', '/admin/settings', '/admin/system', '/admin/benutzer'] as $hidden) { + foreach (['/admin/partials', '/admin/menus', '/admin/forms', '/admin/theme', '/admin/settings', '/admin/system', '/admin/plugins', '/admin/benutzer'] as $hidden) { self::assertNotContains($hidden, $links, $hidden.' gehoert nicht in die Navigation eines Redakteurs.'); } self::assertContains('/admin/media', $links); diff --git a/tests/FormStepsTest.php b/tests/FormStepsTest.php new file mode 100644 index 0000000..ae1bc5b --- /dev/null +++ b/tests/FormStepsTest.php @@ -0,0 +1,351 @@ +path = tempnam(sys_get_temp_dir(), 'kant-form-'); + $this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->path)); + TestSchema::build($this->db); + $this->db->insert('website', ['id' => 'default', 'name' => 'KantSites', 'default_locale' => 'de', 'theme' => '{}']); + $this->db->insert('page', ['id' => 'page-1', 'slug' => 'home', 'path' => 'home', 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]); + $settings = new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')); + $this->pages = new PageRepository($this->db, new DocumentValidator(), $settings); + $this->forms = new FormRepository($this->db); + $partials = new PartialRepository($this->db, new DocumentValidator()); + foreach (DemoContent::areas() as $kind => $area) { + $partials->create($kind, 'default', $area['name'], $area['document']); + } + } + + protected function tearDown(): void + { + $this->db->close(); + gc_collect_cycles(); + @unlink($this->path); + } + + /** Ein kleiner Preisfinder: Objektart, davon abhängige Eckdaten, ein bedingter Schritt, Kontakt. */ + private function preisfinder(): array + { + return [ + ['name' => 'schritt_objekt', 'type' => 'step', 'label' => 'Ihre Immobilie', 'help' => 'Womit können wir helfen?'], + ['name' => 'immobilienart', 'type' => 'radio', 'label' => 'Objektart', 'required' => true, 'options' => ['Wohnung', 'Haus', 'Grundstück']], + ['name' => 'schritt_eckdaten', 'type' => 'step', 'label' => 'Eckdaten'], + ['name' => 'wohnflaeche', 'type' => 'number', 'label' => 'Wohnfläche', 'required' => true, 'min' => 10, 'max' => 2000, 'unit' => 'm²', 'showIf' => ['field' => 'immobilienart', 'values' => ['Wohnung', 'Haus']]], + ['name' => 'ausstattung', 'type' => 'checkboxes', 'label' => 'Ausstattung', 'required' => true, 'options' => ['Balkon', 'Garage', 'Keller'], 'showIfField' => 'immobilienart', 'showIfValues' => 'Wohnung, Haus'], + ['name' => 'schritt_grund', 'type' => 'step', 'label' => 'Grundstück', 'showIf' => ['field' => 'immobilienart', 'values' => ['Grundstück']]], + ['name' => 'flaeche', 'type' => 'number', 'label' => 'Grundstücksfläche', 'required' => true, 'min' => 10, 'unit' => 'm²'], + ['name' => 'schritt_kontakt', 'type' => 'step', 'label' => 'Kontakt'], + ['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true], + ['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Einverstanden'], + ]; + } + + private function settings(): array + { + return ['recipient' => 'deeg@example.org', 'subject' => 'Bewertung', 'replyToField' => 'email', 'success' => 'Danke.', 'summary' => true]; + } + + public function testStepsConditionsAndNumbersAreNormalisedWhenSaving(): void + { + $this->forms->save('preisfinder', 'Preisfinder', $this->preisfinder(), $this->settings(), 0); + $form = $this->forms->findBySlug('preisfinder'); + $fields = array_column($form['fields'], null, 'name'); + + // Beide Schreibweisen der Bedingung landen in derselben gespeicherten Form. + self::assertSame(['field' => 'immobilienart', 'values' => ['Wohnung', 'Haus']], $fields['wohnflaeche']['showIf']); + self::assertSame(['field' => 'immobilienart', 'values' => ['Wohnung', 'Haus']], $fields['ausstattung']['showIf']); + self::assertSame(['field' => 'immobilienart', 'values' => ['Grundstück']], $fields['schritt_grund']['showIf']); + self::assertNull($fields['email']['showIf']); + // Grenzen sind Zahlen, die Einheit bleibt; ein Schritt fragt nichts und ist nie Pflicht. + self::assertEquals(10, $fields['wohnflaeche']['min']); + self::assertEquals(2000, $fields['wohnflaeche']['max']); + self::assertSame('m²', $fields['wohnflaeche']['unit']); + self::assertNull($fields['flaeche']['max']); + self::assertFalse($fields['schritt_objekt']['required']); + self::assertTrue($form['settings']['summary']); + self::assertTrue($this->forms->needsScript($form)); + + // Ein gewöhnliches Kontaktformular braucht das Skript nicht - es hat weder Schritte noch + // Bedingungen, und ohne Zusammenfassung bleibt die Seite ohne JavaScript. + $this->forms->save('kontakt', 'Kontakt', [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true]], ['recipient' => 'deeg@example.org'], 0); + $kontakt = $this->forms->findBySlug('kontakt'); + self::assertFalse($this->forms->needsScript($kontakt)); + self::assertFalse($kontakt['settings']['summary']); + self::assertNull($kontakt['fields'][0]['showIf'], 'Auch ohne Angabe kennt jedes Feld seine Schlüssel.'); + } + + public function testABrokenDefinitionIsRefusedWithAReason(): void + { + $faelle = [ + 'Bedingung auf ein unbekanntes Feld' => [ + ['name' => 'a', 'type' => 'text', 'label' => 'A', 'showIfField' => 'art', 'showIfValues' => 'Haus'], + ], + 'Bedingung auf ein Feld, das erst danach kommt' => [ + ['name' => 'a', 'type' => 'text', 'label' => 'A', 'showIfField' => 'art', 'showIfValues' => 'Haus'], + ['name' => 'art', 'type' => 'select', 'label' => 'Art', 'options' => ['Haus', 'Wohnung']], + ], + 'Bedingung auf ein Textfeld' => [ + ['name' => 'art', 'type' => 'text', 'label' => 'Art'], + ['name' => 'a', 'type' => 'text', 'label' => 'A', 'showIfField' => 'art', 'showIfValues' => 'Haus'], + ], + 'Bedingung mit einem Wert, der keine Option ist' => [ + ['name' => 'art', 'type' => 'select', 'label' => 'Art', 'options' => ['Haus', 'Wohnung']], + ['name' => 'a', 'type' => 'text', 'label' => 'A', 'showIfField' => 'art', 'showIfValues' => 'Schloss'], + ], + 'Bedingung ohne Wert' => [ + ['name' => 'art', 'type' => 'select', 'label' => 'Art', 'options' => ['Haus', 'Wohnung']], + ['name' => 'a', 'type' => 'text', 'label' => 'A', 'showIfField' => 'art', 'showIfValues' => ''], + ], + 'Untergrenze über der Obergrenze' => [ + ['name' => 'n', 'type' => 'number', 'label' => 'N', 'min' => '10', 'max' => '5'], + ], + 'Grenze, die keine Zahl ist' => [ + ['name' => 'n', 'type' => 'number', 'label' => 'N', 'min' => 'zehn'], + ], + 'Optionsfelder mit einer Option' => [ + ['name' => 'r', 'type' => 'radio', 'label' => 'R', 'options' => ['Ja']], + ], + 'Option mit Komma' => [ + ['name' => 'r', 'type' => 'radio', 'label' => 'R', 'options' => ['ja, neu', 'ja, alt']], + ], + 'nur Schritte' => [ + ['name' => 's', 'type' => 'step', 'label' => 'Schritt'], + ], + 'Bild für eine Option, die es nicht gibt' => [ + ['name' => 'r', 'type' => 'radio', 'label' => 'R', 'options' => ['Haus', 'Wohnung'], 'images' => ['Schloss' => str_repeat('a', 32)]], + ], + 'Bild, das keine Kennung ist' => [ + ['name' => 'r', 'type' => 'radio', 'label' => 'R', 'options' => ['Haus', 'Wohnung'], 'images' => 'Haus = bild.png'], + ], + ]; + foreach ($faelle as $fall => $fields) { + try { + $this->forms->save('f', 'F', $fields, ['recipient' => 'a@example.org'], 0); + self::fail($fall.' wurde angenommen.'); + } catch (InvalidForm $e) { + self::assertNotSame('', $e->getMessage(), $fall); + } + } + } + + public function testOnlyFieldsWhoseConditionHoldsAreRequiredCheckedAndKept(): void + { + $this->forms->save('preisfinder', 'Preisfinder', $this->preisfinder(), $this->settings(), 0); + $form = $this->forms->findBySlug('preisfinder'); + + // Ein Grundstück: Wohnfläche und Ausstattung gelten nicht - nicht verlangt, nicht + // gespeichert, auch wenn der Browser etwas mitschickt. Der Grundstücksschritt gilt. + $grund = $this->forms->check($form, ['immobilienart' => 'Grundstück', 'wohnflaeche' => '80', 'ausstattung' => ['Balkon'], 'flaeche' => '1250.00', 'email' => 'x@example.org', 'datenschutz' => 'ja']); + self::assertSame([], $grund['errors']); + self::assertSame('', $grund['values']['wohnflaeche']); + self::assertSame('', $grund['values']['ausstattung']); + self::assertSame('1250', $grund['values']['flaeche'], 'Eine Zahl wird ohne unnötige Stellen gespeichert.'); + self::assertArrayNotHasKey('schritt_objekt', $grund['values'], 'Ein Schritt ist keine Antwort.'); + + // Eine Wohnung: jetzt sind Wohnfläche und Ausstattung Pflicht, der Grundstücksschritt nicht. + $leer = $this->forms->check($form, ['immobilienart' => 'Wohnung', 'email' => 'x@example.org', 'datenschutz' => 'ja']); + self::assertSame('Dieses Feld wird benötigt.', $leer['errors']['wohnflaeche']); + self::assertSame('Bitte wähle mindestens eine Option.', $leer['errors']['ausstattung']); + self::assertArrayNotHasKey('flaeche', $leer['errors']); + + // Zahlen mit Komma, außerhalb der Grenzen, keine Zahl; Mehrfachauswahl mit fremder Option. + $wohnung = $this->forms->check($form, ['immobilienart' => 'Wohnung', 'wohnflaeche' => '85,5', 'ausstattung' => ['Balkon', 'Keller'], 'email' => 'x@example.org', 'datenschutz' => 'ja']); + self::assertSame([], $wohnung['errors']); + self::assertSame('85.5', $wohnung['values']['wohnflaeche']); + self::assertSame('Balkon, Keller', $wohnung['values']['ausstattung']); + self::assertSame('', $wohnung['values']['flaeche']); + + $zuViel = $this->forms->check($form, ['immobilienart' => 'Haus', 'wohnflaeche' => '5000', 'ausstattung' => ['Garage', 'Pool'], 'email' => 'x@example.org', 'datenschutz' => 'ja']); + self::assertSame('Bitte gib einen Wert zwischen 10 und 2000 an.', $zuViel['errors']['wohnflaeche']); + self::assertSame('Bitte wähle nur aus den angebotenen Optionen.', $zuViel['errors']['ausstattung']); + self::assertSame('Garage', $zuViel['values']['ausstattung'], 'Die fremde Option bleibt weg.'); + + $keineZahl = $this->forms->check($form, ['immobilienart' => 'Grundstück', 'flaeche' => 'groß', 'email' => 'x@example.org', 'datenschutz' => 'ja']); + self::assertSame('Bitte gib eine Zahl an.', $keineZahl['errors']['flaeche']); + $zuKlein = $this->forms->check($form, ['immobilienart' => 'Grundstück', 'flaeche' => '3', 'email' => 'x@example.org', 'datenschutz' => 'ja']); + self::assertSame('Bitte gib mindestens 10 an.', $zuKlein['errors']['flaeche']); + + // Eine Objektart, die es nicht gibt: abgelehnt, und nichts hängt davon ab. + $fremd = $this->forms->check($form, ['immobilienart' => 'Schloss', 'email' => 'x@example.org', 'datenschutz' => 'ja']); + self::assertSame('Bitte wähle eine der angebotenen Optionen.', $fremd['errors']['immobilienart']); + self::assertArrayNotHasKey('wohnflaeche', $fremd['errors']); + } + + public function testAStepFormIsRenderedAsFieldsetsAndBringsItsScript(): void + { + $this->forms->save('preisfinder', 'Preisfinder', $this->preisfinder(), $this->settings(), 0); + $document = DemoContent::starter('Bewertung'); + $document['schemaVersion'] = 23; + $document['blocks'][] = ['id' => 'preis', 'type' => 'form', 'props' => ['form' => 'preisfinder']]; + $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1); + + $antwort = $this->request('/'); + $html = $antwort->getContent(); + self::assertSame(200, $antwort->getStatusCode()); + // Schritte sind Abschnitte mit Titel; ohne Skript stehen sie untereinander. + self::assertSame(4, substr_count($html, '
Ihre Immobilie', $html); + self::assertStringContainsString('

Womit können wir helfen?

', $html); + self::assertStringContainsString('data-steps data-summary>', $html); + // Bedingungen stehen am Feld und am Schritt, für das Skript - der Server prüft sie selbst. + self::assertStringContainsString('data-show-if="immobilienart" data-show-values="Wohnung|Haus"', $html); + self::assertStringContainsString('
', $html); + // Optionsfelder, Mehrfachauswahl und Zahl mit Einheit. + self::assertStringContainsString('type="radio" value="Wohnung" required', $html); + self::assertStringContainsString('name="ausstattung[]" type="checkbox" value="Balkon"', $html); + self::assertStringContainsString('data-required', $html); + self::assertStringContainsString('type="number" step="any" inputmode="decimal"', $html); + self::assertStringContainsString('min="10"', $html); + self::assertStringContainsString('', $html); + // Das Skript kommt nur mit diesem Formular, und die Richtlinie erlaubt es dann. + self::assertStringContainsString('', $html); + self::assertStringContainsString("script-src 'self'", (string) $an->headers->get('Content-Security-Policy')); + self::assertSame(1, substr_count($html, 'bildvergleich.css'), 'Jede Datei einmal, auch wenn der Baustein mehrfach vorkäme.'); + + // Die Dateien kommen über die eigene Route, mit Typ und langer Haltbarkeit; anderes nicht. + $css = $this->request('/plugin-assets/vergleich/assets/bildvergleich.css'); + self::assertSame(200, $css->getStatusCode()); + self::assertSame('text/css; charset=UTF-8', $css->headers->get('Content-Type')); + self::assertStringContainsString('immutable', (string) $css->headers->get('Cache-Control')); + self::assertSame(404, $this->request('/plugin-assets/vergleich/plugin.json')->getStatusCode(), 'Das Manifest ist keine öffentliche Datei.'); + self::assertSame(404, $this->request('/plugin-assets/vergleich/assets/../plugin.json')->getStatusCode()); + self::assertSame(404, $this->request('/plugin-assets/vergleich/blocks/bildvergleich.html.twig')->getStatusCode()); + + // Ohne Bilder rendert die Vorlage einen leeren Platzhalter und keine Bilder von anderswo. + $this->plugins->deactivate('vergleich'); + self::assertSame(404, $this->request('/plugin-assets/vergleich/assets/bildvergleich.css')->getStatusCode(), 'Die Dateien eines ausgeschalteten Plugins gibt es nicht.'); + } + + public function testDesignTokensAndOwnStylesApplyToPluginBlocks(): void + { + $this->plugins->activate('vergleich'); + $document = $this->vergleich(['position' => 50]); + $letzter = array_key_last($document['blocks']); + $document['blocks'][$letzter]['layout'] = ['design' => ['desktop' => ['marginTop' => 'large']], 'effects' => ['entrance' => 'fade', 'delay' => 'none']]; + $document['blocks'][$letzter]['style'] = ['desktop' => ['maxWidth' => '600px']]; + $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1); + $html = $this->request('/')->getContent(); + self::assertStringContainsString('class="block-bildvergleich bildvergleich-rund bildvergleich-griff-pfeile d-desktop-marginTop-large fx-entrance-fade fx-delay-none"', $html); + self::assertStringContainsString('#v1{', $html); + } + + public function testSettingsAreValidatedStoredAndHandedToTemplatesAndRoutes(): void + { + $this->plugins->activate('vergleich'); + self::assertSame(['labelBefore' => 'Vorher', 'labelAfter' => 'Nachher', 'handle' => 'pfeile'], $this->plugins->settingsOf('vergleich'), 'Ohne gespeicherte Werte gelten die Vorgaben des Manifests.'); + + // Aus der Verwaltung kommen Zeichenketten; ein leeres Kästchen fehlt ganz. + $this->plugins->saveSettings('vergleich', ['labelBefore' => ' Alt ', 'labelAfter' => 'Neu', 'handle' => 'punkt', 'fremd' => 'weg']); + self::assertSame(['labelBefore' => 'Alt', 'labelAfter' => 'Neu', 'handle' => 'punkt'], (new PluginRegistry($this->db, $this->projectDir))->settingsOf('vergleich')); + try { + $this->plugins->saveSettings('vergleich', ['handle' => 'kugel']); + self::fail('Ein Wert außerhalb der Auswahl muss abgewiesen werden.'); + } catch (\InvalidArgumentException $e) { + self::assertStringContainsString('Griff des Reglers', $e->getMessage()); + } + + // Die Vorlage nimmt die Einstellung als Standard, wenn der Baustein keine eigene hat. + $bild = $this->uploadImage(); + $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $this->vergleich(['before' => $bild['id'], 'after' => $bild['id']]), 0), 1); + $html = $this->request('/')->getContent(); + self::assertStringContainsString('bildvergleich-griff-punkt', $html); + self::assertStringContainsString('>Alt', $html); + self::assertStringContainsString('>Neu', $html); + + // Die eigene Adresse antwortet mit den Einstellungen als JSON - und nur, solange das Plugin an ist. + $daten = $this->request('/vergleich/daten?stadt=Freudenstadt&=1'); + self::assertSame(200, $daten->getStatusCode()); + self::assertSame('application/json', $daten->headers->get('Content-Type')); + self::assertSame('noindex', $daten->headers->get('X-Robots-Tag')); + self::assertStringContainsString("script-src 'none'", (string) $daten->headers->get('Content-Security-Policy')); + $json = json_decode((string) $daten->getContent(), true); + self::assertSame(['before' => 'Alt', 'after' => 'Neu'], $json['labels']); + self::assertSame(['stadt' => 'Freudenstadt'], $json['echo'], 'Nur saubere Parameternamen erreichen die Vorlage.'); + $this->plugins->deactivate('vergleich'); + self::assertSame(404, $this->request('/vergleich/daten')->getStatusCode()); + } + + public function testAContainerBlockRendersItsChildrenAndOnlyTheAllowedOnes(): void + { + $this->plugins->activate('vergleich'); + $bild = $this->uploadImage(); + $kind = static fn (string $id) => ['id' => $id, 'type' => 'vergleich.bildvergleich', 'props' => ['before' => $bild['id'], 'after' => $bild['id'], 'position' => 40]]; + $document = DemoContent::starter('Galerie'); + $document['schemaVersion'] = 23; + $document['blocks'][] = ['id' => 'g', 'type' => 'vergleich.galerie', 'props' => ['columns' => '3', 'title' => 'Unsere Arbeiten'], 'children' => [$kind('k1'), $kind('k2')]]; + $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1); + $html = $this->request('/')->getContent(); + self::assertStringContainsString('
', $html); + self::assertStringContainsString('

Unsere Arbeiten

', $html); + self::assertSame(2, substr_count($html, 'class="block-bildvergleich'), 'Die Kinder werden vom Kern gerendert und der Vorlage übergeben.'); + self::assertSame(1, substr_count($html, 'bildvergleich.css'), 'Stylesheet einmal, obwohl zwei Bausteine es nennen.'); + + // Ein fremdes Kind wird beim Speichern abgewiesen, ebenso Kinder in einem Nicht-Container. + $version = $this->pages->findForEditor('page-1')['version']; + $fremd = $document; + $fremd['blocks'][count($fremd['blocks']) - 1]['children'][] = ['id' => 't', 'type' => 'text', 'props' => ['text' => 'Hallo']]; + try { + $this->pages->saveDraft('page-1', $fremd, $version); + self::fail('Ein Text gehört nicht in die Vergleichsgalerie.'); + } catch (InvalidDocument $e) { + self::assertStringContainsString('passt nur: vergleich.bildvergleich', $e->getMessage()); + } + $keinContainer = $this->vergleich(['position' => 50]); + $keinContainer['blocks'][count($keinContainer['blocks']) - 1]['children'] = [['id' => 't', 'type' => 'text', 'props' => ['text' => 'Hallo']]]; + try { + $this->pages->saveDraft('page-1', $keinContainer, $version); + self::fail('Ein Bildvergleich nimmt keine Kinder auf.'); + } catch (InvalidDocument $e) { + self::assertStringContainsString('nimmt keine Bausteine auf', $e->getMessage()); + } + + // Der Editor erfährt Container und erlaubte Kinder, und die Vorlage mit ihren Bausteinen. + $editor = array_column($this->plugins->forEditor(), null, 'type'); + self::assertTrue($editor['vergleich.galerie']['container']); + self::assertSame(['vergleich.bildvergleich'], $editor['vergleich.galerie']['children']); + self::assertFalse($editor['vergleich.bildvergleich']['container']); + $presets = $this->plugins->presetsForEditor(); + self::assertSame('plugin:vergleich:abschnitt', $presets[0]['id']); + self::assertSame('section', $presets[0]['blocks'][0]['type']); + } + + public function testSwitchingAPluginOnAndOffLeavesTheDesignAlone(): void + { + // Ein Plugin bringt kein Designpaket mehr mit: Farben, Schriften und Buttons gehören der + // Website und ändern sich nur unter Design. Ein Schalter darf daran nichts drehen. + $theme = new \App\Content\Theme($this->db); + $vorher = $theme->load(); + $document = $this->vergleich(['position' => 50]); + $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1); + + $this->plugins->activate('vergleich'); + self::assertSame($vorher, (new \App\Content\Theme($this->db))->load(), 'Einschalten färbt die Website nicht um.'); + $this->plugins->deactivate('vergleich'); + self::assertSame($vorher, (new \App\Content\Theme($this->db))->load(), 'Ausschalten lässt das Design ebenso in Ruhe.'); + // Und die Inhalte bleiben in beiden Richtungen stehen. + self::assertSame(end($document['blocks']), end($this->pages->findPublished('home')['document']['blocks'])); + } + + private function request(string $url, string $method = 'GET', array $body = []): Response + { + $previous = []; + foreach (['APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->path), 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media', 'FONT_DIRECTORY' => $this->path.'-fonts', 'ASSET_DIRECTORY' => $this->path.'-assets'] as $key => $value) { + $previous[$key] = [$_SERVER[$key] ?? null, $_ENV[$key] ?? null]; + $_SERVER[$key] = $_ENV[$key] = $value; + } + $kernel = new Kernel('test', false); + try { + $request = Request::create($url, $method, $body); + $response = $kernel->handle($request); + $kernel->terminate($request, $response); + + return $response; + } finally { + $kernel->shutdown(); + foreach ($previous as $key => [$server, $env]) { + if (null === $server) { + unset($_SERVER[$key]); + } else { + $_SERVER[$key] = $server; + } + if (null === $env) { + unset($_ENV[$key]); + } else { + $_ENV[$key] = $env; + } + } + } + } +} diff --git a/tests/ReleaseOnDisk.php b/tests/ReleaseOnDisk.php index ffb7cb4..42141a2 100644 --- a/tests/ReleaseOnDisk.php +++ b/tests/ReleaseOnDisk.php @@ -43,4 +43,15 @@ final class ReleaseOnDisk extends Download return $this->antworten[$url] ?? null; } + + /** Wie get(), nur in eine Datei - der Updater laedt das Archiv so, ohne es in den Speicher zu nehmen. */ + public function toFile(string $url, string $path): bool + { + $inhalt = $this->get($url); + if (null === $inhalt) { + return false; + } + + return false !== file_put_contents($path, $inhalt); + } } diff --git a/tests/SchedulerTest.php b/tests/SchedulerTest.php index 0ce6bc0..879571d 100644 --- a/tests/SchedulerTest.php +++ b/tests/SchedulerTest.php @@ -2,10 +2,16 @@ namespace App\Tests; +use App\EventSubscriber\InstallSubscriber; +use App\EventSubscriber\TaskWorkerSubscriber; use App\Infrastructure\ConnectionFactory; use App\Ops\Scheduler; use Doctrine\DBAL\Connection; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\TerminateEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; /** * Der Aufgabenplan - und zwar die Eigenschaften, an denen er auf einem echten Hosting scheitern @@ -256,4 +262,32 @@ final class SchedulerTest extends TestCase self::assertNotSame($one, $two); self::assertSame($one, Scheduler::cronToken('geheimnis-eins')); } + + public function testTheWorkerNeverShortensAProcessWithoutATimeLimit(): void + { + // `set_time_limit()` setzt, es verlaengert nicht. Auf einem Prozess ohne Grenze - Konsole, + // Testlauf, Hoster mit max_execution_time = 0 - machte der Worker aus "unbegrenzt" + // achtzig Sekunden, und danach starb der Prozess irgendwo weit weg von den Aufgaben. + $vorher = ini_get('max_execution_time'); + ini_set('max_execution_time', '0'); + file_put_contents($this->projectDir.'/'.InstallSubscriber::MARKER, 'test +'); + $task = new SpyTask('a'); + $scheduler = $this->scheduler([$task]); + $scheduler->sync(); + $this->makeDue('a'); + // Die Zustandsdatei kennt den alten Zeitpunkt; ohne sie fragt der naechste Aufruf die Datenbank. + @unlink($this->projectDir.'/'.Scheduler::STATE_FILE); + + $subscriber = new TaskWorkerSubscriber($scheduler, $this->projectDir, 'admin'); + $subscriber->onTerminate(new TerminateEvent( + $this->createMock(HttpKernelInterface::class), + Request::create('/'), + new Response(), + )); + + self::assertSame(1, $task->runs, 'Die faellige Aufgabe soll gelaufen sein.'); + self::assertSame('0', (string) ini_get('max_execution_time'), 'Ohne Grenze bleibt ohne Grenze.'); + ini_set('max_execution_time', (string) $vorher); + } } diff --git a/tests/TestSchema.php b/tests/TestSchema.php index 3b1d718..20af8d2 100644 --- a/tests/TestSchema.php +++ b/tests/TestSchema.php @@ -19,6 +19,8 @@ use DoctrineMigrations\Version20260912001200; use DoctrineMigrations\Version20260912001300; use DoctrineMigrations\Version20260913000100; use DoctrineMigrations\Version20260914000100; +use DoctrineMigrations\Version20260915000100; +use DoctrineMigrations\Version20260916000100; use Psr\Log\NullLogger; /** @@ -49,6 +51,8 @@ final class TestSchema Version20260912001300::class, Version20260913000100::class, Version20260914000100::class, + Version20260915000100::class, + Version20260916000100::class, ] as $migration) { (new $migration($db, new NullLogger()))->up($schema); } diff --git a/tests/UpdaterTest.php b/tests/UpdaterTest.php index c30dee0..2eff943 100644 --- a/tests/UpdaterTest.php +++ b/tests/UpdaterTest.php @@ -95,7 +95,7 @@ final class UpdaterTest extends TestCase // The order matters, so it is asserted. self::assertSame( - ['Vorprüfung', 'Ziel bestimmen', 'Sicherung', 'Wartungszustand', 'Code wechseln', 'Fassung prüfen', 'Abhängigkeiten', 'Migration', 'Cache', 'Rauchtest', 'Fertig'], + ['Vorprüfung', 'Ziel bestimmen', 'Sperre', 'Sicherung', 'Wartungszustand', 'Code wechseln', 'Fassung prüfen', 'Abhängigkeiten', 'Migration', 'Cache', 'Rauchtest', 'Fertig'], array_column($result['steps'], 'step'), ); diff --git a/tests/editor-model.test.mjs b/tests/editor-model.test.mjs index 6cd68e8..87ab3c0 100644 --- a/tests/editor-model.test.mjs +++ b/tests/editor-model.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, readdirSync } from 'node:fs'; -import { EditorHistory, createBlock, locate, insertBlock, insertBlockAt, moveBlockTo, dropList, insertionTarget, insertRowAt, CATEGORIES, labels as blockLabels, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, insertRow, blockPath, insertMedia, setEffect, isPluginBlock, blockLabel, blockIcon, canContain, isContainer, setStyle, effectiveStyle, hoverStyle, resetStyleDevice, clearStyle, copyStyle, SCHEMA_VERSION } from '../public/assets/editor-model.js'; +import { EditorHistory, createBlock, locate, insertBlock, insertBlockAt, moveBlockTo, dropList, insertionTarget, insertRowAt, CATEGORIES, labels as blockLabels, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, insertRow, blockPath, insertMedia, setEffect, isPluginBlock, blockLabel, blockIcon, canContain, isContainer, setStyle, effectiveStyle, hoverStyle, resetStyleDevice, clearStyle, copyStyle, SCHEMA_VERSION, registerPlugins, pluginDefinition } from '../public/assets/editor-model.js'; import { readFile } from 'node:fs/promises'; import { designCss } from '../scripts/build-design.mjs'; import { relevantProperty } from '../public/assets/editor-model.js'; @@ -600,3 +600,34 @@ test('every module imported by another one is listed in the import map', () => { const fehlend = [...importiert].filter(name => !eingetragen.has(name)).sort(); assert.deepEqual(fehlend, [], `ohne Fassungskennung in der Importkarte: ${fehlend.join(', ')}`); }); + +test('registered plugin blocks get labels, defaults, container rules and presets with fresh ids', () => { + registerPlugins([ + { type: 'vergleich.bildvergleich', label: 'Bildvergleich', icon: '⇔', container: false, children: null, props: { position: { kind: 'number', default: 50 }, before: { kind: 'media', default: '' } } }, + { type: 'vergleich.galerie', label: 'Vergleichsgalerie', icon: '⊞', container: true, children: ['vergleich.bildvergleich'], props: {} }, + ], [{ id: 'plugin:vergleich:abschnitt', label: 'Abschnitt', plugin: 'Bildvergleich', blocks: [{ id: 'fix', type: 'section', children: [{ id: 'fix2', type: 'vergleich.bildvergleich', props: { position: 30 } }] }] }]); + const bild = createBlock('vergleich.bildvergleich'); + assert.deepEqual(bild.props, { position: 50, before: '' }); + assert.equal('children' in bild, false); + const galerie = createBlock('vergleich.galerie'); + assert.deepEqual(galerie.children, []); + assert.equal(isContainer(galerie), true); + assert.equal(canContain(galerie, bild), true, 'the manifest allows the comparison'); + assert.equal(canContain(galerie, { type: 'text' }), false, 'and nothing else'); + assert.equal(canContain(bild, { type: 'text' }), false, 'a plain plugin block takes no children'); + assert.equal(pluginDefinition(galerie).label, 'Vergleichsgalerie'); + + const document = { schemaVersion: 4, title: 'P', description: 'D', blocks: [{ id: 's1', type: 'section', children: [] }] }; + const first = insertPreset(document, 'plugin:vergleich:abschnitt', 's1'); + const second = insertPreset(document, 'plugin:vergleich:abschnitt', 's1'); + assert.notEqual(first, second, 'every insertion gets its own ids'); + assert.notEqual(first, 'fix'); + assert.equal(document.blocks.length, 3); + assert.equal(locate(document.blocks, first).block.children[0].props.position, 30); + assert.notEqual(locate(document.blocks, first).block.children[0].id, locate(document.blocks, second).block.children[0].id); + + // Switched off again: the type is unknown once more and the block a placeholder. + registerPlugins([], []); + assert.equal(pluginDefinition(bild), null); + assert.equal(isContainer(galerie), false); +});