Beiträge mit Archiven, Beitragsliste und Feed (Arbeitspaket B)

Migration 20260912000600 ergänzt page.excerpt, author,
featured_media_id und published_at sowie die Tabellen term und
page_term. Beitragsmetadaten beschreiben die Seite, nicht ihren
Inhalt, und liegen deshalb als Spalten statt im Blockdokument: das
Archiv sortiert und paginiert damit in SQL, statt für jede
Auflistung alle Dokumente zu dekodieren.

Kategorien und Schlagwörter teilen sich eine Tabelle mit einem
kind-Feld. Beim Löschen einer Seite verschwinden ihre Zuordnungen,
die Begriffe bleiben als wiederverwendbare Etiketten; ein leer
gewordenes Archiv antwortet weiter mit 200 und leerer Liste.

Der Block postlist (Dokumentschema v5) speichert nur Anzahl und
Filter. Die Beiträge stellt der Controller beim Ausliefern zusammen,
damit ein gespeichertes Dokument keinen unveröffentlichten Beitrag
zeigen kann. Archive unter /beitraege, /kategorie/{slug} und
/schlagwort/{slug}, Feed unter /feed.xml; alle vier Segmente sind
für Wurzelseiten reserviert.

Geprüft: 91 PHP-Tests / 347 Assertions unter PHP 8.2.12 und 8.4.22,
12 JavaScript-Modelltests, Lint/Format/Container, Migration zuerst
gegen eine Datenbankkopie. Keine Browser- oder Lighthouse-Läufe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 11:25:09 +02:00
1 parent de9d6e3da8
commit ebd5883593
25 files changed
+808 -21

No files matched your search

+37 -1
View File
@@ -6,6 +6,7 @@ use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PostRepository;
use App\Domain\InvalidDocument;
use App\Domain\InvalidPage;
use App\Domain\StaleWrite;
@@ -19,7 +20,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
#[Route('/admin')]
final class EditorController extends AbstractController
{
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private CsrfTokenManagerInterface $csrf)
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private CsrfTokenManagerInterface $csrf)
{
}
@@ -30,6 +31,9 @@ final class EditorController extends AbstractController
foreach ($pages as &$page) {
$page['title'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR)['title'] ?? $page['slug'];
$page['depth'] = substr_count((string) $page['path'], '/');
if ('post' === $page['type']) {
$page['terms'] = $this->posts->namesFor($page['id']);
}
}
unset($page);
@@ -64,6 +68,38 @@ final class EditorController extends AbstractController
return $this->redirectToRoute('admin_editor', ['id' => $id]);
}
#[Route('/pages/{id}/meta', name: 'admin_post_meta', methods: ['POST'])]
public function metadata(Request $request, string $id): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
$terms = static function (string $raw): array {
$names = array_filter(array_map(trim(...), explode(',', $raw)), static fn (string $n): bool => '' !== $n);
return array_values(array_unique($names));
};
try {
$this->posts->saveMetadata(
$id,
[
'excerpt' => $request->request->getString('excerpt'),
'author' => $request->request->getString('author'),
'publishedAt' => $request->request->getString('publishedAt'),
'featuredMediaId' => $request->request->getString('featuredMediaId'),
],
$terms($request->request->getString('categories')),
$terms($request->request->getString('tags')),
$request->request->getInt('expectedVersion', -1),
);
} catch (InvalidPage|StaleWrite $e) {
return $this->back($e->getMessage());
}
return $this->back('Die Beitragsdaten wurden gespeichert.', 'success');
}
#[Route('/pages/{id}/delete', name: 'admin_delete', methods: ['POST'])]
public function delete(Request $request, string $id): Response
{
+81 -2
View File
@@ -4,6 +4,7 @@ namespace App\Controller;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PostRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -11,13 +12,13 @@ use Symfony\Component\Routing\Attribute\Route;
final class PageController extends AbstractController
{
public function __construct(private PageRepository $pages, private MenuRepository $menus, private string $siteOrigin)
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private string $siteOrigin)
{
}
// A segment is a slug; up to four of them form the public path. The leading lookahead keeps
// the CMS routes (/admin, /media, /assets) out of the catch-all, which matches before them.
private const PATH = '(?!admin(?:/|$)|media/|assets/)[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){0,3}';
private const PATH = '(?!admin(?:/|$)|media/|assets/|kategorie/|schlagwort/|beitraege(?:/|$)|feed\.xml$)[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){0,3}';
#[Route('/', name: 'home', methods: ['GET'])]
#[Route('/{path}', name: 'page', requirements: ['path' => self::PATH], methods: ['GET'])]
@@ -35,8 +36,17 @@ final class PageController extends AbstractController
'page' => $page,
'canonical' => rtrim($this->siteOrigin, '/').('home' === $path ? '/' : '/'.$path),
'navigation' => $this->navigation($path),
'postLists' => $this->postLists($page['document']),
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null,
]);
}
return $this->shell($response);
}
/** Every public response carries the same headers, whether page, archive or feed. */
private function shell(Response $response): Response
{
$response->headers->set('Cache-Control', 'no-store');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
@@ -45,6 +55,75 @@ final class PageController extends AbstractController
return $response;
}
#[Route('/beitraege', name: 'posts', methods: ['GET'])]
#[Route('/kategorie/{slug}', name: 'category', requirements: ['slug' => '[a-z0-9-]{1,80}'], methods: ['GET'])]
#[Route('/schlagwort/{slug}', name: 'tag', requirements: ['slug' => '[a-z0-9-]{1,80}'], methods: ['GET'])]
public function archive(Request $request, ?string $slug = null): Response
{
$kind = 'category' === $request->attributes->get('_route') ? 'category' : 'tag';
$term = null;
if (null !== $slug) {
$term = $this->posts->findTerm($kind, $slug);
if (null === $term) {
return $this->shell($this->render('not-found.html.twig', ['navigation' => $this->navigation('')], new Response(status: 404)));
}
}
$page = max(1, $request->query->getInt('seite', 1));
$result = $this->posts->published($page, $term['id'] ?? null);
$base = null === $term ? '/beitraege' : '/'.PostRepository::ARCHIVE_PREFIX[$kind].'/'.$term['slug'];
return $this->shell($this->render('archive.html.twig', [
'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(''),
]));
}
#[Route('/feed.xml', name: 'feed', methods: ['GET'])]
public function feed(): Response
{
$result = $this->posts->published(1, null, 20);
$response = $this->shell($this->render('feed.xml.twig', [
'posts' => $result['posts'],
'origin' => rtrim($this->siteOrigin, '/'),
]));
$response->headers->set('Content-Type', 'application/xml; charset=UTF-8');
return $response;
}
/**
* Collects the posts each post list block needs. Stored content never carries the posts
* themselves, so a saved document cannot claim a post it is not allowed to show.
*
* @return array<string, list<array>>
*/
private function postLists(array $document): array
{
$lists = [];
$walk = function (array $blocks) use (&$walk, &$lists): void {
foreach ($blocks as $block) {
if ('postlist' === ($block['type'] ?? null)) {
$props = $block['props'] ?? [];
$term = null;
foreach (['category', 'tag'] as $kind) {
if ('' !== ($props[$kind] ?? '')) {
$term = $this->posts->findTerm($kind, $props[$kind]);
}
}
$lists[$block['id']] = $this->posts->published(1, $term['id'] ?? null, $props['count'])['posts'];
}
$walk($block['children'] ?? []);
}
};
$walk($document['blocks'] ?? []);
return $lists;
}
/**
* @return array<string, list<array>>
*/