Rebuild DEEG as a fifth installation, add stepped forms and a plugin system, and clear the review backlog

Forms gained steps, conditions, image choices, number fields with units and a
summary step; the DEEG price finder uses all of it. Plugins are manifest plus
Twig: blocks, settings, containers, routes, presets and theme packs, switched
per installation. The review backlog is done: archive pages render through the
normal page pipeline, forms and post lists work inside areas and sections,
repeated sections get their own ids, breakpoints and colours come from the
palette, backup and update work on MySQL and lock each other out.

The task worker no longer shortens a process that has no time limit:
set_time_limit() sets the limit instead of extending it, which turned an
unlimited CLI run into eighty seconds and killed the full test suite far away
from the scheduler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-16 07:32:35 +02:00
1 parent 66ff68abf8
commit c8a8fa7460
74 files changed
+5352 -243

No files matched your search

+185 -42
View File
@@ -4,6 +4,7 @@ namespace App\Controller;
use App\Content\Consent;
use App\Content\CustomCode;
use App\Content\DocumentValidator;
use App\Content\FontLibrary;
use App\Content\FormRepository;
use App\Content\MediaLibrary;
@@ -18,6 +19,7 @@ use App\Content\StyleBundle;
use App\Content\StyleCompiler;
use App\Content\Theme;
use App\Content\Turnstile;
use App\Plugin\PluginRegistry;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -25,7 +27,7 @@ use Symfony\Component\Routing\Attribute\Route;
final class PageController extends AbstractController
{
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin, private NotFoundPage $notFoundPage)
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)
{
}
@@ -47,6 +49,11 @@ final class PageController extends AbstractController
}
$page = $this->pages->findPublished($path);
if (null === $page) {
// Eine Seite geht vor; eine eigene Adresse eines Plugins kommt vor Weiterleitung und 404.
$route = $this->plugins->route('/'.$path);
if (null !== $route) {
return $this->pluginRoute($request, $route);
}
$redirect = $this->redirects->find($path);
if (null !== $redirect && 301 === $redirect['status']) {
return $this->shell($this->redirect('/'.$redirect['to'], 301));
@@ -61,6 +68,27 @@ final class PageController extends AbstractController
return $this->renderPage($request, $page, $path);
}
/**
* Eine eigene Adresse eines Plugins: die Vorlage bekommt die Einstellungen des Plugins und die
* Abfrageparameter, sonst nichts. Dieselben Schutzkopfzeilen wie jede Seite, kein Skript.
*/
private function pluginRoute(Request $request, array $route): Response
{
$query = [];
foreach ($request->query->all() as $key => $value) {
if (is_string($value) && 1 === preg_match('/^[a-zA-Z0-9_-]{1,40}$/D', (string) $key)) {
$query[$key] = mb_substr($value, 0, 500);
}
}
$response = $this->render($route['template'], ['settings' => $this->plugins->settingsOf($route['plugin']), 'query' => $query, 'route' => $route]);
$response->headers->set('Content-Type', $route['contentType']);
if ('html' !== $route['type']) {
$response->headers->set('X-Robots-Tag', 'noindex');
}
return $this->shell($response);
}
public function notFound(Request $request, int $status = 404): Response
{
return $this->renderPage($request, $this->notFoundPage->page($status), '', $status);
@@ -75,12 +103,29 @@ final class PageController extends AbstractController
$theme = $this->theme->load();
// Theme first, block styles after, so a block always wins over the site defaults.
$sections = $this->sections($page['document'], $areas);
// Dieselbe Sektion zweimal auf der Seite: ab der zweiten Verknüpfung eine Kopie mit eigenen
// Kennungen, in Renderreihenfolge Kopf, Inhalt, Fuß.
$pristine = $sections;
$seen = [];
foreach (['header', 'footer'] as $kind) {
if ('header' === $kind && isset($areas['header'])) {
$areas['header'] = $this->partials->expandRepeatedLinks($areas['header'], $sections, $seen, $pristine);
}
if ('header' === $kind) {
$page['document'] = $this->partials->expandRepeatedLinks($page['document'], $sections, $seen, $pristine);
}
if ('footer' === $kind && isset($areas['footer'])) {
$areas['footer'] = $this->partials->expandRepeatedLinks($areas['footer'], $sections, $seen, $pristine);
}
}
$documents = [$page['document'], ...array_values($areas), ...array_values($sections)];
$needsSlider = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'slider'));
$needsMotion = (bool) array_filter($documents, fn (array $doc): bool => $this->hasEntrance($doc));
// Tabs und vergroesserbare Bilder koennen auch im Kopf, im Fuss oder in einer verknuepften
// Sektion stehen - das Skript und die Richtlinie dafuer richten sich nach allen Dokumenten.
$needsTabs = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'tabs'));
// Stylesheet und Skript eines Plugin-Bausteins kommen nur dorthin, wo er vorkommt.
$pluginAssets = $this->plugins->assetsFor($documents);
$needsZoom = (bool) array_filter($documents, fn (array $doc): bool => $this->hasZoom($doc));
// Only the header changes with the scroll position; a scrolled value anywhere in it asks
// for the small script that marks the page as scrolled.
@@ -91,7 +136,10 @@ final class PageController extends AbstractController
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
}
// Die Farben jedes Formulars auf der Seite - nach dem Theme, damit sie es uebersteuern.
$forms = $this->formStates($page, $request->query->getString('gesendet'), $request);
$forms = $this->formStates($page, $documents, $request->query->getString('gesendet'), $request);
// Schritte, Bedingungen und Zusammenfassung brauchen das eine Formularskript - nur dort,
// wo ein solches Formular steht.
$needsFormScript = (bool) array_filter($forms, fn (array $state): bool => null !== $state['form'] && $this->forms->needsScript($state['form']));
$styled = [];
foreach ($forms as $state) {
if (null !== $state['form'] && !isset($styled[$state['form']['slug']])) {
@@ -104,11 +152,31 @@ final class PageController extends AbstractController
// das gaebe die IP-Adresse jedes Besuchers an einen Dritten weiter und haengt den
// Seitenaufbau an zwei fremde Verbindungen nacheinander.
$localFonts = $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : [];
$areaScripts = [];
foreach ($areas as $kind => $area) {
if (isset($this->areaIds[$kind]) && [] !== $this->code->scriptsOf($area)) {
$areaScripts[] = '/assets/bereich/'.$this->areaIds[$kind].'.js';
}
}
$postLists = $this->postLists($documents, max(1, $request->query->getInt('seite', 1)));
// Eine blätternde Liste macht die Seite zu einer Folge von Seiten: die Canonical nennt die
// Seitenzahl, prev und next verbinden die Folge - sonst hielte eine Suchmaschine Seite 3
// für ein Duplikat von Seite 1.
$blaettern = null;
foreach ($postLists as $liste) {
if (isset($liste['pages']) && $liste['pages'] > 1) {
$blaettern = $liste;
break;
}
}
$adresse = $status >= 400 ? null : rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $this->settings->homePath());
$canonical = null === $adresse ? null : $adresse.(null !== $blaettern && $blaettern['page'] > 1 ? '?seite='.$blaettern['page'] : '');
$response = $this->render('page.html.twig', [
'page' => $page,
'canonical' => $status >= 400 ? null : rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $this->settings->homePath()),
'canonical' => $canonical,
'pagination' => null === $blaettern || null === $adresse ? null : ['page' => $blaettern['page'], 'pages' => $blaettern['pages'], 'base' => $adresse],
'navigation' => $this->navigation($path),
'postLists' => $this->postLists($page['document'], max(1, $request->query->getInt('seite', 1))),
'postLists' => $postLists,
// Ohne Beiträge gibt es keinen Feed, auf den der Kopf verweisen könnte.
'hasPosts' => $this->posts->anyPublished(),
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null,
@@ -116,6 +184,9 @@ final class PageController extends AbstractController
// Baustein wirklich enthält.
'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id']) : [],
'needsTabs' => $needsTabs,
'needsFormScript' => $needsFormScript,
'pluginStyles' => $pluginAssets['styles'],
'pluginScripts' => $pluginAssets['scripts'],
// Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der
// dafür ein Skript braucht - nach demselben Muster wie die Tabs.
'needsSlider' => $needsSlider,
@@ -127,6 +198,7 @@ final class PageController extends AbstractController
// Own scripts arrive as one file per page, never inline, so the policy can stay
// hash based and no page without a script loses its strict setting.
'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js',
'areaScripts' => $areaScripts,
'forms' => $forms,
'breadcrumbs' => $status >= 400 ? [] : $this->breadcrumbs($page),
'socialImage' => $this->socialImage($page),
@@ -148,8 +220,8 @@ final class PageController extends AbstractController
// Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur
// dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website.
$mitTurnstile = $this->turnstile->isActive() && $this->hasType($page['document'], 'form');
$wantsScript = ($mitTurnstile || $needsTabs || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || [] !== $this->code->scriptsOf($page['document']));
$mitTurnstile = $this->turnstile->isActive() && (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'form'));
$wantsScript = ($mitTurnstile || $needsTabs || $needsFormScript || [] !== $pluginAssets['scripts'] || [] !== $areaScripts || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || [] !== $this->code->scriptsOf($page['document']));
$frames = $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
if ($mitTurnstile) {
$frames[] = 'challenges.cloudflare.com';
@@ -169,8 +241,7 @@ final class PageController extends AbstractController
*
* The generated stylesheet is inline for speed, so it is allowed by its own hash rather than
* by 'unsafe-inline' – any other inline style, injected or not, stays blocked.
*/
/**
*
* @param list<string> $frameHosts Hosts of embeds that are actually rendered on this response.
* Nothing is added to the policy for an embed still waiting
* for consent.
@@ -209,13 +280,9 @@ final class PageController extends AbstractController
}
/**
* Prepares one state per form block: the definition, whether this request just sent it, and a
* signed timestamp the submission route uses to tell a person from a script.
*
* @return array<string, array>
*/
/**
* Der Zustand jedes Formulars auf dieser Seite.
* Der Zustand jedes Formulars auf dieser Seite: die Definition, ob diese Anfrage es gerade
* abgeschickt hat, und ein signierter Zeitstempel, an dem der Versand einen Menschen von
* einem Skript unterscheidet.
*
* Kommt die Anfrage aus dem Formularversand zurueck, weil eine Eingabe fehlte, dann stehen die
* Meldungen und die eingetippten Werte schon fest und werden hier uebernommen. Genau deshalb
@@ -223,7 +290,7 @@ final class PageController extends AbstractController
* Formularcode die Seite selbst noch einmal auf - ohne Theme, ohne Schriften, ohne
* Stylesheet-Buendel. Wer ein Pflichtfeld vergass, bekam eine Seite ohne Gestaltung zu sehen.
*/
private function formStates(array $page, string $sentSlug, ?Request $request = null): array
private function formStates(array $page, array $documents, string $sentSlug, ?Request $request = null): array
{
$issued = time();
$states = [];
@@ -232,8 +299,11 @@ final class PageController extends AbstractController
foreach ($blocks as $block) {
if ('form' === ($block['type'] ?? null)) {
$slug = $block['props']['form'] ?? '';
$form = $this->forms->findBySlug($slug);
$states[$block['id']] = [
'form' => $this->forms->findBySlug($slug),
'form' => $form,
// Die Bilder einer Bildauswahl, einmal fuer alle Optionen aller Felder geholt.
'optionImages' => null === $form ? [] : $this->optionImages($form),
'sent' => '' !== $sentSlug && $sentSlug === $slug,
'errors' => [], 'values' => [],
'blockId' => $block['id'], 'page' => $page,
@@ -246,16 +316,46 @@ final class PageController extends AbstractController
$walk($block['children'] ?? []);
}
};
$walk($page['document']['blocks'] ?? []);
foreach ($uebergeben as $blockId => $zustand) {
if (isset($states[$blockId])) {
$states[$blockId] = [...$states[$blockId], ...$zustand];
// Formulare stehen auch im Kopf, im Fuss oder in einer verknuepften Sektion - ein
// Kontaktformular im Fuss jeder Seite ist der haeufigste Fall.
foreach ($documents as $document) {
$walk($document['blocks'] ?? []);
}
// Die Meldungen aus dem Formularversand gelten jedem Block, der dieses Formular zeigt.
if (isset($uebergeben['slug'])) {
foreach ($states as $blockId => $state) {
if (($state['form']['slug'] ?? null) === $uebergeben['slug']) {
$states[$blockId] = [...$state, 'errors' => $uebergeben['errors'], 'values' => $uebergeben['values'], 'sent' => false];
}
}
}
return $states;
}
/**
* Die Bilder der Optionen eines Formulars: Kennung => kleinste Fassung mit Massen. Ein Bild,
* das die Mediathek nicht mehr kennt, fehlt hier - die Option zeigt dann ihren Text.
*
* @return array<string, array{src: string, width: int, height: int}>
*/
private function optionImages(array $form): array
{
$ids = [];
foreach ($form['fields'] as $field) {
$ids = [...$ids, ...array_values($field['images'] ?? [])];
}
$images = [];
foreach ($this->media->findMany($ids) as $id => $item) {
$variant = $item['variants'][0] ?? null;
if (null !== $variant) {
$images[$id] = ['src' => $variant['src'], 'width' => (int) $variant['width'], 'height' => (int) $variant['height']];
}
}
return $images;
}
/**
* The trail from the home page down to this one, resolved from the stored hierarchy rather
* than from content, so it cannot claim a page that does not exist.
@@ -290,6 +390,15 @@ final class PageController extends AbstractController
if ($page['path'] === $this->settings->homePath()) {
return [];
}
// Ein Archiv hat keine Seitenhierarchie: die Spur ist „Beiträge“, dann die Kategorie.
if ('archive' === ($page['type'] ?? null)) {
$trail = [['name' => 'Beiträge', 'href' => '/beitraege']];
if ('beitraege' !== $page['path']) {
$trail[] = ['name' => $page['document']['title'], 'href' => '/'.$page['path']];
}
return $trail;
}
$trail = [];
$prefix = '';
foreach (explode('/', (string) $page['path']) as $segment) {
@@ -508,27 +617,36 @@ final class PageController extends AbstractController
return $this->notFound($request);
}
$base = null === $term ? '/beitraege' : '/'.PostRepository::ARCHIVE_PREFIX[$kind].'/'.$term['slug'];
$heading = null === $term ? 'Beiträge' : $term['name'];
$areas = $this->areas();
$frames = $this->frameHosts($request, ...array_values($areas));
$needsScrolled = isset($areas['header']) && $this->hasScrolled($areas['header']);
// Palette und Hinweisfarben wie auf jeder Seite - das Archiv trug bisher weder das eine
// noch das andere und zeigte den Einwilligungshinweis in den Standardfarben.
$theme = $this->theme->load();
$styles = $this->theme->css($theme).$this->consent->styles($theme);
// Das Archiv ist eine Seite wie jede andere: ein Dokument aus Bausteinen, das durch dieselbe
// Aufbaukette läuft - Kopf und Fuß mit ihren Stilen, verknüpfte Sektionen, Schriften,
// Stylesheet-Bündel, Nach-oben-Knopf, Einwilligung, Blätterverweise. Vorher hatte es eine
// eigene Vorlage, die von alledem nur die Palette kannte.
$kopf = [
['id' => 'archiv-titel', 'type' => 'heading', 'props' => ['text' => $heading, 'level' => 1]],
['id' => 'archiv-zahl', 'type' => 'text', 'props' => ['text' => sprintf('%d %s', $result['total'], 1 === $result['total'] ? 'Beitrag' : 'Beiträge')]],
];
if (null !== $term) {
array_unshift($kopf, ['id' => 'archiv-art', 'type' => 'text', 'props' => ['text' => 'category' === $kind ? 'Kategorie' : 'Schlagwort'], 'style' => ['desktop' => ['fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '0.08em', 'textTransform' => 'uppercase', 'margin' => ['bottom' => '8px']]]]);
}
$document = [
'schemaVersion' => DocumentValidator::SCHEMA_VERSION,
'title' => $heading,
'description' => null === $term ? 'Alle Beiträge, die neuesten zuerst.' : sprintf('Beiträge %s „%s“.', 'category' === $kind ? 'in der Kategorie' : 'zum Schlagwort', $term['name']),
'blocks' => [
['id' => 'archiv-kopf', 'type' => 'section', 'layout' => ['tone' => 'mint', 'width' => 'normal'], 'children' => $kopf],
['id' => 'archiv', 'type' => 'section', 'layout' => ['tone' => 'paper', 'width' => 'normal'], 'children' => [
['id' => 'archiv-liste', 'type' => 'postlist', 'props' => ['count' => PostRepository::PER_PAGE, 'category' => 'category' === $kind && null !== $term ? $term['slug'] : '', 'tag' => 'tag' === $kind && null !== $term ? $term['slug'] : '', 'layout' => 'karten', 'paginate' => true]],
]],
],
];
$seite = [
'id' => 'archiv-'.(null === $term ? 'beitraege' : $kind.'-'.$term['slug']), 'type' => 'archive', 'path' => ltrim($base, '/'), 'locale' => 'de',
'website_name' => '', 'seo_title' => '', 'robots' => 'index', 'published_at' => null, 'author' => null, 'document' => $document,
];
return $this->shell($this->render('archive.html.twig', [
'pageStyles' => $styles,
'areas' => $areas,
'needsScrolled' => $needsScrolled,
'navigation' => $this->navigation(''),
'term' => $term,
'heading' => null === $term ? 'Beiträge' : $term['name'],
'result' => $result,
'base' => $base,
'canonical' => rtrim($this->siteOrigin, '/').$base.($result['page'] > 1 ? '?seite='.$result['page'] : ''),
'navigation' => $this->navigation(''),
]), $needsScrolled, $styles, $frames);
return $this->renderPage($request, $seite, $seite['path']);
}
#[Route('/feed.xml', name: 'feed', methods: ['GET'])]
@@ -553,7 +671,7 @@ final class PageController extends AbstractController
*
* @return array<string, list<array>>
*/
private function postLists(array $document, int $page = 1): array
private function postLists(array $documents, int $page = 1): array
{
$lists = [];
$walk = function (array $blocks) use (&$walk, &$lists, $page): void {
@@ -576,7 +694,9 @@ final class PageController extends AbstractController
$walk($block['children'] ?? []);
}
};
$walk($document['blocks'] ?? []);
foreach ($documents as $document) {
$walk($document['blocks'] ?? []);
}
return $lists;
}
@@ -714,9 +834,13 @@ final class PageController extends AbstractController
*
* @return array<string, array>
*/
/** Die Kennungen der Bereiche dieser Antwort, fuer ihre Skriptdateien. @var array<string, string> */
private array $areaIds = [];
private function areas(?array $page = null): array
{
$areas = [];
$this->areaIds = [];
foreach (['header', 'footer'] as $kind) {
// A page may ask for another area or for none at all: a landing page without a
// navigation is a normal wish, and it must not need a second header document that
@@ -731,12 +855,31 @@ final class PageController extends AbstractController
$partial ??= $this->partials->findByKind($kind);
if (null !== $partial) {
$areas[$kind] = $partial['document'];
$this->areaIds[$kind] = (string) $partial['id'];
}
}
return $areas;
}
/**
* Die eigenen Skripte eines Bereichs, als Datei - dasselbe Muster wie bei einer Seite. Vorher
* wurde ein Skript im Kopf oder Fuss gespeichert, geprueft und nie ausgefuehrt.
*/
#[Route('/assets/bereich/{id}.js', name: 'area_script', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['GET'])]
public function areaScript(string $id): Response
{
$partial = $this->partials->find($id);
if (null === $partial || [] === $this->code->scriptsOf($partial['document'])) {
throw $this->createNotFoundException('Kein Skript.');
}
$response = new Response($this->code->compileScript($partial['document']), 200, ['Content-Type' => 'text/javascript; charset=UTF-8']);
$response->headers->set('Cache-Control', 'no-store');
$response->headers->set('X-Content-Type-Options', 'nosniff');
return $response;
}
/**
* Resolves every linked section a page or one of its areas refers to, including sections that
* link further sections. A missing section is left out; the renderer shows a placeholder only