Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b60e599dda | ||
|
|
791c265f42 | ||
|
|
78083fac6a | ||
|
|
b9740e6c1b | ||
|
|
2adcaab5c2 | ||
|
|
9825bac3b3 | ||
|
|
b61f958ca1 |
No files matched your search
@@ -30,7 +30,7 @@ services:
|
|||||||
tags: ['kant.task']
|
tags: ['kant.task']
|
||||||
App\:
|
App\:
|
||||||
resource: '../src/'
|
resource: '../src/'
|
||||||
exclude: '../src/{Kernel.php,Domain/}'
|
exclude: '../src/{Kernel.php,Domain/,Plugin/PluginApi.php,Plugin/PluginTask.php}'
|
||||||
Doctrine\DBAL\Connection:
|
Doctrine\DBAL\Connection:
|
||||||
factory: ['App\Infrastructure\ConnectionFactory', 'create']
|
factory: ['App\Infrastructure\ConnectionFactory', 'create']
|
||||||
arguments: ['%env(projectdir:DATABASE_URL)%', '@App\Infrastructure\QueryCounter']
|
arguments: ['%env(projectdir:DATABASE_URL)%', '@App\Infrastructure\QueryCounter']
|
||||||
@@ -156,6 +156,7 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$tasks: !tagged_iterator kant.task
|
$tasks: !tagged_iterator kant.task
|
||||||
$projectDir: '%kernel.project_dir%'
|
$projectDir: '%kernel.project_dir%'
|
||||||
|
$plugins: '@App\Plugin\PluginRuntime'
|
||||||
App\Ops\Zentrale:
|
App\Ops\Zentrale:
|
||||||
arguments:
|
arguments:
|
||||||
$root: '%kernel.project_dir%'
|
$root: '%kernel.project_dir%'
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
Stand: 27.10.2026. Arbeitstitel KantSites; Produktname offen.
|
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)
|
## 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.
|
**Kontext:** Bei Luximmo fiel auf, dass LocalBusiness immer der allgemeine Typ war und weder Logo noch Koordinaten kannte.
|
||||||
|
|||||||
+46
-3
@@ -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**
|
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`.
|
(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
|
Ein Plugin kann reines Manifest plus Twig sein. Braucht es mehr – eigene Tabellen, Aufgaben im
|
||||||
Hooks in Speichern oder Veröffentlichen und keine eigenen Tabellen – und deshalb kann ein Update
|
Takt, Seiten aus eigenen Daten, Verwaltungsbildschirme –, bringt es wie bei WordPress eine
|
||||||
des Kerns ein Plugin nicht brechen, solange die Übergaben unten stehen.
|
`plugin.php` mit (siehe „Eigener PHP-Code“, ADR 127).
|
||||||
|
|
||||||
## Der Ordner
|
## 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
|
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).
|
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
|
## Die Vorlagen
|
||||||
|
|
||||||
### Bausteine
|
### Bausteine
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# Projektstatus
|
# Projektstatus
|
||||||
|
|
||||||
|
## 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)
|
## 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.
|
**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 +22,10 @@
|
|||||||
|
|
||||||
Die Ausgabe auf einer Live-Seite braucht mindestens Fassung 1.38.0; 1.37.0 ignoriert die Werte.
|
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)
|
## 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“.
|
**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“.
|
||||||
|
|||||||
@@ -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.
|
* Der Bildbetrachter.
|
||||||
*
|
*
|
||||||
@@ -23,9 +26,9 @@
|
|||||||
<img class="zoom-image" alt="">
|
<img class="zoom-image" alt="">
|
||||||
<figcaption class="zoom-caption"></figcaption>
|
<figcaption class="zoom-caption"></figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
<button class="zoom-close" type="button" aria-label="Schließen">×</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">‹</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">›</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>
|
<p class="zoom-counter" aria-live="polite"></p>
|
||||||
`;
|
`;
|
||||||
document.body.append(dialog);
|
document.body.append(dialog);
|
||||||
@@ -138,7 +141,7 @@
|
|||||||
<p class="pdf-title" id="pdf-dialog-title"></p>
|
<p class="pdf-title" id="pdf-dialog-title"></p>
|
||||||
<a class="pdf-action pdf-download" download>Herunterladen</a>
|
<a class="pdf-action pdf-download" download>Herunterladen</a>
|
||||||
<a class="pdf-action pdf-open" target="_blank" rel="noopener">Im neuen Tab</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>
|
</div>
|
||||||
<iframe class="pdf-frame" title="PDF-Dokument"></iframe>
|
<iframe class="pdf-frame" title="PDF-Dokument"></iframe>
|
||||||
`;
|
`;
|
||||||
|
|||||||
+17
-7
@@ -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";
|
@charset "UTF-8";
|
||||||
/* Lange Wörter dürfen umbrechen, damit nichts aus der Spalte läuft. Getrennt wird nur im
|
/* 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. */
|
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 a:hover,.menu-list summary:hover{text-decoration:underline}
|
||||||
.menu-list [aria-current=page]{font-weight:700;text-decoration:none}
|
.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{cursor:pointer;padding:6px 2px;list-style:none}
|
||||||
.menu-branch>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{content:' ▴'}
|
.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-branch-body{display:grid;gap:2px;padding:6px 0 6px 14px}
|
||||||
.menu-level-2{flex-direction:column;align-items:flex-start;gap:2px}
|
.menu-level-2{flex-direction:column;align-items:flex-start;gap:2px}
|
||||||
|
|
||||||
/* Posts */
|
/* 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-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-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-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)}
|
.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 blockquote p{margin:0;font-size:inherit;line-height:1.65}
|
||||||
.block-quote figcaption{font-size:14px;font-weight:600}
|
.block-quote figcaption{font-size:14px;font-weight:600}
|
||||||
.block-quote figcaption span{font-weight:400;opacity:.85}
|
.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{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}
|
.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}
|
.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}}
|
@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 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{text-decoration:none}
|
||||||
.block-breadcrumb a:hover{text-decoration:underline}
|
.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}
|
.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}
|
.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{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::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{content:"▶"}
|
.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:focus-visible{outline:3px solid #fff;outline-offset:2px}
|
||||||
.video-toggle[hidden]{display:none}
|
.video-toggle[hidden]{display:none}
|
||||||
.block-html{max-width:100%}
|
.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,.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: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: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-close{top:18px;right:18px}
|
||||||
.zoom-prev{left:18px;top:50%;transform:translateY(-50%)}
|
.zoom-prev{left:18px;top:50%;transform:translateY(-50%)}
|
||||||
.zoom-next{right: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}
|
.tone-ink,.tone-mint{background:transparent;color:#000}
|
||||||
[data-step][hidden],.block-form [hidden]{display:block!important}
|
[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}
|
||||||
@@ -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)]));
|
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 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 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 = [];
|
const dots = [];
|
||||||
if (slider.classList.contains('slider-dots')) {
|
if (slider.classList.contains('slider-dots')) {
|
||||||
const group = doc.createElement('div'); group.className = 'slider-control-dots'; controls.append(group);
|
const group = doc.createElement('div'); group.className = 'slider-control-dots'; controls.append(group);
|
||||||
slides.forEach((slide, i) => {
|
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;
|
const play = seconds >= 3 ? button('', 'Automatische Wiedergabe pausieren', () => { playing = !playing; update(); schedule(); }) : null;
|
||||||
|
|||||||
@@ -452,8 +452,8 @@ $kopf = [
|
|||||||
]],
|
]],
|
||||||
// Goldene Linie, die beim Überfahren wächst; das Klappmenü liegt als Fläche
|
// Goldene Linie, die beim Überfahren wächst; das Klappmenü liegt als Fläche
|
||||||
// unter dem ganzen Kopf.
|
// unter dem ganzen Kopf.
|
||||||
'css' => "& .menu-list{gap:4px 28px;align-items:center}\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}\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::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-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"
|
."& .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', [
|
$column('fuss-seiten', [
|
||||||
$kleinzeile('fuss-seiten-titel', 'Leistungen', '@gold-dunkel', '8px'),
|
$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']],
|
'desktop' => ['display' => 'grid', 'gridColumns' => 3, 'gap' => '40px', 'padding' => ['top' => '56px', 'bottom' => '40px']],
|
||||||
@@ -649,7 +649,10 @@ $startseite = [
|
|||||||
$section('leistungen-bereich', [
|
$section('leistungen-bereich', [
|
||||||
$zeile('leistungen-kopf', [
|
$zeile('leistungen-kopf', [
|
||||||
$heading('leistungen-titel', 'Leistungen', 2, $h2Stil),
|
$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']]),
|
], '16px', ['justifyContent' => 'space-between', 'alignItems' => 'baseline', 'flexWrap' => 'wrap', 'margin' => ['bottom' => '56px']], ['margin' => ['bottom' => '36px']]),
|
||||||
$column('leistungen-raster', [
|
$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.', [
|
$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', [
|
$column('kontakt-mehr', [
|
||||||
$kleinzeile('kontakt-mehr-titel', 'Direkt', '@gold-dunkel', '8px'),
|
$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']),
|
], '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.
|
// 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}";
|
$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([
|
$anlegen('immobilienbewertung', $einblenden([
|
||||||
'schemaVersion' => $SCHEMA, 'title' => 'Immobilienbewertung',
|
'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.',
|
'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', [
|
$menus->save('header', 'Hauptnavigation', [
|
||||||
$anker('Leistungen', 'leistungen'),
|
$anker('Leistungen', 'leistungen'),
|
||||||
$anker('Über uns', 'ueber-uns'),
|
$anker('Über uns', 'ueber-uns'),
|
||||||
|
['label' => 'Immobilien', 'type' => 'page', 'pageId' => $seiteId('immobilien'), 'children' => []],
|
||||||
['label' => 'Bewertung', 'type' => 'page', 'pageId' => $seiteId('immobilienbewertung'), 'children' => []],
|
['label' => 'Bewertung', 'type' => 'page', 'pageId' => $seiteId('immobilienbewertung'), 'children' => []],
|
||||||
['label' => 'Anfrage', 'type' => 'page', 'pageId' => $seiteId('verwaltungsanfrage'), 'children' => []],
|
['label' => 'Anfrage', 'type' => 'page', 'pageId' => $seiteId('verwaltungsanfrage'), 'children' => []],
|
||||||
], 0);
|
], 0);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Content\PageRepository;
|
|||||||
use App\Content\SiteSettings;
|
use App\Content\SiteSettings;
|
||||||
use App\Content\Turnstile;
|
use App\Content\Turnstile;
|
||||||
use App\Ops\MailCheck;
|
use App\Ops\MailCheck;
|
||||||
|
use App\Plugin\PluginRuntime;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
@@ -33,6 +34,7 @@ final class FormController extends AbstractController
|
|||||||
private string $secret,
|
private string $secret,
|
||||||
private string $siteOrigin,
|
private string $siteOrigin,
|
||||||
private Languages $languages,
|
private Languages $languages,
|
||||||
|
private PluginRuntime $plugins,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +43,8 @@ final class FormController extends AbstractController
|
|||||||
{
|
{
|
||||||
$form = $this->forms->findBySlug($slug);
|
$form = $this->forms->findBySlug($slug);
|
||||||
$path = trim($request->request->getString('page'), '/') ?: $this->settings->homePath();
|
$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) {
|
if (null === $form || null === $page) {
|
||||||
throw $this->createNotFoundException('Formular oder Seite nicht gefunden.');
|
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));
|
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) {
|
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);
|
$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);
|
$this->forms->markDelivered($submission);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,9 +107,12 @@ final class FormController extends AbstractController
|
|||||||
return $age >= FormRepository::MIN_SECONDS && $age <= FormRepository::MAX_SECONDS;
|
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 = [];
|
$lines = [];
|
||||||
|
foreach ($extra as $name => $text) {
|
||||||
|
$lines[] = $name.': '.$text;
|
||||||
|
}
|
||||||
foreach ($form['fields'] as $field) {
|
foreach ($form['fields'] as $field) {
|
||||||
// Ein Schritt ist eine Überschrift in der Post; ein Feld, das nicht galt, bleibt weg.
|
// Ein Schritt ist eine Überschrift in der Post; ein Feld, das nicht galt, bleibt weg.
|
||||||
if ('step' === $field['type']) {
|
if ('step' === $field['type']) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use App\Content\Theme;
|
|||||||
use App\Content\Translations;
|
use App\Content\Translations;
|
||||||
use App\Content\Turnstile;
|
use App\Content\Turnstile;
|
||||||
use App\Plugin\PluginRegistry;
|
use App\Plugin\PluginRegistry;
|
||||||
|
use App\Plugin\PluginRuntime;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
@@ -29,7 +30,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
|||||||
|
|
||||||
final class PageController extends AbstractController
|
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) {
|
if (null !== $route) {
|
||||||
return $this->pluginRoute($request, $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);
|
$redirect = $this->redirects->find($path);
|
||||||
if (null !== $redirect && 301 === $redirect['status']) {
|
if (null !== $redirect && 301 === $redirect['status']) {
|
||||||
return $this->shell($this->redirect('/'.$redirect['to'], 301));
|
return $this->shell($this->redirect('/'.$redirect['to'], 301));
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Ops\Scheduler;
|
||||||
use App\Plugin\PluginRegistry;
|
use App\Plugin\PluginRegistry;
|
||||||
|
use App\Plugin\PluginRuntime;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
@@ -14,7 +16,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
|||||||
*/
|
*/
|
||||||
final class PluginAdminController extends AbstractController
|
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.
|
// Ein Plugin bringt Code auf die Website - das schaltet nur ein Administrator.
|
||||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
$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'])]
|
#[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 {
|
try {
|
||||||
if ('an' === $state) {
|
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');
|
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');
|
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) {
|
} 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');
|
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
|
private function back(string $message, string $type = 'error'): Response
|
||||||
{
|
{
|
||||||
$this->addFlash($type, $message);
|
$this->addFlash($type, $message);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
|||||||
*/
|
*/
|
||||||
final class SeoController extends AbstractController
|
final class SeoController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(private \App\Plugin\PluginRuntime $plugins,
|
||||||
private Connection $db,
|
private Connection $db,
|
||||||
private SiteSettings $settings,
|
private SiteSettings $settings,
|
||||||
private PostRepository $posts,
|
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[] = ['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]));
|
return $this->xml($this->renderView('seo/sitemap.xml.twig', ['entries' => $entries]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ final class Scheduler
|
|||||||
private Connection $db,
|
private Connection $db,
|
||||||
private iterable $tasks,
|
private iterable $tasks,
|
||||||
private string $projectDir,
|
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
|
* @return bool false, wenn die Tabelle (noch) nicht da ist - vor der Installation und
|
||||||
* zwischen hochgeladenem Code und ausgefuehrter Migration
|
* 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
|
public function sync(?\DateTimeImmutable $now = null): bool
|
||||||
{
|
{
|
||||||
$now ??= self::now();
|
$now ??= self::now();
|
||||||
@@ -396,6 +405,9 @@ final class Scheduler
|
|||||||
foreach ($this->tasks as $task) {
|
foreach ($this->tasks as $task) {
|
||||||
$registry[$task->name()] = $task;
|
$registry[$task->name()] = $task;
|
||||||
}
|
}
|
||||||
|
foreach ($this->plugins?->tasks() ?? [] as $task) {
|
||||||
|
$registry[$task->name()] = $task;
|
||||||
|
}
|
||||||
ksort($registry);
|
ksort($registry);
|
||||||
|
|
||||||
return $this->registry = $registry;
|
return $this->registry = $registry;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,8 +16,8 @@ use Doctrine\DBAL\Connection;
|
|||||||
*
|
*
|
||||||
* Seit dem zweiten Teil von Arbeitspaket H (ADR 103) kann ein Plugin außerdem: Bausteine mit
|
* 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
|
* 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
|
* Seitenvorlagen für den Editor. Seit ADR 127 darf es zusätzlich eine plugin.php mitbringen
|
||||||
* Plugin nicht mit, und deshalb gibt es auch keine Hooks in Speichern oder Veröffentlichen.
|
* ("main" im Manifest); was die anmeldet, führt PluginRuntime aus.
|
||||||
*
|
*
|
||||||
* Eingeschaltet wird ein Plugin in der Verwaltung; der Schalter liegt in der Datenbank. Ein
|
* 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):
|
* 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)]);
|
$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. */
|
/** Die Einstellungen eines Plugins mit Vorgaben - leer, wenn es das Plugin nicht gibt. */
|
||||||
public function settingsOf(string $name): array
|
public function settingsOf(string $name): array
|
||||||
{
|
{
|
||||||
@@ -423,11 +429,15 @@ final class PluginRegistry
|
|||||||
}
|
}
|
||||||
$routes = $this->routes($name, $data['routes'] ?? []);
|
$routes = $this->routes($name, $data['routes'] ?? []);
|
||||||
$presets = $this->presets($name, $data['presets'] ?? []);
|
$presets = $this->presets($name, $data['presets'] ?? []);
|
||||||
if ([] === $blocks && [] === $routes && [] === $presets) {
|
$main = $data['main'] ?? null;
|
||||||
throw new \RuntimeException('Das Manifest braucht mindestens einen Baustein, eine Adresse oder eine Vorlage.');
|
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
|
private function blockDefinition(string $plugin, string $key, array $block, string $version): array
|
||||||
|
|||||||
@@ -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'] ?? [],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,8 +81,8 @@ final class LanguageExtension extends AbstractExtension
|
|||||||
'Beiträge in der Kategorie „%s“.' => 'Posts in the category “%s”.',
|
'Beiträge in der Kategorie „%s“.' => 'Posts in the category “%s”.',
|
||||||
'Beiträge zum Schlagwort „%s“.' => 'Posts tagged “%s”.',
|
'Beiträge zum Schlagwort „%s“.' => 'Posts tagged “%s”.',
|
||||||
'von' => 'by',
|
'von' => 'by',
|
||||||
'← Neuere' => '← Newer',
|
'Neuere' => 'Newer',
|
||||||
'Ältere →' => 'Older →',
|
'Ältere' => 'Older',
|
||||||
'Seite %d von %d' => 'Page %d of %d',
|
'Seite %d von %d' => 'Page %d of %d',
|
||||||
'Externe Inhalte und Einwilligung' => 'External content and consent',
|
'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.',
|
'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 in der Kategorie „%s“.' => '„%s“ kategorisindeki yazılar.',
|
||||||
'Beiträge zum Schlagwort „%s“.' => '„%s“ etiketli yazılar.',
|
'Beiträge zum Schlagwort „%s“.' => '„%s“ etiketli yazılar.',
|
||||||
'von' => 'yazan:',
|
'von' => 'yazan:',
|
||||||
'← Neuere' => '← Daha yeni',
|
'Neuere' => 'Daha yeni',
|
||||||
'Ältere →' => 'Daha eski →',
|
'Ältere' => 'Daha eski',
|
||||||
'Seite %d von %d' => 'Sayfa %d / %d',
|
'Seite %d von %d' => 'Sayfa %d / %d',
|
||||||
'Externe Inhalte und Einwilligung' => 'Harici içerikler ve onay',
|
'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.',
|
'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.',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Twig;
|
|||||||
use App\Content\MediaLibrary;
|
use App\Content\MediaLibrary;
|
||||||
use App\Domain\InvalidDocument;
|
use App\Domain\InvalidDocument;
|
||||||
use App\Plugin\PluginRegistry;
|
use App\Plugin\PluginRegistry;
|
||||||
|
use App\Plugin\PluginRuntime;
|
||||||
use Twig\Extension\AbstractExtension;
|
use Twig\Extension\AbstractExtension;
|
||||||
use Twig\Loader\FilesystemLoader;
|
use Twig\Loader\FilesystemLoader;
|
||||||
use Twig\TwigFunction;
|
use Twig\TwigFunction;
|
||||||
@@ -15,7 +16,7 @@ use Twig\TwigFunction;
|
|||||||
*/
|
*/
|
||||||
final class PluginExtension extends AbstractExtension
|
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
|
// 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
|
// 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_props', $this->props(...)),
|
||||||
new TwigFunction('kant_plugin_media', $this->mediaItem(...)),
|
new TwigFunction('kant_plugin_media', $this->mediaItem(...)),
|
||||||
new TwigFunction('kant_plugin_settings', $this->plugins->settingsOf(...)),
|
new TwigFunction('kant_plugin_settings', $this->plugins->settingsOf(...)),
|
||||||
|
new TwigFunction('kant_plugin_data', $this->runtime->data(...)),
|
||||||
new TwigFunction('kant_media', $this->adminMedia(...)),
|
new TwigFunction('kant_media', $this->adminMedia(...)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -56,6 +56,9 @@
|
|||||||
<dd>{% if plugin.active %}<a href="{{ route.path }}">{{ route.path }}</a>{% else %}<code>{{ route.path }}</code>{% endif %} · {{ route.type }}</dd>
|
<dd>{% if plugin.active %}<a href="{{ route.path }}">{{ route.path }}</a>{% else %}<code>{{ route.path }}</code>{% endif %} · {{ route.type }}</dd>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</dl>
|
</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 %}
|
{% if plugin.active and plugin.settings is not empty %}
|
||||||
<form method="post" action="{{ path('admin_plugin_settings', {name: plugin.name}) }}" class="plugin-settings">
|
<form method="post" action="{{ path('admin_plugin_settings', {name: plugin.name}) }}" class="plugin-settings">
|
||||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('plugins') }}">
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('plugins') }}">
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
{% elseif type == 'quote' %}
|
{% elseif type == 'quote' %}
|
||||||
<figure id="{{ block.id }}" class="block-quote{{ designClasses }}">
|
<figure id="{{ block.id }}" class="block-quote{{ designClasses }}">
|
||||||
{% if props.rating|default(0) > 0 %}
|
{% 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 %}
|
{% endif %}
|
||||||
<blockquote><p>{{ props.text }}</p></blockquote>
|
<blockquote><p>{{ props.text }}</p></blockquote>
|
||||||
<figcaption>{{ props.author }}{% if props.role|default('') %} <span>{{ props.role }}</span>{% endif %}</figcaption>
|
<figcaption>{{ props.author }}{% if props.role|default('') %} <span>{{ props.role }}</span>{% endif %}</figcaption>
|
||||||
|
|||||||
@@ -41,8 +41,8 @@
|
|||||||
{% if pages|default(1) > 1 %}
|
{% if pages|default(1) > 1 %}
|
||||||
{# Dieselbe Blätterleiste wie im Archiv: eine Liste, die mehr hat als sie zeigt, sagt es. #}
|
{# 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 }}">
|
<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>
|
<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>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
{# Ein Anker, kein Skript. Er springt an den Anfang - das kann ein Browser allein. Der nav-Rahmen
|
{# 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
|
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“. #}
|
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 %}
|
{% endif %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user