Compare commits

...
8 Commits
Author SHA1 Message Date
kantarciandClaude Opus 5.5 76e179b077 Note the 1.39.1 release in the status
CMS quality / php (8.2) (push) Waiting to run
CMS quality / php (8.4) (push) Waiting to run
CMS quality / browser (push) Waiting to run
CMS quality / mysql (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 20:02:58 +01:00
kantarciandClaude Opus 5.5 b60e599dda Release 1.39.1
CMS quality / php (8.2) (push) Waiting to run
CMS quality / php (8.4) (push) Waiting to run
CMS quality / browser (push) Waiting to run
CMS quality / mysql (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 20:02:24 +01:00
kantarciandClaude Opus 5.5 791c265f42 Add the listings page to the Luximmo site build
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 20:02:24 +01:00
kantarciandClaude Opus 5.5 78083fac6a Use own SVG icons instead of text glyphs on the website
Phones turn glyphs such as arrows and crosses into emoji. The viewer, scroll button, video toggle, menus, breadcrumbs, pagers and quote stars now draw SVG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 20:02:24 +01:00
kantarciandClaude Opus 5.5 b9740e6c1b Note the 1.39.0 release in the status
CMS quality / php (8.2) (push) Waiting to run
CMS quality / php (8.4) (push) Waiting to run
CMS quality / browser (push) Waiting to run
CMS quality / mysql (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:19:02 +01:00
kantarciandClaude Opus 5.5 2adcaab5c2 Release 1.39.0
CMS quality / php (8.2) (push) Waiting to run
CMS quality / php (8.4) (push) Waiting to run
CMS quality / browser (push) Waiting to run
CMS quality / mysql (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:18:17 +01:00
kantarciandClaude Opus 5.5 9825bac3b3 Let plugins run their own PHP code
CMS quality / php (8.2) (push) Waiting to run
CMS quality / php (8.4) (push) Waiting to run
CMS quality / browser (push) Waiting to run
CMS quality / mysql (push) Waiting to run
A plugin can now bring a plugin.php that registers hooks, scheduled tasks, its own tables, public pages and admin screens. Failures stay contained to the plugin; a form on a plugin page can be submitted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:56:14 +01:00
kantarciandClaude Opus 5.5 b61f958ca1 Note the 1.38.0 release in the status
CMS quality / php (8.2) (push) Waiting to run
CMS quality / php (8.4) (push) Waiting to run
CMS quality / browser (push) Waiting to run
CMS quality / mysql (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:04:21 +01:00
26 changed files with 975 additions and 47 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
1.38.0
1.39.1
+2 -1
View File
@@ -30,7 +30,7 @@ services:
tags: ['kant.task']
App\:
resource: '../src/'
exclude: '../src/{Kernel.php,Domain/}'
exclude: '../src/{Kernel.php,Domain/,Plugin/PluginApi.php,Plugin/PluginTask.php}'
Doctrine\DBAL\Connection:
factory: ['App\Infrastructure\ConnectionFactory', 'create']
arguments: ['%env(projectdir:DATABASE_URL)%', '@App\Infrastructure\QueryCounter']
@@ -156,6 +156,7 @@ services:
arguments:
$tasks: !tagged_iterator kant.task
$projectDir: '%kernel.project_dir%'
$plugins: '@App\Plugin\PluginRuntime'
App\Ops\Zentrale:
arguments:
$root: '%kernel.project_dir%'
+8
View File
@@ -2,6 +2,14 @@
Stand: 27.10.2026. Arbeitstitel KantSites; Produktname offen.
## ADR 127 – Plugins mit eigenem PHP-Code, wie bei WordPress (09.11.2026)
**Kontext:** Umut will einen ImmoScout-Abgleich für DEEG, ausdrücklich als Plugin und nicht im Kern: „Ansonsten musst du Kern so erweitern, dass Plugins das können“, „wie WordPress“. Das hebt die Grundentscheidung aus ADR 103 auf, dass ein Plugin nur Manifest plus Twig ist; dort war sie mit dem fehlenden Bedarf und der Stabilität begründet. Eigene Daten und Arbeit im Takt gehen ohne Servercode nicht.
**Entscheidung:** `"main"` im Manifest nennt eine PHP-Datei, die eine Funktion zurückgibt. `PluginRuntime` lädt sie für aktive Plugins, `PluginApi` nimmt Hooks, Aufgaben (im vorhandenen `Scheduler`), Seiten (durch `renderPage`, also mit Kopf, Fuß und Design), Twig-Daten und Verwaltungsbildschirme entgegen. Eigene Tabellen legt das Plugin in `onInstall` an, Präfix `plugin_<name>_`; dadurch braucht der Kern keine Plugin-Migrationen. Reine Twig-Plugins bleiben unverändert.
**Folgen:** Plugin-Code läuft mit vollen Rechten, das steht in der Verwaltung und wird nicht als Sandkasten bezeichnet. Anders als bei WordPress gibt es bewusst keinen ZIP-Upload in der Verwaltung: Wer PHP auf den Server legt, tut das per FTP, ein gestohlenes Adminkonto wird so nicht zur Codeausführung. Ausnahmen grenzt die Runtime auf das Plugin ein, einen harten PHP-Abbruch nicht. Der Kern löst vorerst nur `sitemap.entries` aus; weitere Hooks kommen mit dem ersten Bedarf.
## ADR 126 – Unternehmensart, Standort und Logo in den strukturierten Daten (09.11.2026)
**Kontext:** Bei Luximmo fiel auf, dass LocalBusiness immer der allgemeine Typ war und weder Logo noch Koordinaten kannte.
+46 -3
View File
@@ -5,9 +5,9 @@ Website. Farben, Schriften und Buttons fasst es nie an – das Design gehört de
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.
Ein Plugin kann reines Manifest plus Twig sein. Braucht es mehr – eigene Tabellen, Aufgaben im
Takt, Seiten aus eigenen Daten, Verwaltungsbildschirme –, bringt es wie bei WordPress eine
`plugin.php` mit (siehe „Eigener PHP-Code“, ADR 127).
## Der Ordner
@@ -121,6 +121,49 @@ Stylesheet und die Design-Tokens des Kerns; Palette, Schriften und Buttons der W
nur der Betreiber unter **Design**. So färbt kein Schalter eine Website um, und nichts, was dort
eingestellt wurde, geht durch ein Plugin verloren (ADR 105).
## Eigener PHP-Code (`plugin.php`)
`"main": "plugin.php"` im Manifest lädt die Datei, solange das Plugin eingeschaltet ist. Sie gibt
eine Funktion zurück und meldet sich dort beim Kern an:
```php
<?php
return static function (App\Plugin\PluginApi $api): void {
$tabelle = $api->table('objekt'); // plugin_<name>_objekt
$api->onInstall(fn () => $api->db()->executeStatement("CREATE TABLE IF NOT EXISTS $tabelle (id VARCHAR(20) PRIMARY KEY)"));
$api->addTask('abgleich', 'Abgleich', 'Was ausbleibt, wenn sie nicht läuft.', 3600, fn (): string => 'Bericht');
$api->addPage('/objekte/{slug}', fn (array $p): ?array => ['title' => 'Titel', 'description' => 'Text', 'blocks' => []]);
$api->addData('liste', fn (array $argumente): array => []);
$api->addFilter('sitemap.entries', fn (array $e, string $origin): array => $e);
$api->addAdminPage('uebersicht', 'Übersicht', 'admin/uebersicht.html.twig', fn (): array => []);
$api->addAdminAction('jetzt', 'Jetzt ausführen', 'abgleich'); // Aufgabenschlüssel oder Callback
};
```
| Methode | Zweck |
|---|---|
| `onInstall`, `onDeactivate` | Beim Einschalten und bei neuer Fassung; beim Ausschalten. Muss wiederholbar sein. Tabellen bleiben beim Ausschalten stehen. |
| `addAction`, `addFilter` | Hooks wie bei WordPress, kleinere Priorität zuerst. |
| `addTask` | Aufgabe im Plan des Kerns (Bildschirm „Aufgaben“); Fehler werfen, Rückgabe ist der Bericht. |
| `addPage` | Öffentliche Seite im Rahmen der Website. Gibt `title`, `description`, `blocks` und optional `seoTitle`, `robots`, `status`, `image` zurück oder `null` für 404. Eine veröffentlichte Seite geht vor. |
| `addData` | Werte für Vorlagen: `kant_plugin_data('name', 'schluessel', {…})`. |
| `addAdminPage`, `addAdminAction` | Bildschirm und Knopf der Verwaltung, nur für Administratoren, Knöpfe mit CSRF-Schutz. |
| `settings`, `db`, `table`, `media`, `fetch`, `storeImage`, `directory` | Einstellungen, Datenbank, Mediathek, HTTPS-Abruf, Bild ablegen. |
Der Kern ruft bisher diese Filter auf: `sitemap.entries` (`$entries`, `$origin`) und `form.context`
(`$extra`, `$form`, `$path`: zusätzliche Angaben `Name => Text` für eine eingehende Anfrage).
Weitere Hooks kommen in den
Kern, wenn ein Plugin sie braucht.
Die SQL-Anweisungen müssen auf SQLite und MySQL laufen. Klassen und Funktionen gehören in einen
eigenen Namensraum. Plugin-Tabellen gehören nicht zum Inhaltspaket; bei SQLite sind sie in jeder
Sicherung, weil die ganze Datei gesichert wird.
**Rechte:** Der Code läuft mit den vollen Rechten der Anwendung. Das ist kein Sandkasten. Eine
Ausnahme beim Laden, in einem Hook oder auf einer Seite nimmt nur das Plugin aus dem Spiel und
steht auf dem Bildschirm „Plugins“. Einen echten PHP-Abbruch fängt das nicht ab: Dann den Ordner
unter `plugins/` umbenennen.
## Die Vorlagen
### Bausteine
+24
View File
@@ -1,5 +1,25 @@
# Projektstatus
## Fassung 1.39.1: SVG statt Schriftzeichen auf der Website, Luximmo-Immobilien (09.11.2026)
**Anlass:** Umut: „nur eigene SVGs nutzen“. Bildbetrachter, PDF-Betrachter, Nach-oben-Knopf, Video-Knopf, Menüpfeile, Brotkrumen, Weiterlesen, Blättern und Zitatsterne sind SVG (Masken in `site.css`, sonst eingebettet).
**Geprüft:** PHPUnit 692 grün (PHP 8.4), `npm test` 41, `test:editor`, `test:browser`, Twig-Lint; Symbole im Browser nachgemessen. Tag `v1.39.1`, Download stimmt mit der Prüfsumme überein.
**Offen:** Die Verwaltung enthält weiter Schriftzeichen (Editor-Symbole samt Plugin-Bausteinen, Pfeile auf Knöpfen). `var/luximmo-paket.zip` trägt Fassung 1.39.0 und hat die Immobilien-Seite. Im Menü von Luximmo bricht der fünfte Punkt zwischen etwa 1025 und 1130 px um. Immoscout-Plugin aktuell 1.5.2: Paket-Import ersetzt die Mediathek, der Abgleich holt die Bilder danach neu.
## Kern: Plugins mit PHP-Code; Plugin „ImmoScout-Abgleich“ für DEEG (09.11.2026)
**Anlass:** Umut: ImmoScout-Objekte im Takt abgleichen, kostenlos per URL, „100 % ein Plugin“, „wie WordPress“.
**Kern (ADR 127):** `plugin.php` mit `PluginApi`/`PluginRuntime`: Hooks, Aufgaben, Seiten, eigene Tabellen, Verwaltungsbildschirme, Fehler je Plugin eingegrenzt. **Release:** Tag `v1.39.0` auf `main`, Releaseeintrag mit Zip (3694 Dateien, 5,8 MB), `.sha256` und `install.php`, gebaut aus sauberem Stand ohne das ImmoScout-Plugin; anonymer Download stimmt mit der Prüfsumme überein.
**Geprüft:** PHPUnit 692 grün (PHP 8.4, neu `PluginServerTest`), `npm test` 41, `test:editor`, Twig-Lint, php-cs-fixer. Auf einer Kopie der DEEG-Installation in der Verwaltung: einschalten, einstellen, echter Abgleich (5 Objekte, Bilder, Exposés), Liste, Objektseite 1440 und 390 px ohne waagrechtes Scrollen, Sitemap, Anfrage mit Objektangabe gespeichert, verschwundenes Objekt beim zweiten Lauf inaktiv mit 410. Nicht geprüft: Hosting, MySQL, Mailversand, Lighthouse der Objektseiten; die Abweichung Angebotszahl/gelesene Objekte wurde nicht einzeln simuliert.
**Nächster Schritt:** DEEG auf 1.39.0 aktualisieren, `var/immoscout-plugin-1.4.0.zip` nach `plugins/` entpacken, einschalten, Portal-Adresse eintragen, abgleichen, Baustein auf `/angebote` setzen. Vorher Zustimmung von DEEG einholen.
**Plugin:** liegt in `plugins/immoscout` (lokal von Git ausgeschlossen, Auslieferung als ZIP), nicht Teil des Kerns. ImmoScouts `robots.txt` verbietet automatisches Abrufen; das hat Umut entschieden, der Kunde sollte zustimmen. Eine Sperre wird nicht umgangen.
## Kern: Unternehmensart, Standort und Logo in den strukturierten Daten (09.11.2026)
**Anlass:** SEO-Durchsicht bei Luximmo. Neu unter Einstellungen (ADR 126): Art des Unternehmens, Breiten-/Längengrad, Logo aus der Mediathek. Ohne Angaben bleibt die Ausgabe wie bisher, keine Migration.
@@ -10,6 +30,10 @@
Die Ausgabe auf einer Live-Seite braucht mindestens Fassung 1.38.0; 1.37.0 ignoriert die Werte.
**Release:** Tag `v1.38.0` auf `main`, Releaseeintrag mit Zip (3690 Dateien, 5,8 MB), `.sha256` und `install.php`, gebaut aus sauberem Stand; anonymer Download stimmt mit der Prüfsumme überein. PHPUnit 687 grün unter PHP 8.4, PHP 8.2 nicht gelaufen. Luximmo-Paket mit Fassung 1.38.0 neu exportiert, es lässt sich nur auf 1.38.0 oder neuer importieren.
**Nächster Schritt:** Luximmo auf 1.38.0 einrichten, Paket importieren, Impressum vervollständigen; übrige Installationen über die Zentrale aktualisieren.
## Neue Installation „Luximmo Hausverwaltung“, Gaggenau (06.11.2026)
**Anlass:** Umut lieferte `luximmo-website.zip` (Onepager der Luximmo UG, Eslem Kurt, Hauptstr. 169, 76571 Gaggenau): „in KantSites nachbauen und wie die anderen Ablagen, gerne etwas ausbauen, und wie bei Immo DEEG ein Formular für Immo auf einer separaten Seite“.
+7 -4
View File
@@ -1,3 +1,6 @@
/* Symbole als SVG: Schriftzeichen macht ein Telefon zu Emoji. */
const symbol = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="${p}"/></svg>`;
/*
* Der Bildbetrachter.
*
@@ -23,9 +26,9 @@
<img class="zoom-image" alt="">
<figcaption class="zoom-caption"></figcaption>
</figure>
<button class="zoom-close" type="button" aria-label="Schließen">×</button>
<button class="zoom-prev" type="button" aria-label="Vorheriges Bild">‹</button>
<button class="zoom-next" type="button" aria-label="Nächstes Bild">›</button>
<button class="zoom-close" type="button" aria-label="Schließen">${symbol('M6 6l12 12M18 6 6 18')}</button>
<button class="zoom-prev" type="button" aria-label="Vorheriges Bild">${symbol('M15 5.5 8.5 12l6.5 6.5')}</button>
<button class="zoom-next" type="button" aria-label="Nächstes Bild">${symbol('m9 5.5 6.5 6.5L9 18.5')}</button>
<p class="zoom-counter" aria-live="polite"></p>
`;
document.body.append(dialog);
@@ -138,7 +141,7 @@
<p class="pdf-title" id="pdf-dialog-title"></p>
<a class="pdf-action pdf-download" download>Herunterladen</a>
<a class="pdf-action pdf-open" target="_blank" rel="noopener">Im neuen Tab</a>
<button class="pdf-close" type="button" aria-label="Schließen">×</button>
<button class="pdf-close" type="button" aria-label="Schließen">${symbol('M6 6l12 12M18 6 6 18')}</button>
</div>
<iframe class="pdf-frame" title="PDF-Dokument"></iframe>
`;
+17 -7
View File
@@ -1,3 +1,5 @@
/* Symbole als eingebettete SVG-Masken: Schriftzeichen macht ein Telefon zu Emoji. */
:root{--kant-svg-unten:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");--kant-svg-oben:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 15 6-6 6 6'/%3E%3C/svg%3E");--kant-svg-rechts:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 5.5 6.5 6.5L9 18.5'/%3E%3C/svg%3E");--kant-svg-pfeil:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12h15.5m-6.5-6.5 6.5 6.5-6.5 6.5'/%3E%3C/svg%3E");--kant-svg-pfeil-links:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 12H4.5M11 5.5 4.5 12 11 18.5'/%3E%3C/svg%3E");--kant-svg-pause:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 5v14m8-14v14'/%3E%3C/svg%3E");--kant-svg-play:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23000' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 5v14l11-7z'/%3E%3C/svg%3E")}
@charset "UTF-8";
/* Lange Wörter dürfen umbrechen, damit nichts aus der Spalte läuft. Getrennt wird nur im
Fließtext: eine Überschrift mit Trennstrich („di-gitale") sieht nach Fehler aus. */
@@ -19,14 +21,14 @@ p{hyphens:auto}
.menu-list a:hover,.menu-list summary:hover{text-decoration:underline}
.menu-list [aria-current=page]{font-weight:700;text-decoration:none}
.menu-branch>summary{cursor:pointer;padding:6px 2px;list-style:none}
.menu-branch>summary::after{content:' ▾'}
.menu-branch[open]>summary::after{content:' ▴'}
.menu-branch>summary::after{content:'';display:inline-block;width:.75em;height:.75em;background:currentColor;-webkit-mask:var(--kant-svg-unten) center/contain no-repeat;mask:var(--kant-svg-unten) center/contain no-repeat;margin-left:.3em;vertical-align:-.05em}
.menu-branch[open]>summary::after{-webkit-mask-image:var(--kant-svg-oben);mask-image:var(--kant-svg-oben)}
.menu-branch-body{display:grid;gap:2px;padding:6px 0 6px 14px}
.menu-level-2{flex-direction:column;align-items:flex-start;gap:2px}
/* 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::after{content:'';display:inline-block;width:1em;height:1em;background:currentColor;-webkit-mask:var(--kant-svg-pfeil) center/contain no-repeat;mask:var(--kant-svg-pfeil) center/contain no-repeat;vertical-align:-.12em;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: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)}
@@ -66,7 +68,9 @@ p{hyphens:auto}
.block-quote blockquote p{margin:0;font-size:inherit;line-height:1.65}
.block-quote figcaption{font-size:14px;font-weight:600}
.block-quote figcaption span{font-weight:400;opacity:.85}
.quote-rating{margin:0;letter-spacing:3px}
.quote-rating{margin:0}
.quote-rating svg{display:inline-block;width:1.1em;height:1.1em;margin-right:2px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linejoin:round}
.quote-rating .quote-star-voll{fill:currentColor}
.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: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}
@@ -170,7 +174,7 @@ p{hyphens:auto}
@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}
.block-breadcrumb li+li::before{content:'';display:inline-block;width:.85em;height:.85em;background:currentColor;-webkit-mask:var(--kant-svg-rechts) center/contain no-repeat;mask:var(--kant-svg-rechts) center/contain no-repeat;margin-right:10px;opacity:.6;vertical-align:-.08em}
.block-breadcrumb a{text-decoration:none}
.block-breadcrumb a:hover{text-decoration:underline}
@@ -247,8 +251,8 @@ body.consent-open{padding-bottom:320px}
.section-video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none}
.has-video>.section-inner{position:relative;z-index:1}
.video-toggle{position:absolute;right:16px;bottom:16px;z-index:2;width:44px;height:44px;padding:0;border:0;border-radius:50%;background:rgba(10,20,35,.72);color:#fff;font:14px/1 system-ui,sans-serif;cursor:pointer}
.video-toggle::before{content:"❚❚"}
.video-toggle.is-paused::before{content:"▶"}
.video-toggle::before{content:'';display:inline-block;width:20px;height:20px;background:currentColor;-webkit-mask:var(--kant-svg-pause) center/contain no-repeat;mask:var(--kant-svg-pause) center/contain no-repeat;display:block;margin:auto}
.video-toggle.is-paused::before{-webkit-mask-image:var(--kant-svg-play);mask-image:var(--kant-svg-play)}
.video-toggle:focus-visible{outline:3px solid #fff;outline-offset:2px}
.video-toggle[hidden]{display:none}
.block-html{max-width:100%}
@@ -603,6 +607,10 @@ html.kant-top-armed.kant-top-shown .scroll-top{opacity:1;visibility:visible;tran
.zoom-close,.zoom-prev,.zoom-next{position:fixed;display:grid;place-items:center;width:52px;height:52px;border-radius:999px;border:0;background:rgba(255,255,255,.14);color:#fff;font-size:26px;line-height:1;cursor:pointer}
.zoom-close:hover,.zoom-prev:hover,.zoom-next:hover{background:rgba(255,255,255,.26)}
.zoom-close:focus-visible,.zoom-prev:focus-visible,.zoom-next:focus-visible{outline:3px solid #fff;outline-offset:3px}
.zoom-close svg,.zoom-prev svg,.zoom-next svg,.pdf-close svg,.scroll-top svg{display:block;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.zoom-close svg,.zoom-prev svg,.zoom-next svg{width:26px;height:26px}
.pdf-close svg{width:20px;height:20px}
.scroll-top svg{width:46%;height:46%}
.zoom-close{top:18px;right:18px}
.zoom-prev{left:18px;top:50%;transform:translateY(-50%)}
.zoom-next{right:18px;top:50%;transform:translateY(-50%)}
@@ -644,3 +652,5 @@ html.kant-top-armed.kant-top-shown .scroll-top{opacity:1;visibility:visible;tran
.tone-ink,.tone-mint{background:transparent;color:#000}
[data-step][hidden],.block-form [hidden]{display:block!important}
}
.post-pager a[rel="prev"]::before{content:'';display:inline-block;width:1em;height:1em;background:currentColor;-webkit-mask:var(--kant-svg-pfeil-links) center/contain no-repeat;mask:var(--kant-svg-pfeil-links) center/contain no-repeat;margin-right:.35em;vertical-align:-.12em}
.post-pager a[rel="next"]::after{content:'';display:inline-block;width:1em;height:1em;background:currentColor;-webkit-mask:var(--kant-svg-pfeil) center/contain no-repeat;mask:var(--kant-svg-pfeil) center/contain no-repeat;margin-left:.35em;vertical-align:-.12em}
+3 -3
View File
@@ -56,14 +56,14 @@ export function initSliders(doc = document) {
go(direction > 0 ? (last ? visible[0] : visible[Math.min(visible.length - 1, position + 1)]) : (first ? visible.at(-1) : visible[Math.max(0, position - 1)]));
};
const manual = action => { playing = false; clearTimeout(timer); update(); action(); };
const previous = button('←', 'Vorherige Folie', () => manual(() => step(-1))); previous.className = 'slider-prev';
const previous = button('', 'Vorherige Folie', () => manual(() => step(-1))); previous.className = 'slider-prev';
const count = doc.createElement('span'); count.className = 'slider-position'; controls.append(count);
const next = button('→', 'Nächste Folie', () => manual(() => step(1))); next.className = 'slider-next';
const next = button('', 'Nächste Folie', () => manual(() => step(1))); next.className = 'slider-next';
const dots = [];
if (slider.classList.contains('slider-dots')) {
const group = doc.createElement('div'); group.className = 'slider-control-dots'; controls.append(group);
slides.forEach((slide, i) => {
const dot = button('●', `Folie ${i + 1} anzeigen`, () => manual(() => go(i))); group.append(dot); dots.push(dot);
const dot = button('', `Folie ${i + 1} anzeigen`, () => manual(() => go(i))); group.append(dot); dots.push(dot);
});
}
const play = seconds >= 3 ? button('', 'Automatische Wiedergabe pausieren', () => { playing = !playing; update(); schedule(); }) : null;
+28 -5
View File
@@ -452,8 +452,8 @@ $kopf = [
]],
// Goldene Linie, die beim Überfahren wächst; das Klappmenü liegt als Fläche
// unter dem ganzen Kopf.
'css' => "& .menu-list{gap:4px 28px;align-items:center}\n"
."& .menu-list a{position:relative;padding:12px 0;text-decoration:none}\n"
'css' => "& .menu-list{gap:4px clamp(10px,calc(4.2vw - 32px),28px);align-items:center}\n"
."& .menu-list a{position:relative;padding:12px 0;text-decoration:none;font-size:clamp(13px,1.05vw,15px)}\n"
."& .menu-list a::after{content:'';position:absolute;left:0;right:100%;bottom:6px;height:1.5px;background:#BDA076;transition:right .25s ease}\n"
."& .menu-list a:hover::after{right:0}\n"
."& .menu-burger summary{width:46px;height:46px;display:flex;align-items:center;justify-content:center;padding:0}\n"
@@ -501,7 +501,7 @@ $fuss = [
]),
$column('fuss-seiten', [
$kleinzeile('fuss-seiten-titel', 'Leistungen', '@gold-dunkel', '8px'),
$fussText('fuss-seiten-text', '<a href="/#leistungen">Immobilienverkauf</a><br><a href="/#leistungen">WEG-Verwaltung</a><br><a href="/#leistungen">Mietverwaltung</a><br><a href="/#leistungen">Sondereigentum</a><br><a href="/verwaltungsanfrage">Verwaltungsanfrage</a><br><a href="/immobilienbewertung">Immobilienbewertung</a>'),
$fussText('fuss-seiten-text', '<a href="/#leistungen">Immobilienverkauf</a><br><a href="/#leistungen">WEG-Verwaltung</a><br><a href="/#leistungen">Mietverwaltung</a><br><a href="/#leistungen">Sondereigentum</a><br><a href="/verwaltungsanfrage">Verwaltungsanfrage</a><br><a href="/immobilienbewertung">Immobilienbewertung</a><br><a href="/immobilien">Immobilien</a>'),
]),
], [
'desktop' => ['display' => 'grid', 'gridColumns' => 3, 'gap' => '40px', 'padding' => ['top' => '56px', 'bottom' => '40px']],
@@ -649,7 +649,10 @@ $startseite = [
$section('leistungen-bereich', [
$zeile('leistungen-kopf', [
$heading('leistungen-titel', 'Leistungen', 2, $h2Stil),
$text('leistungen-link', '<a href="/verwaltungsanfrage">Unverbindlich anfragen →</a>', ['desktop' => ['fontSize' => '15px', 'fontWeight' => '500', 'margin' => ['bottom' => '0px']]], $linkCss),
$zeile('leistungen-link-zeile', [
$text('leistungen-link', '<a href="/verwaltungsanfrage">Unverbindlich anfragen</a>', ['desktop' => ['fontSize' => '15px', 'fontWeight' => '500', 'margin' => ['bottom' => '0px']]], $linkCss),
$symbol('leistungen-link-pfeil', 'arrow-right', 16, ['desktop' => ['color' => '@tinte']]),
], '6px'),
], '16px', ['justifyContent' => 'space-between', 'alignItems' => 'baseline', 'flexWrap' => 'wrap', 'margin' => ['bottom' => '56px']], ['margin' => ['bottom' => '36px']]),
$column('leistungen-raster', [
$leistung('leistung-verkauf', $medien['poster'], 'Offener Wohn- und Essbereich mit Treppe, Holzlamellen und großer Glasfront', 'Immobilienverkauf', 'Professionelle Vermarktung, marktgerechte Bewertung und persönliche Begleitung bis zum erfolgreichen Verkauf.', [
@@ -816,7 +819,11 @@ $startseite = [
]),
$column('kontakt-mehr', [
$kleinzeile('kontakt-mehr-titel', 'Direkt', '@gold-dunkel', '8px'),
$text('kontakt-mehr-text', '<a href="'.$tel.'">'.$telefon.'</a><br><a href="mailto:'.$mail.'">'.$mail.'</a><br>Für ein Angebot:<br><a href="/verwaltungsanfrage">Verwaltungsanfrage →</a>', ['desktop' => ['fontSize' => '17px', 'lineHeight' => 1.6, 'margin' => ['bottom' => '0px']]], $linkCss),
$text('kontakt-mehr-text', '<a href="'.$tel.'">'.$telefon.'</a><br><a href="mailto:'.$mail.'">'.$mail.'</a><br>Für ein Angebot:', ['desktop' => ['fontSize' => '17px', 'lineHeight' => 1.6, 'margin' => ['bottom' => '0px']]], $linkCss),
$zeile('kontakt-mehr-zeile', [
$text('kontakt-mehr-link', '<a href="/verwaltungsanfrage">Verwaltungsanfrage</a>', ['desktop' => ['fontSize' => '17px', 'lineHeight' => 1.6, 'margin' => ['bottom' => '0px']]], $linkCss),
$symbol('kontakt-mehr-pfeil', 'arrow-right', 16, ['desktop' => ['color' => '@tinte']]),
], '6px'),
]),
], '56px', ['alignItems' => 'flex-start', 'flexWrap' => 'wrap', 'rowGap' => '32px']),
]),
@@ -959,6 +966,21 @@ $hinweis = static fn (string $id, string $titel, array $punkte) => $column($id,
// Die Bildkarten einer Auswahl: Bild oben, gerundet, dunkler Rand bei Wahl.
$kartenCss = "\n& .form-choice-grid{grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px}\n& .form-choice-card label{border:1.5px solid #E6DED2;border-radius:20px;background:#fff;padding:10px 10px 14px;font-weight:500;font-size:15px;color:#141414}\n& .form-choice-card label:hover{border-color:#BDA076}\n& .form-choice-card input:checked+label{border-color:#141414;box-shadow:0 0 0 1.5px #141414}\n& .form-choice-card input{accent-color:#141414;top:20px;left:20px;width:20px;height:20px;min-height:0;padding:0;margin:0}\n& .form-choice-card input:focus{outline:none}\n& .form-choice-picture{border-radius:12px}";
// Die Objekte kommen aus dem Plugin „ImmoScout-Abgleich“; der Baustein trägt das Luximmo-Aussehen.
// Ohne eingeschaltetes Plugin bleibt die Seite erreichbar, nur die Liste fehlt.
$anlegen('immobilien', $einblenden([
'schemaVersion' => $SCHEMA, 'title' => 'Immobilien',
'description' => 'Aktuelle Immobilienangebote von Luximmo in Gaggenau und Umgebung, Baden-Baden und im Schwarzwald.',
'blocks' => [
$seitenkopf('immobilien', 'Aktuelle Angebote', 'Unsere Immobilien.', 'Wohnungen und Häuser, die wir aktuell in Gaggenau und Umgebung, Baden-Baden und im Schwarzwald anbieten.'),
$section('immobilien', [
['id' => 'immobilien-liste', 'type' => 'immoscout.liste',
'props' => ['art' => 'alle', 'darstellung' => 'kacheln', 'spalten' => '3', 'spaltenTablet' => '2', 'spaltenMobil' => '1', 'reihen' => 0, 'anzahl' => 0, 'bild' => 'breit', 'abstand' => 'normal', 'ausrichtung' => 'links', 'zeigeArt' => true, 'zeigeOrt' => true, 'zeigeFakten' => true],
'css' => "& .immoscout-karte{border:0;border-radius:24px;overflow:hidden;background:#F7F4EF}\n& .immoscout-karte-text{padding:24px 26px 28px}\n& .immoscout-karte h2{font-size:22px;font-weight:500;letter-spacing:-0.015em}\n& .immoscout-art{font-size:13px;font-weight:500;letter-spacing:0.04em;color:#6E5830}\n& .immoscout-ort{color:#5A554D;opacity:1}\n& .immoscout-karte dt{color:#5A554D;opacity:1}\n& .immoscout-karte dd{font-weight:500}\n& .immoscout-leer{border:0;border-radius:24px;background:#F7F4EF}\n& .immoscout-leer h2{font-weight:500;letter-spacing:-0.02em}\n& .immoscout-leer p{color:#5A554D}"],
], $abschnitt('0px', '160px'), null, '', 'wide'),
],
], ['immobilien-liste' => ['rise']]), 'Immobilien in Gaggenau, Baden-Baden und im Schwarzwald | LUXIMMO', $medien['miet']['id']);
$anlegen('immobilienbewertung', $einblenden([
'schemaVersion' => $SCHEMA, 'title' => 'Immobilienbewertung',
'description' => 'Was ist Ihre Immobilie wert? Luximmo schätzt Wohnungen, Häuser, Mehrfamilienhäuser und Grundstücke in Gaggenau und Umgebung – kostenlos und unverbindlich.',
@@ -1093,6 +1115,7 @@ $anker = static fn (string $label, string $anker): array => ['label' => $label,
$menus->save('header', 'Hauptnavigation', [
$anker('Leistungen', 'leistungen'),
$anker('Über uns', 'ueber-uns'),
['label' => 'Immobilien', 'type' => 'page', 'pageId' => $seiteId('immobilien'), 'children' => []],
['label' => 'Bewertung', 'type' => 'page', 'pageId' => $seiteId('immobilienbewertung'), 'children' => []],
['label' => 'Anfrage', 'type' => 'page', 'pageId' => $seiteId('verwaltungsanfrage'), 'children' => []],
], 0);
+12 -3
View File
@@ -9,6 +9,7 @@ use App\Content\PageRepository;
use App\Content\SiteSettings;
use App\Content\Turnstile;
use App\Ops\MailCheck;
use App\Plugin\PluginRuntime;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -33,6 +34,7 @@ final class FormController extends AbstractController
private string $secret,
private string $siteOrigin,
private Languages $languages,
private PluginRuntime $plugins,
) {
}
@@ -41,7 +43,8 @@ final class FormController extends AbstractController
{
$form = $this->forms->findBySlug($slug);
$path = trim($request->request->getString('page'), '/') ?: $this->settings->homePath();
$page = $this->pages->findPublished($path);
// Ein Formular kann auch auf der Seite eines Plugins stehen (ADR 127).
$page = $this->pages->findPublished($path) ?? $this->plugins->page('/'.$path);
if (null === $form || null === $page) {
throw $this->createNotFoundException('Formular oder Seite nicht gefunden.');
}
@@ -77,8 +80,11 @@ final class FormController extends AbstractController
return $this->renderPage($request, $page, $form, ['errors' => ['_form' => 'Gerade gehen sehr viele Anfragen ein. Bitte versuchen Sie es später erneut oder melden Sie sich telefonisch.'], 'values' => $checked['values']], null === $language ? null : ($page['path'] === $this->settings->homePath() ? $language : $language.'/'.$translatedPath));
}
if (!$silentlyDropped) {
// Angaben eines Plugins zur Seite, von der die Anfrage kommt: Name => Text.
$extra = array_filter((array) $this->plugins->applyFilters('form.context', [], $form, $path), 'is_string');
$checked['values'] += $extra;
$submission = $this->forms->store($form['id'], $checked['values'], $checked['consent'], $senderHash);
if ($this->deliver($form, $checked['values'])) {
if ($this->deliver($form, $checked['values'], $extra)) {
$this->forms->markDelivered($submission);
}
}
@@ -101,9 +107,12 @@ final class FormController extends AbstractController
return $age >= FormRepository::MIN_SECONDS && $age <= FormRepository::MAX_SECONDS;
}
private function deliver(array $form, array $values): bool
private function deliver(array $form, array $values, array $extra = []): bool
{
$lines = [];
foreach ($extra as $name => $text) {
$lines[] = $name.': '.$text;
}
foreach ($form['fields'] as $field) {
// Ein Schritt ist eine Überschrift in der Post; ein Feld, das nicht galt, bleibt weg.
if ('step' === $field['type']) {
+6 -1
View File
@@ -22,6 +22,7 @@ use App\Content\Theme;
use App\Content\Translations;
use App\Content\Turnstile;
use App\Plugin\PluginRegistry;
use App\Plugin\PluginRuntime;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -29,7 +30,7 @@ use Symfony\Component\Routing\Attribute\Route;
final class PageController extends AbstractController
{
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin, private NotFoundPage $notFoundPage, private PluginRegistry $plugins, private Languages $languages)
public function __construct(private PageRepository $pages, private PluginRuntime $runtime, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin, private NotFoundPage $notFoundPage, private PluginRegistry $plugins, private Languages $languages)
{
}
@@ -78,6 +79,10 @@ final class PageController extends AbstractController
if (null !== $route) {
return $this->pluginRoute($request, $route);
}
$pluginPage = $this->runtime->page('/'.$path);
if (null !== $pluginPage) {
return $this->renderPage($request, $pluginPage, $path, $pluginPage['status']);
}
$redirect = $this->redirects->find($path);
if (null !== $redirect && 301 === $redirect['status']) {
return $this->shell($this->redirect('/'.$redirect['to'], 301));
+48 -4
View File
@@ -2,7 +2,9 @@
namespace App\Controller;
use App\Ops\Scheduler;
use App\Plugin\PluginRegistry;
use App\Plugin\PluginRuntime;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -14,7 +16,7 @@ use Symfony\Component\Routing\Attribute\Route;
*/
final class PluginAdminController extends AbstractController
{
public function __construct(private PluginRegistry $plugins)
public function __construct(private PluginRegistry $plugins, private PluginRuntime $runtime, private Scheduler $scheduler)
{
}
@@ -24,7 +26,12 @@ final class PluginAdminController extends AbstractController
// Ein Plugin bringt Code auf die Website - das schaltet nur ein Administrator.
$this->denyAccessUnlessGranted('ROLE_ADMIN');
return $this->render('admin/plugins.html.twig', ['plugins' => $this->plugins->all(), 'directory' => $this->plugins->directory()]);
$admin = [];
foreach ($this->plugins->all() as $name => $plugin) {
$admin[$name] = $this->runtime->admin($name);
}
return $this->render('admin/plugins.html.twig', ['plugins' => $this->plugins->all(), 'directory' => $this->plugins->directory(), 'admin' => $admin, 'runtimeErrors' => $this->runtime->errors()]);
}
#[Route('/%kant.admin_path%/plugins/{name}/{state}', name: 'admin_plugin_toggle', requirements: ['name' => '[a-z][a-z0-9]{1,30}', 'state' => 'an|aus'], methods: ['POST'])]
@@ -36,11 +43,13 @@ final class PluginAdminController extends AbstractController
}
try {
if ('an' === $state) {
$this->plugins->activate($name);
$this->runtime->activate($name);
$this->scheduler->pluginsChanged();
return $this->back(sprintf('„%s“ ist eingeschaltet. Seine Bausteine stehen jetzt im Editor unter „Erweiterungen“.', $this->plugins->all()[$name]['title']), 'success');
}
$this->plugins->deactivate($name);
$this->runtime->deactivate($name);
$this->scheduler->pluginsChanged();
return $this->back(sprintf('„%s“ ist ausgeschaltet. Seine Bausteine bleiben in den Seiten gespeichert, werden aber nicht mehr dargestellt.', $this->plugins->all()[$name]['title']), 'success');
} catch (\InvalidArgumentException $e) {
@@ -64,6 +73,41 @@ final class PluginAdminController extends AbstractController
return $this->back(sprintf('Die Einstellungen von „%s“ wurden gespeichert.', $this->plugins->all()[$name]['title']), 'success');
}
#[Route('/%kant.admin_path%/plugins/{name}/seite/{key}', name: 'admin_plugin_page', requirements: ['name' => '[a-z][a-z0-9]{1,30}', 'key' => '[a-z][a-z0-9-]{0,40}'], methods: ['GET'])]
public function page(string $name, string $key): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$admin = $this->runtime->admin($name);
$page = $admin['pages'][$key] ?? throw $this->createNotFoundException();
return $this->render('admin/plugin-page.html.twig', ['plugin' => $this->plugins->all()[$name], 'page' => $page, 'actions' => $admin['actions'], 'data' => ($page['callback'])()]);
}
#[Route('/%kant.admin_path%/plugins/{name}/aktion/{key}', name: 'admin_plugin_action', requirements: ['name' => '[a-z][a-z0-9]{1,30}', 'key' => '[a-z][a-z0-9-]{0,40}'], methods: ['POST'])]
public function action(Request $request, string $name, string $key): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('plugins', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
$admin = $this->runtime->admin($name);
$action = $admin['actions'][$key] ?? throw $this->createNotFoundException();
try {
if (is_string($action['handler'])) {
$result = $this->scheduler->runNow(PluginRuntime::taskName($name, $action['handler']));
[$type, $message] = null === $result ? ['error', 'Die Aufgabe läuft gerade schon.'] : ['ok' === $result['status'] ? 'success' : 'error', $result['message']];
} else {
[$type, $message] = ['success', (string) ($action['handler'])()];
}
} catch (\Throwable $e) {
[$type, $message] = ['error', $e->getMessage()];
}
$this->addFlash($type, $message);
$back = $request->request->getString('zurueck');
return isset($admin['pages'][$back]) ? $this->redirectToRoute('admin_plugin_page', ['name' => $name, 'key' => $back]) : $this->redirectToRoute('admin_plugins');
}
private function back(string $message, string $type = 'error'): Response
{
$this->addFlash($type, $message);
+3 -1
View File
@@ -17,7 +17,7 @@ use Symfony\Component\Routing\Attribute\Route;
*/
final class SeoController extends AbstractController
{
public function __construct(
public function __construct(private \App\Plugin\PluginRuntime $plugins,
private Connection $db,
private SiteSettings $settings,
private PostRepository $posts,
@@ -74,6 +74,8 @@ final class SeoController extends AbstractController
$entries[] = ['loc' => $origin.'/'.PostRepository::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug'], 'lastmod' => null];
}
$entries = $this->plugins->applyFilters('sitemap.entries', $entries, $origin);
return $this->xml($this->renderView('seo/sitemap.xml.twig', ['entries' => $entries]));
}
+12
View File
@@ -62,6 +62,7 @@ final class Scheduler
private Connection $db,
private iterable $tasks,
private string $projectDir,
private ?\App\Plugin\PluginRuntime $plugins = null,
) {
}
@@ -268,6 +269,14 @@ final class Scheduler
* @return bool false, wenn die Tabelle (noch) nicht da ist - vor der Installation und
* zwischen hochgeladenem Code und ausgefuehrter Migration
*/
/** Nach dem Ein- oder Ausschalten eines Plugins: Der Plan kennt dessen Aufgaben sofort. */
public function pluginsChanged(): void
{
$this->registry = null;
$this->sync();
$this->refreshState();
}
public function sync(?\DateTimeImmutable $now = null): bool
{
$now ??= self::now();
@@ -396,6 +405,9 @@ final class Scheduler
foreach ($this->tasks as $task) {
$registry[$task->name()] = $task;
}
foreach ($this->plugins?->tasks() ?? [] as $task) {
$registry[$task->name()] = $task;
}
ksort($registry);
return $this->registry = $registry;
+248
View File
@@ -0,0 +1,248 @@
<?php
namespace App\Plugin;
use App\Content\MediaLibrary;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
* Was die plugin.php eines Plugins vom Kern bekommt (ADR 127).
*
* Eine Instanz je Plugin. Das Plugin meldet hier an, was es kann - Hooks, Aufgaben im Takt, Seiten,
* Bildschirme der Verwaltung -, und bekommt seine Einstellungen, die Datenbank und die Mediathek.
* Der Kern übernimmt die Anmeldungen erst, wenn die plugin.php ohne Fehler durchgelaufen ist.
*
* Plugin-Code läuft mit den vollen Rechten der Anwendung, wie bei WordPress. Das ist kein
* Sandkasten und wird nicht so genannt: Ein Plugin installiert nur, wer dem Code traut.
*/
final class PluginApi
{
/** Größte Antwort, die fetch() annimmt. */
private const MAX_BYTES = 16_000_000;
/** @var array{install: list<callable>, deactivate: list<callable>, actions: array<string, list<array{int, callable}>>, filters: array<string, list<array{int, callable}>>, tasks: array<string, array>, pages: list<array>, data: array<string, callable>, adminPages: array<string, array>, adminActions: array<string, array>} */
public array $registered = ['install' => [], 'deactivate' => [], 'actions' => [], 'filters' => [], 'tasks' => [], 'pages' => [], 'data' => [], 'adminPages' => [], 'adminActions' => []];
public function __construct(
private string $name,
private string $directory,
private PluginRegistry $plugins,
private Connection $db,
private MediaLibrary $media,
) {
}
public function name(): string
{
return $this->name;
}
/** Der Ordner des Plugins, ohne Schrägstrich am Ende. */
public function directory(): string
{
return $this->directory;
}
/** Die geprüften Einstellungen aus der plugin.json, jede vorhanden. */
public function settings(): array
{
return $this->plugins->settingsOf($this->name);
}
public function db(): Connection
{
return $this->db;
}
/** Der Name einer eigenen Tabelle: plugin_<name>_<kurz>. Andere Tabellen gehören dem Kern. */
public function table(string $short): string
{
if (1 !== preg_match('/^[a-z][a-z0-9_]{0,30}$/D', $short)) {
throw new \InvalidArgumentException('Ein Tabellenname besteht aus Kleinbuchstaben, Ziffern und Unterstrichen.');
}
return 'plugin_'.$this->name.'_'.$short;
}
public function media(): MediaLibrary
{
return $this->media;
}
// ---------------------------------------------------------------- Lebenszyklus
/**
* Läuft beim Einschalten und wenn sich die Fassung des Plugins geändert hat - muss also
* wiederholbar sein (CREATE TABLE IF NOT EXISTS). Wirft es, bleibt das Plugin aus.
*/
public function onInstall(callable $callback): void
{
$this->registered['install'][] = $callback;
}
/** Läuft beim Ausschalten. Eigene Tabellen bleiben stehen, wie die Bausteine in den Seiten. */
public function onDeactivate(callable $callback): void
{
$this->registered['deactivate'][] = $callback;
}
// ---------------------------------------------------------------- Hooks
/** Reagiert auf ein Ereignis des Kerns oder eines anderen Plugins. Kleinere Priorität zuerst. */
public function addAction(string $hook, callable $callback, int $priority = 10): void
{
$this->registered['actions'][$hook][] = [$priority, $callback];
}
/** Verändert einen Wert: Der Callback bekommt ihn als erstes Argument und gibt ihn zurück. */
public function addFilter(string $hook, callable $callback, int $priority = 10): void
{
$this->registered['filters'][$hook][] = [$priority, $callback];
}
// ---------------------------------------------------------------- Anmeldungen
/**
* Eine Aufgabe im Takt. Sie steht unter „Aufgaben“ im Plan des Kerns; Fehler soll sie werfen,
* ihre Rückgabe ist der Bericht. Die Regeln stehen in App\Ops\Tasks\Task.
*
* @param callable(): string $callback
*/
public function addTask(string $key, string $label, string $explanation, int $interval, callable $callback): void
{
$this->key($key);
if ($interval < 60) {
throw new \InvalidArgumentException('Eine Aufgabe läuft höchstens jede Minute.');
}
$this->registered['tasks'][$key] = ['key' => $key, 'label' => $label, 'explanation' => $explanation, 'interval' => $interval, 'callback' => $callback];
}
/**
* Eine öffentliche Seite im Rahmen der Website, zum Beispiel `/objekte/{slug}`.
*
* Der Callback bekommt die Platzhalter und gibt null zurück (dann 404) oder ein Array mit
* `title`, `description`, `blocks` und optional `seoTitle`, `robots`, `status`, `image`.
* Eine veröffentlichte Seite mit derselben Adresse geht vor.
*
* @param callable(array<string, string>): ?array $callback
*/
public function addPage(string $pattern, callable $callback): void
{
if (1 !== preg_match('~^(?:/(?:[a-z0-9]+(?:-[a-z0-9]+)*|\{[a-z]+\})){1,4}$~D', $pattern)) {
throw new \InvalidArgumentException(sprintf('Die Adresse „%s“ hat nicht die Form /name/{platzhalter}.', $pattern));
}
if (1 === preg_match('~^/(admin|media|assets|plugin-assets|formular|kategorie|schlagwort|beitraege|einwilligung)(/|$)~', $pattern)) {
throw new \InvalidArgumentException(sprintf('Die Adresse „%s“ gehört dem Kern.', $pattern));
}
$regex = '~^'.preg_replace('~\\\\\{([a-z]+)\\\\\}~', '(?P<$1>[a-z0-9]+(?:-[a-z0-9]+)*)', preg_quote($pattern, '~')).'$~D';
$this->registered['pages'][] = ['pattern' => $pattern, 'regex' => $regex, 'callback' => $callback];
}
/**
* Daten für die Twig-Vorlagen: `kant_plugin_data('name', 'schluessel', {…})`.
*
* @param callable(array): mixed $callback
*/
public function addData(string $key, callable $callback): void
{
$this->key($key);
$this->registered['data'][$key] = $callback;
}
/**
* Ein Bildschirm der Verwaltung, nur für Administratoren. Die Vorlage liegt im Pluginordner.
*
* @param callable(): array $callback liefert die Werte für die Vorlage
*/
public function addAdminPage(string $key, string $title, string $template, callable $callback): void
{
$this->key($key);
if (1 !== preg_match('~^[a-zA-Z0-9_/-]+\.html\.twig$~D', $template) || !is_file($this->directory.'/'.$template)) {
throw new \InvalidArgumentException(sprintf('Die Vorlage „%s“ fehlt im Pluginordner.', $template));
}
$this->registered['adminPages'][$key] = ['key' => $key, 'title' => $title, 'template' => '@plugins/'.$this->name.'/'.$template, 'callback' => $callback];
}
/**
* Ein Knopf auf den Bildschirmen des Plugins. Entweder startet er eine angemeldete Aufgabe
* (`$handler` ist ihr Schlüssel, die Sperre des Aufgabenplans gilt) oder ruft einen Callback,
* dessen Rückgabe als Meldung erscheint.
*
* @param string|callable(): string $handler
*/
public function addAdminAction(string $key, string $label, string|callable $handler): void
{
$this->key($key);
$this->registered['adminActions'][$key] = ['key' => $key, 'label' => $label, 'handler' => $handler];
}
// ---------------------------------------------------------------- Werkzeuge
/**
* Ruft eine HTTPS-Adresse ab. Wirft bei einem Netzfehler; ein HTTP-Fehler ist eine Antwort.
*
* @param array{userAgent?: string, timeout?: int, maxBytes?: int} $options
*
* @return array{status: int, body: string, contentType: string}
*/
public function fetch(string $url, array $options = []): array
{
if (!str_starts_with($url, 'https://') || false === filter_var($url, FILTER_VALIDATE_URL)) {
throw new \InvalidArgumentException('Abgerufen werden nur HTTPS-Adressen.');
}
if (!function_exists('curl_init')) {
throw new \RuntimeException('Auf diesem Hosting fehlt die PHP-Erweiterung cURL.');
}
$limit = min(self::MAX_BYTES, $options['maxBytes'] ?? self::MAX_BYTES);
$body = '';
$handle = curl_init($url);
curl_setopt_array($handle, [
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_CONNECTTIMEOUT => 8,
CURLOPT_TIMEOUT => max(1, min(60, $options['timeout'] ?? 20)),
CURLOPT_USERAGENT => $options['userAgent'] ?? 'KantSites-Plugin/'.$this->name,
CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use (&$body, $limit): int {
$body .= $chunk;
return strlen($body) > $limit ? 0 : strlen($chunk);
},
]);
if (false === curl_exec($handle)) {
throw new \RuntimeException('Abruf gescheitert: '.curl_error($handle));
}
return ['status' => (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE), 'body' => $body, 'contentType' => (string) curl_getinfo($handle, CURLINFO_CONTENT_TYPE)];
}
/**
* Legt Bilddaten in der Mediathek ab und gibt den Eintrag zurück.
*
* @throws \InvalidArgumentException wenn die Daten kein brauchbares Bild sind
*/
public function storeImage(string $bytes, string $title, string $alt = ''): array
{
$temp = tempnam(sys_get_temp_dir(), 'kant-plugin-');
if (false === $temp || false === file_put_contents($temp, $bytes)) {
throw new \RuntimeException('Die Bilddaten ließen sich nicht zwischenspeichern.');
}
try {
$item = $this->media->upload(new UploadedFile($temp, 'bild.jpg', null, null, true));
$this->media->update($item['id'], ['title' => mb_substr('' !== trim($title) ? $title : 'Bild', 0, 40), 'alt' => mb_substr($alt, 0, 150), 'archived' => false, 'version' => $item['version']]);
return $this->media->find($item['id']) ?? $item;
} finally {
@unlink($temp);
}
}
private function key(string $key): void
{
if (1 !== preg_match('/^[a-z][a-z0-9-]{0,40}$/D', $key)) {
throw new \InvalidArgumentException(sprintf('Der Schlüssel „%s“ besteht aus Kleinbuchstaben, Ziffern und Bindestrichen.', $key));
}
}
}
+15 -5
View File
@@ -16,8 +16,8 @@ use Doctrine\DBAL\Connection;
*
* Seit dem zweiten Teil von Arbeitspaket H (ADR 103) kann ein Plugin außerdem: Bausteine mit
* Kindern (Container), Einstellungen für den Betreiber, eigene öffentliche Adressen und
* Seitenvorlagen für den Editor. Alles davon ist Manifest plus Twig - PHP-Code bringt ein
* Plugin nicht mit, und deshalb gibt es auch keine Hooks in Speichern oder Veröffentlichen.
* Seitenvorlagen für den Editor. Seit ADR 127 darf es zusätzlich eine plugin.php mitbringen
* ("main" im Manifest); was die anmeldet, führt PluginRuntime aus.
*
* Eingeschaltet wird ein Plugin in der Verwaltung; der Schalter liegt in der Datenbank. Ein
* ausgeschaltetes oder fehlendes Plugin lässt seine Bausteine unangetastet im Dokument (ADR 011):
@@ -144,6 +144,12 @@ final class PluginRegistry
$this->write($name, ['settings' => json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)]);
}
/** Die Fassung, für die die plugin.php zuletzt eingerichtet wurde. */
public function markInstalled(string $name): void
{
$this->write($name, ['version' => $this->all()[$name]['version']]);
}
/** Die Einstellungen eines Plugins mit Vorgaben - leer, wenn es das Plugin nicht gibt. */
public function settingsOf(string $name): array
{
@@ -423,11 +429,15 @@ final class PluginRegistry
}
$routes = $this->routes($name, $data['routes'] ?? []);
$presets = $this->presets($name, $data['presets'] ?? []);
if ([] === $blocks && [] === $routes && [] === $presets) {
throw new \RuntimeException('Das Manifest braucht mindestens einen Baustein, eine Adresse oder eine Vorlage.');
$main = $data['main'] ?? null;
if (null !== $main && (!is_string($main) || 1 !== preg_match('~^[a-zA-Z0-9_-]+\.php$~D', $main) || !is_file($this->directory().'/'.$name.'/'.$main))) {
throw new \RuntimeException(sprintf('Die Datei „%s“ aus "main" fehlt im Pluginordner.', is_string($main) ? $main : ''));
}
if ([] === $blocks && [] === $routes && [] === $presets && null === $main) {
throw new \RuntimeException('Das Manifest braucht mindestens einen Baustein, eine Adresse, eine Vorlage oder eine plugin.php.');
}
return ['name' => $name, 'title' => $title, 'description' => mb_substr(trim((string) ($data['description'] ?? '')), 0, 300), 'version' => $version, 'requires' => $requires, 'blocks' => $blocks, 'settings' => $settings, 'routes' => $routes, 'presets' => $presets];
return ['main' => $main, 'name' => $name, 'title' => $title, 'description' => mb_substr(trim((string) ($data['description'] ?? '')), 0, 300), 'version' => $version, 'requires' => $requires, 'blocks' => $blocks, 'settings' => $settings, 'routes' => $routes, 'presets' => $presets];
}
private function blockDefinition(string $plugin, string $key, array $block, string $version): array
+256
View File
@@ -0,0 +1,256 @@
<?php
namespace App\Plugin;
use App\Content\DocumentValidator;
use App\Content\MediaLibrary;
use App\Ops\Tasks\Task;
use Doctrine\DBAL\Connection;
/**
* Lädt die plugin.php der aktiven Plugins und führt aus, was sie angemeldet haben (ADR 127).
*
* Ein Plugin, das beim Laden oder in einem Hook eine Ausnahme wirft, darf die Website nicht
* mitreißen: Es fällt für diese Anfrage aus, der Grund steht auf dem Bildschirm „Plugins“. Einen
* echten PHP-Abbruch fängt auch das nicht ab - dann hilft, den Ordner umzubenennen.
*/
final class PluginRuntime
{
/** @var array<string, PluginApi>|null */
private ?array $apis = null;
/** @var array<string, string> */
private array $errors = [];
public function __construct(private PluginRegistry $plugins, private Connection $db, private MediaLibrary $media)
{
}
/** Nach dem Ein- oder Ausschalten: beim nächsten Zugriff neu laden. */
public function reset(): void
{
$this->apis = null;
$this->errors = [];
}
/** @return array<string, string> Plugin => Grund, aus dem es gerade nicht läuft */
public function errors(): array
{
$this->apis();
return $this->errors;
}
/**
* Schaltet ein und richtet ein. Scheitert das Einrichten, bleibt das Plugin aus.
*
* @throws \InvalidArgumentException
*/
public function activate(string $name): void
{
$this->plugins->activate($name);
$this->reset();
if (isset($this->errors()[$name])) {
$reason = $this->errors()[$name];
$this->plugins->deactivate($name);
$this->reset();
throw new \InvalidArgumentException('Das Plugin lässt sich nicht laden: '.$reason);
}
try {
$this->install($this->apis()[$name] ?? null);
} catch (\Throwable $e) {
$this->plugins->deactivate($name);
$this->reset();
throw new \InvalidArgumentException('Das Plugin ließ sich nicht einrichten: '.$e->getMessage());
}
}
/** @throws \InvalidArgumentException */
public function deactivate(string $name): void
{
foreach ($this->apis()[$name]->registered['deactivate'] ?? [] as $callback) {
try {
$callback();
} catch (\Throwable $e) {
// Ausschalten muss immer gehen, gerade bei einem Plugin, das Fehler macht.
$this->errors[$name] = $e->getMessage();
}
}
$this->plugins->deactivate($name);
$this->reset();
}
/**
* Führt ein Ereignis aus. Der Fehler eines Plugins hält die übrigen nicht auf.
*/
public function doAction(string $hook, mixed ...$arguments): void
{
foreach ($this->hooks('actions', $hook) as [$name, $callback]) {
try {
$callback(...$arguments);
} catch (\Throwable $e) {
$this->errors[$name] = $e->getMessage();
}
}
}
/**
* Reicht einen Wert durch die Filter. Wirft einer, gilt der Wert von davor.
*/
public function applyFilters(string $hook, mixed $value, mixed ...$arguments): mixed
{
foreach ($this->hooks('filters', $hook) as [$name, $callback]) {
try {
$value = $callback($value, ...$arguments);
} catch (\Throwable $e) {
$this->errors[$name] = $e->getMessage();
}
}
return $value;
}
/** @return list<Task> */
public function tasks(): array
{
$tasks = [];
foreach ($this->apis() as $name => $api) {
foreach ($api->registered['tasks'] as $task) {
$tasks[] = new PluginTask(self::taskName($name, $task['key']), $task['label'], $task['explanation'], $task['interval'], $task['callback']);
}
}
return $tasks;
}
public static function taskName(string $plugin, string $key): string
{
return 'plugin-'.$plugin.'-'.$key;
}
/**
* Die Seite eines Plugins zu dieser Adresse, in der Form einer gespeicherten Seite - oder null.
*/
public function page(string $path): ?array
{
foreach ($this->apis() as $name => $api) {
foreach ($api->registered['pages'] as $page) {
if (1 !== preg_match($page['regex'], $path, $match)) {
continue;
}
try {
$result = $page['callback'](array_filter($match, 'is_string', ARRAY_FILTER_USE_KEY));
} catch (\Throwable $e) {
$this->errors[$name] = $e->getMessage();
return null;
}
return is_array($result) ? $this->pageRecord($name, $path, $result) : null;
}
}
return null;
}
public function data(string $plugin, string $key, array $arguments = []): mixed
{
$callback = $this->apis()[$plugin]->registered['data'][$key] ?? null;
if (null === $callback) {
return null;
}
try {
return $callback($arguments);
} catch (\Throwable $e) {
$this->errors[$plugin] = $e->getMessage();
return null;
}
}
/** @return array{pages: array<string, array>, actions: array<string, array>} */
public function admin(string $plugin): array
{
$api = $this->apis()[$plugin] ?? null;
return ['pages' => $api?->registered['adminPages'] ?? [], 'actions' => $api?->registered['adminActions'] ?? []];
}
/** @return array<string, PluginApi> nur Plugins, deren plugin.php fehlerfrei geladen wurde */
private function apis(): array
{
if (null !== $this->apis) {
return $this->apis;
}
$this->apis = [];
foreach ($this->plugins->all() as $name => $plugin) {
if (!$plugin['active'] || null !== $plugin['error'] || null === ($plugin['main'] ?? null)) {
continue;
}
$directory = $this->plugins->directory().'/'.$name;
$api = new PluginApi($name, $directory, $this->plugins, $this->db, $this->media);
try {
$boot = self::load($directory.'/'.$plugin['main']);
if (!is_callable($boot)) {
throw new \RuntimeException($plugin['main'].' gibt keine Funktion zurück.');
}
$boot($api);
// Eine neue Fassung im Ordner: einrichten, bevor etwas sie benutzt.
if ($plugin['activatedVersion'] !== $plugin['version']) {
$this->install($api);
$this->plugins->markInstalled($name);
}
$this->apis[$name] = $api;
} catch (\Throwable $e) {
$this->errors[$name] = $e->getMessage();
}
}
return $this->apis;
}
/** In einer eigenen statischen Funktion, damit die Datei nichts von hier sieht. */
private static function load(string $file): mixed
{
return require $file;
}
private function install(?PluginApi $api): void
{
foreach ($api?->registered['install'] ?? [] as $callback) {
$callback();
}
}
/** @return list<array{string, callable}> nach Priorität, bei Gleichstand in Reihenfolge der Plugins */
private function hooks(string $kind, string $hook): array
{
$found = [];
foreach ($this->apis() as $name => $api) {
foreach ($api->registered[$kind][$hook] ?? [] as [$priority, $callback]) {
$found[] = [$priority, $name, $callback];
}
}
usort($found, static fn (array $a, array $b): int => $a[0] <=> $b[0]);
return array_map(static fn (array $entry): array => [$entry[1], $entry[2]], $found);
}
private function pageRecord(string $plugin, string $path, array $result): array
{
return [
'id' => 'plugin-'.$plugin, 'path' => ltrim($path, '/'), 'type' => 'page', 'locale' => 'de',
'seo_title' => (string) ($result['seoTitle'] ?? ''),
'robots' => 'noindex' === ($result['robots'] ?? 'index') ? 'noindex' : 'index',
'status' => (int) ($result['status'] ?? 200),
'og_media_id' => $result['image'] ?? null,
'header_area' => null, 'footer_area' => null,
'document' => [
'schemaVersion' => DocumentValidator::SCHEMA_VERSION,
'title' => (string) ($result['title'] ?? ''),
'description' => (string) ($result['description'] ?? ''),
'blocks' => $result['blocks'] ?? [],
],
];
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Plugin;
use App\Ops\Tasks\Task;
/** Eine Aufgabe, die ein Plugin angemeldet hat, im Vertrag des Aufgabenplans. */
final class PluginTask implements Task
{
/** @param callable(): string $callback */
public function __construct(
private string $name,
private string $label,
private string $explanation,
private int $interval,
private $callback,
) {
}
public function name(): string
{
return $this->name;
}
public function label(): string
{
return $this->label;
}
public function explanation(): string
{
return $this->explanation;
}
public function interval(): int
{
return $this->interval;
}
public function run(): string
{
return (string) ($this->callback)();
}
}
+4 -4
View File
@@ -81,8 +81,8 @@ final class LanguageExtension extends AbstractExtension
'Beiträge in der Kategorie „%s“.' => 'Posts in the category “%s”.',
'Beiträge zum Schlagwort „%s“.' => 'Posts tagged “%s”.',
'von' => 'by',
'← Neuere' => '← Newer',
'Ältere →' => 'Older →',
'Neuere' => 'Newer',
'Ältere' => 'Older',
'Seite %d von %d' => 'Page %d of %d',
'Externe Inhalte und Einwilligung' => 'External content and consent',
'Diese Website lädt bestimmte Inhalte erst, wenn du zustimmst. Ohne Zustimmung wird die Seite vollständig angezeigt, nur eingebettete Inhalte anderer Anbieter bleiben leer.' => 'This website only loads certain content once you agree. Without consent the page is shown in full; only embedded content from other providers stays empty.',
@@ -173,8 +173,8 @@ final class LanguageExtension extends AbstractExtension
'Beiträge in der Kategorie „%s“.' => '„%s“ kategorisindeki yazılar.',
'Beiträge zum Schlagwort „%s“.' => '„%s“ etiketli yazılar.',
'von' => 'yazan:',
'← Neuere' => '← Daha yeni',
'Ältere →' => 'Daha eski →',
'Neuere' => 'Daha yeni',
'Ältere' => 'Daha eski',
'Seite %d von %d' => 'Sayfa %d / %d',
'Externe Inhalte und Einwilligung' => 'Harici içerikler ve onay',
'Diese Website lädt bestimmte Inhalte erst, wenn du zustimmst. Ohne Zustimmung wird die Seite vollständig angezeigt, nur eingebettete Inhalte anderer Anbieter bleiben leer.' => 'Bu web sitesi bazı içerikleri ancak onayınızdan sonra yükler. Onay vermeseniz de sayfa eksiksiz görüntülenir; yalnızca diğer sağlayıcıların yerleştirilmiş içerikleri boş kalır.',
+3 -1
View File
@@ -5,6 +5,7 @@ namespace App\Twig;
use App\Content\MediaLibrary;
use App\Domain\InvalidDocument;
use App\Plugin\PluginRegistry;
use App\Plugin\PluginRuntime;
use Twig\Extension\AbstractExtension;
use Twig\Loader\FilesystemLoader;
use Twig\TwigFunction;
@@ -15,7 +16,7 @@ use Twig\TwigFunction;
*/
final class PluginExtension extends AbstractExtension
{
public function __construct(private PluginRegistry $plugins, private MediaLibrary $media, FilesystemLoader $loader)
public function __construct(private PluginRegistry $plugins, private MediaLibrary $media, FilesystemLoader $loader, private PluginRuntime $runtime)
{
// Der Namensraum @plugins zeigt auf den Pluginordner - aber nur, wenn es ihn gibt. Ein
// fester Pfad in twig.yaml liesse eine Installation ohne plugins/ mit einem Ladefehler
@@ -33,6 +34,7 @@ final class PluginExtension extends AbstractExtension
new TwigFunction('kant_plugin_props', $this->props(...)),
new TwigFunction('kant_plugin_media', $this->mediaItem(...)),
new TwigFunction('kant_plugin_settings', $this->plugins->settingsOf(...)),
new TwigFunction('kant_plugin_data', $this->runtime->data(...)),
new TwigFunction('kant_media', $this->adminMedia(...)),
];
}
+19
View File
@@ -0,0 +1,19 @@
{% extends 'admin/shell.html.twig' %}
{% set nav = 'plugins' %}
{% block title %}{{ page.title }}{% endblock %}
{% block heading %}{{ page.title }}{% endblock %}
{% block lead %}{{ plugin.title }} · <a href="{{ path('admin_plugins') }}">Zurück zu den Plugins</a>{% endblock %}
{% block content %}
{% if actions %}
<div class="favicon-actions">
{% for action in actions %}
<form method="post" action="{{ path('admin_plugin_action', {name: plugin.name, key: action.key}) }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('plugins') }}">
<input type="hidden" name="zurueck" value="{{ page.key }}">
<button class="secondary" type="submit">{{ action.label }}</button>
</form>
{% endfor %}
</div>
{% endif %}
{% include page.template with data only %}
{% endblock %}
+3
View File
@@ -56,6 +56,9 @@
<dd>{% if plugin.active %}<a href="{{ route.path }}">{{ route.path }}</a>{% else %}<code>{{ route.path }}</code>{% endif %} · {{ route.type }}</dd>
{% endfor %}
</dl>
{% if plugin.main %}<p class="muted">Führt eigenen PHP-Code mit den vollen Rechten der Website aus. Nur einschalten, wenn du der Quelle vertraust.</p>{% endif %}
{% if runtimeErrors[plugin.name] is defined %}<p class="field-error">Fehler im Plugin: {{ runtimeErrors[plugin.name] }}</p>{% endif %}
{% for page in admin[plugin.name].pages %}<p><a href="{{ path('admin_plugin_page', {name: plugin.name, key: page.key}) }}">{{ page.title }}</a></p>{% endfor %}
{% if plugin.active and plugin.settings is not empty %}
<form method="post" action="{{ path('admin_plugin_settings', {name: plugin.name}) }}" class="plugin-settings">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('plugins') }}">
+1 -1
View File
@@ -78,7 +78,7 @@
{% elseif type == 'quote' %}
<figure id="{{ block.id }}" class="block-quote{{ designClasses }}">
{% if props.rating|default(0) > 0 %}
<p class="quote-rating"><span class="visually-hidden">Bewertung: {{ props.rating }} von 5</span><span aria-hidden="true">{% for i in 1..5 %}{{ i <= props.rating ? '★' : '☆' }}{% endfor %}</span></p>
<p class="quote-rating"><span class="visually-hidden">Bewertung: {{ props.rating }} von 5</span><span aria-hidden="true">{% for i in 1..5 %}<svg class="{{ i <= props.rating ? 'quote-star-voll' : 'quote-star' }}" viewBox="0 0 24 24" focusable="false"><path d="m12 3 2.7 5.9 6.3.7-4.7 4.3 1.3 6.1L12 17l-5.6 3 1.3-6.1L3 9.6l6.3-.7Z"/></svg>{% endfor %}</span></p>
{% endif %}
<blockquote><p>{{ props.text }}</p></blockquote>
<figcaption>{{ props.author }}{% if props.role|default('') %} <span>{{ props.role }}</span>{% endif %}</figcaption>
+2 -2
View File
@@ -41,8 +41,8 @@
{% if pages|default(1) > 1 %}
{# Dieselbe Blätterleiste wie im Archiv: eine Liste, die mehr hat als sie zeigt, sagt es. #}
<nav class="post-pager" aria-label="{{ 'Weitere Beiträge'|kant_t }}">
{% if page > 1 %}<a rel="prev" href="?seite={{ page - 1 }}">{{ '← Neuere'|kant_t }}</a>{% endif %}
{% if page > 1 %}<a rel="prev" href="?seite={{ page - 1 }}">{{ 'Neuere'|kant_t }}</a>{% endif %}
<span>{{ 'Seite %d von %d'|kant_t|format(page, pages) }}{% if total %} · {{ total }} {{ 'Beiträge'|kant_t }}{% endif %}</span>
{% if page < pages %}<a rel="next" href="?seite={{ page + 1 }}">{{ 'Ältere →'|kant_t }}</a>{% endif %}
{% if page < pages %}<a rel="next" href="?seite={{ page + 1 }}">{{ 'Ältere'|kant_t }}</a>{% endif %}
</nav>
{% endif %}
+1 -1
View File
@@ -125,7 +125,7 @@
{# Ein Anker, kein Skript. Er springt an den Anfang - das kann ein Browser allein. Der nav-Rahmen
gibt ihm eine Landmarke: ein Verweis außerhalb jeder Landmarke ist für Vorlesesoftware
Inhalt ohne Ort, und axe meldet ihn als Verstoß gegen „region“. #}
<nav aria-label="{{ oben.scrollTopLabel|default('Nach oben')|kant_t }}"><a class="scroll-top{{ oben.scrollTopPosition|default('rechts') == 'links' ? ' scroll-top-links' : '' }}" href="#seitenanfang" aria-label="{{ oben.scrollTopLabel|default('Nach oben')|kant_t }}"><span aria-hidden="true">↑</span></a></nav>
<nav aria-label="{{ oben.scrollTopLabel|default('Nach oben')|kant_t }}"><a class="scroll-top{{ oben.scrollTopPosition|default('rechts') == 'links' ? ' scroll-top-links' : '' }}" href="#seitenanfang" aria-label="{{ oben.scrollTopLabel|default('Nach oben')|kant_t }}"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 20V4.5m-6.5 6.5L12 4.5l6.5 6.5"/></svg></a></nav>
{% endif %}
</body>
</html>
+162
View File
@@ -0,0 +1,162 @@
<?php
namespace App\Tests;
use App\Content\MediaLibrary;
use App\Infrastructure\ConnectionFactory;
use App\Ops\Scheduler;
use App\Plugin\PluginRegistry;
use App\Plugin\PluginRuntime;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\TestCase;
/** Plugins mit eigenem PHP-Code (ADR 127): laden, Hooks, Aufgaben, Seiten, Fehler eingrenzen. */
final class PluginServerTest extends TestCase
{
private string $root;
private Connection $db;
private PluginRegistry $plugins;
private PluginRuntime $runtime;
protected function setUp(): void
{
$this->root = sys_get_temp_dir().'/kant-plugin-server-'.bin2hex(random_bytes(6));
mkdir($this->root.'/plugins', 0o775, true);
file_put_contents($this->root.'/VERSION', "1.39.0\n");
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->root).'/test.db');
TestSchema::build($this->db);
$this->fresh();
}
protected function tearDown(): void
{
unset($this->runtime, $this->plugins);
$this->db->close();
$remove = static function (string $path) use (&$remove): void {
foreach (glob($path.'/*') ?: [] as $entry) {
is_dir($entry) ? $remove($entry) : @unlink($entry);
}
@rmdir($path);
};
$remove($this->root);
}
public function testAPluginRegistersHooksTasksPagesAndItsOwnTable(): void
{
$this->plugin('demo', <<<'PHP'
$api->onInstall(fn () => $api->db()->executeStatement('CREATE TABLE IF NOT EXISTS '.$api->table('zeile').' (wert TEXT)'));
$api->addFilter('liste', fn (array $w) => [...$w, 'spaet'], 20);
$api->addFilter('liste', fn (array $w) => [...$w, 'frueh'], 5);
$api->addTask('takt', 'Takt', 'Erklaerung', 600, fn () => 'gelaufen');
$api->addPage('/objekte/{slug}', fn (array $p) => 'gibt-es' === $p['slug'] ? ['title' => 'T', 'description' => 'D', 'blocks' => [], 'status' => 410, 'robots' => 'noindex'] : null);
$api->addData('zahl', fn (array $a) => $a['x'] * 2);
PHP);
$this->runtime->activate('demo');
$this->db->executeStatement('INSERT INTO plugin_demo_zeile (wert) VALUES (?)', ['ja']);
self::assertSame(['frueh', 'spaet'], $this->runtime->applyFilters('liste', []));
self::assertSame(['plugin-demo-takt'], array_map(static fn ($t) => $t->name(), $this->runtime->tasks()));
self::assertSame('gelaufen', $this->runtime->tasks()[0]->run());
self::assertSame(42, $this->runtime->data('demo', 'zahl', ['x' => 21]));
$seite = $this->runtime->page('/objekte/gibt-es');
self::assertSame(410, $seite['status']);
self::assertSame('noindex', $seite['robots']);
self::assertSame('objekte/gibt-es', $seite['path']);
self::assertNull($this->runtime->page('/objekte/gibt-es-nicht'));
self::assertNull($this->runtime->page('/objekte/a/b'));
// Der Aufgabenplan kennt die Aufgabe, und nach dem Ausschalten nicht mehr.
$plan = new Scheduler($this->db, [], $this->root, $this->runtime);
$plan->pluginsChanged();
self::assertSame('plugin-demo-takt', $this->db->fetchOne('SELECT name FROM scheduled_task'));
self::assertSame('ok', $plan->runNow('plugin-demo-takt')['status']);
$this->runtime->deactivate('demo');
$plan->pluginsChanged();
self::assertFalse($this->db->fetchOne('SELECT name FROM scheduled_task'));
self::assertSame([], $this->runtime->applyFilters('liste', []));
// Eigene Tabellen überleben das Ausschalten.
self::assertSame('ja', $this->db->fetchOne('SELECT wert FROM plugin_demo_zeile'));
}
public function testAFaultyPluginStaysContained(): void
{
$this->plugin('kaputt', 'throw new \RuntimeException("Beim Laden gescheitert");');
$this->plugin('wackel', <<<'PHP'
$api->addFilter('wert', function ($w) { throw new \RuntimeException('Filterfehler'); });
$api->addFilter('wert', fn ($w) => $w.'+gut', 20);
$api->addPage('/wackel', function () { throw new \RuntimeException('Seitenfehler'); });
PHP);
try {
$this->runtime->activate('kaputt');
self::fail('Ein Plugin, das nicht lädt, darf nicht eingeschaltet bleiben.');
} catch (\InvalidArgumentException $e) {
self::assertStringContainsString('Beim Laden gescheitert', $e->getMessage());
}
self::assertFalse($this->plugins->all()['kaputt']['active']);
$this->runtime->activate('wackel');
self::assertSame('x+gut', $this->runtime->applyFilters('wert', 'x'));
self::assertNull($this->runtime->page('/wackel'));
self::assertSame('Seitenfehler', $this->runtime->errors()['wackel']);
}
public function testAFailedInstallLeavesThePluginOff(): void
{
$this->plugin('demo', '$api->onInstall(function () { throw new \RuntimeException("Keine Tabelle"); });');
$this->expectException(\InvalidArgumentException::class);
try {
$this->runtime->activate('demo');
} finally {
self::assertFalse($this->plugins->all()['demo']['active']);
}
}
public function testANewVersionRunsTheInstallAgainOnce(): void
{
$code = '$api->onInstall(fn () => $api->db()->executeStatement("INSERT INTO media (id, title, alt, variants, archived, version, created_at) VALUES (lower(hex(randomblob(16))), \'t\', \'\', \'[]\', 0, 1, \'2026-01-01 00:00:00\')"));';
$this->plugin('demo', $code);
$this->runtime->activate('demo');
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'));
$this->plugin('demo', $code, '1.1.0');
$this->fresh();
$this->runtime->tasks();
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'));
$this->fresh();
$this->runtime->tasks();
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'));
}
public function testAPluginCannotTakeCoreAddressesOrOtherFolders(): void
{
$this->plugin('demo', '$api->addPage("/admin/{x}", fn () => null);');
try {
$this->runtime->activate('demo');
self::fail('Die Verwaltung gehört dem Kern.');
} catch (\InvalidArgumentException $e) {
self::assertStringContainsString('gehört dem Kern', $e->getMessage());
}
file_put_contents($this->root.'/plugins/demo/plugin.json', json_encode(['name' => 'demo', 'title' => 'D', 'version' => '1.0.0', 'main' => '../fremd.php'], JSON_THROW_ON_ERROR));
$this->fresh();
self::assertNotNull($this->plugins->all()['demo']['error']);
}
private function fresh(): void
{
$this->plugins = new PluginRegistry($this->db, $this->root);
$this->runtime = new PluginRuntime($this->plugins, $this->db, new MediaLibrary($this->db, $this->root.'/media'));
}
private function plugin(string $name, string $body, string $version = '1.0.0'): void
{
@mkdir($this->root.'/plugins/'.$name);
file_put_contents($this->root.'/plugins/'.$name.'/plugin.json', json_encode(['name' => $name, 'title' => $name, 'version' => $version, 'main' => 'plugin.php'], JSON_THROW_ON_ERROR));
file_put_contents($this->root.'/plugins/'.$name.'/plugin.php', "<?php\nreturn static function (\\App\\Plugin\\PluginApi \$api): void {\n".$body."\n};\n");
clearstatcache();
if (function_exists('opcache_invalidate')) {
opcache_invalidate($this->root.'/plugins/'.$name.'/plugin.php', true);
}
}
}