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:
1 parent
de9d6e3da8
commit
ebd5883593
25 files changed
+808
-21
No files matched your search
@@ -9,7 +9,7 @@ final class DocumentValidator
|
||||
public function validate(array $document): void
|
||||
{
|
||||
$schemaVersion = $document['schemaVersion'] ?? null;
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4], true), 'Unsupported document schema.');
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5], true), 'Unsupported document schema.');
|
||||
$this->text($document['title'] ?? null, 160);
|
||||
$this->text($document['description'] ?? null, 320);
|
||||
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.');
|
||||
@@ -34,7 +34,7 @@ final class DocumentValidator
|
||||
// page: a deactivated plugin must never make stored content unreadable. Nothing from
|
||||
// props is rendered for such a block, so unvalidated values never reach the output.
|
||||
$plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type);
|
||||
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
|
||||
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
|
||||
$props = $block['props'] ?? [];
|
||||
$layout = $block['layout'] ?? [];
|
||||
$this->require(is_array($props) && is_array($layout), 'Props and layout must be objects.');
|
||||
@@ -61,6 +61,16 @@ final class DocumentValidator
|
||||
$this->require(is_int($props['level'] ?? null) && $props['level'] >= 1 && $props['level'] <= 6, 'Invalid heading level.');
|
||||
$headings += 1 === $props['level'] ? 1 : 0;
|
||||
}
|
||||
if ('postlist' === $type) {
|
||||
// The list is filled by the renderer, never by stored content: the block only says
|
||||
// how many posts to show and which term to limit them to.
|
||||
$this->require(is_int($props['count'] ?? null) && $props['count'] >= 1 && $props['count'] <= 24, 'Die Anzahl der Beiträge liegt zwischen 1 und 24.');
|
||||
foreach (['category', 'tag'] as $kind) {
|
||||
if (isset($props[$kind]) && '' !== $props[$kind]) {
|
||||
$this->require(is_string($props[$kind]) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props[$kind]) && strlen($props[$kind]) <= 80, 'Ungültiger Filter für die Beitragsliste.');
|
||||
}
|
||||
}
|
||||
}
|
||||
if ('button' === $type) {
|
||||
$this->url($props['href'] ?? null);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use Doctrine\DBAL\Connection;
|
||||
final class PageRepository
|
||||
{
|
||||
/** Reserved because the application already answers under these paths. */
|
||||
public const RESERVED_SLUGS = ['admin', 'media', 'assets', 'favicon'];
|
||||
public const RESERVED_SLUGS = ['admin', 'media', 'assets', 'favicon', 'kategorie', 'schlagwort', 'feed', 'beitraege'];
|
||||
public const MAX_DEPTH = 4;
|
||||
|
||||
public function __construct(private Connection $db, private DocumentValidator $validator)
|
||||
@@ -88,6 +88,7 @@ final class PageRepository
|
||||
}
|
||||
// Detach both pointers first: the revision rows are referenced by the page row.
|
||||
$this->db->executeStatement('UPDATE page SET draft_revision_id = NULL, published_revision_id = NULL WHERE id = ?', [$pageId]);
|
||||
$this->db->executeStatement('DELETE FROM page_term WHERE page_id = ?', [$pageId]);
|
||||
$this->db->executeStatement('DELETE FROM revision WHERE page_id = ?', [$pageId]);
|
||||
$this->db->executeStatement('DELETE FROM page WHERE id = ?', [$pageId]);
|
||||
});
|
||||
@@ -131,13 +132,18 @@ final class PageRepository
|
||||
if (1 !== $changed) {
|
||||
throw new StaleWrite('The page changed. Reload before publishing.');
|
||||
}
|
||||
// A post that goes public without its own date gets one, so the archive can order it.
|
||||
$this->db->executeStatement(
|
||||
"UPDATE page SET published_at = ? WHERE id = ? AND type = 'post' AND published_at IS NULL",
|
||||
[gmdate('Y-m-d H:i:s'), $pageId],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public function findPublished(string $path, string $locale = 'de'): ?array
|
||||
{
|
||||
$page = $this->db->fetchAssociative(
|
||||
'SELECT p.id, p.slug, p.path, p.type, p.locale, r.document, r.id AS revision_id, w.name AS website_name FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id JOIN website w ON w.id = p.website_id WHERE p.path = ? AND p.locale = ? AND p.website_id = ?',
|
||||
'SELECT p.id, p.slug, p.path, p.type, p.locale, p.author, p.excerpt, p.published_at, r.document, r.id AS revision_id, w.name AS website_name FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id JOIN website w ON w.id = p.website_id WHERE p.path = ? AND p.locale = ? AND p.website_id = ?',
|
||||
[$path, $locale, 'default'],
|
||||
);
|
||||
if (false === $page) {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Domain\StaleWrite;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Post metadata and the terms that group posts. Metadata describes the page, not its content, so
|
||||
* it lives in page columns instead of the block document: the document schema and the editor stay
|
||||
* untouched, and the archive can order and paginate in SQL instead of decoding every document.
|
||||
*/
|
||||
final class PostRepository
|
||||
{
|
||||
public const KINDS = ['category' => 'Kategorie', 'tag' => 'Schlagwort'];
|
||||
public const ARCHIVE_PREFIX = ['category' => 'kategorie', 'tag' => 'schlagwort'];
|
||||
public const PER_PAGE = 10;
|
||||
|
||||
public function __construct(private Connection $db, private MediaLibrary $media)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $categories term names, created on first use
|
||||
* @param list<string> $tags term names, created on first use
|
||||
*/
|
||||
public function saveMetadata(string $pageId, array $values, array $categories, array $tags, int $expectedVersion): void
|
||||
{
|
||||
$excerpt = trim((string) ($values['excerpt'] ?? ''));
|
||||
if (mb_strlen($excerpt) > 500) {
|
||||
throw new InvalidPage('Der Auszug darf höchstens 500 Zeichen lang sein.');
|
||||
}
|
||||
$author = trim((string) ($values['author'] ?? ''));
|
||||
if (mb_strlen($author) > 120) {
|
||||
throw new InvalidPage('Der Autorname darf höchstens 120 Zeichen lang sein.');
|
||||
}
|
||||
$date = trim((string) ($values['publishedAt'] ?? ''));
|
||||
if ('' !== $date && 1 !== preg_match('/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2})?$/D', $date)) {
|
||||
throw new InvalidPage('Das Datum braucht die Form JJJJ-MM-TT, wahlweise mit Uhrzeit.');
|
||||
}
|
||||
$mediaId = trim((string) ($values['featuredMediaId'] ?? ''));
|
||||
if ('' !== $mediaId && (1 !== preg_match('/^[a-f0-9]{32}$/D', $mediaId) || null === $this->media->find($mediaId))) {
|
||||
throw new InvalidPage('Das gewählte Beitragsbild gibt es nicht.');
|
||||
}
|
||||
|
||||
$this->db->transactional(function () use ($pageId, $excerpt, $author, $date, $mediaId, $categories, $tags, $expectedVersion): void {
|
||||
$changed = $this->db->executeStatement(
|
||||
'UPDATE page SET excerpt = ?, author = ?, featured_media_id = ?, published_at = ?, version = version + 1 WHERE id = ? AND version = ?',
|
||||
[
|
||||
'' === $excerpt ? null : $excerpt,
|
||||
'' === $author ? null : $author,
|
||||
'' === $mediaId ? null : $mediaId,
|
||||
'' === $date ? null : str_replace('T', ' ', 10 === strlen($date) ? $date.' 00:00' : $date).':00',
|
||||
$pageId,
|
||||
$expectedVersion,
|
||||
],
|
||||
);
|
||||
if (1 !== $changed) {
|
||||
throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.');
|
||||
}
|
||||
$this->db->executeStatement('DELETE FROM page_term WHERE page_id = ?', [$pageId]);
|
||||
foreach (['category' => $categories, 'tag' => $tags] as $kind => $names) {
|
||||
foreach ($names as $name) {
|
||||
$this->db->insert('page_term', ['page_id' => $pageId, 'term_id' => $this->term($kind, $name)]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Finds or creates a term and returns its ID. Names are matched by their slug. */
|
||||
private function term(string $kind, string $name, string $locale = 'de'): string
|
||||
{
|
||||
$name = trim($name);
|
||||
$slug = trim(preg_replace('/[^a-z0-9]+/', '-', strtr(mb_strtolower($name, 'UTF-8'), ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss'])) ?? '', '-');
|
||||
if ('' === $slug || mb_strlen($name) > 120) {
|
||||
throw new InvalidPage(sprintf('„%s“ ist als %s nicht verwendbar.', $name, self::KINDS[$kind]));
|
||||
}
|
||||
$existing = $this->db->fetchOne('SELECT id FROM term WHERE website_id = ? AND locale = ? AND kind = ? AND slug = ?', ['default', $locale, $kind, substr($slug, 0, 80)]);
|
||||
if (false !== $existing) {
|
||||
return $existing;
|
||||
}
|
||||
$id = bin2hex(random_bytes(16));
|
||||
$this->db->insert('term', ['id' => $id, 'website_id' => 'default', 'locale' => $locale, 'kind' => $kind, 'slug' => substr($slug, 0, 80), 'name' => $name]);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/** Stamps the publication date when a post first goes public and has no date of its own. */
|
||||
public function stampPublication(string $pageId): void
|
||||
{
|
||||
$this->db->executeStatement(
|
||||
"UPDATE page SET published_at = ? WHERE id = ? AND type = 'post' AND published_at IS NULL",
|
||||
[gmdate('Y-m-d H:i:s'), $pageId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{posts: list<array>, total: int, pages: int, page: int}
|
||||
*/
|
||||
public function published(int $page = 1, ?string $termId = null, int $perPage = self::PER_PAGE, string $locale = 'de'): array
|
||||
{
|
||||
$where = "p.website_id = ? AND p.locale = ? AND p.type = 'post' AND p.published_revision_id IS NOT NULL";
|
||||
$params = ['default', $locale];
|
||||
$join = '';
|
||||
if (null !== $termId) {
|
||||
$join = ' JOIN page_term pt ON pt.page_id = p.id AND pt.term_id = ?';
|
||||
array_unshift($params, $termId);
|
||||
}
|
||||
$total = (int) $this->db->fetchOne('SELECT COUNT(*) FROM page p'.$join.' WHERE '.$where, $params);
|
||||
$pages = max(1, (int) ceil($total / $perPage));
|
||||
$page = max(1, min($page, $pages));
|
||||
$rows = $this->db->fetchAllAssociative(
|
||||
'SELECT p.id, p.path, p.excerpt, p.author, p.featured_media_id, p.published_at, r.document FROM page p'
|
||||
.$join.' JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE '.$where
|
||||
// A missing date sorts last, then newest first, with the path as a stable tiebreaker.
|
||||
.' ORDER BY p.published_at IS NULL, p.published_at DESC, p.path ASC LIMIT '.$perPage.' OFFSET '.(($page - 1) * $perPage),
|
||||
$params,
|
||||
);
|
||||
|
||||
return ['posts' => array_map($this->present(...), $rows), 'total' => $total, 'pages' => $pages, 'page' => $page];
|
||||
}
|
||||
|
||||
private function present(array $row): array
|
||||
{
|
||||
$document = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$image = null;
|
||||
if (null !== $row['featured_media_id']) {
|
||||
$item = $this->media->find($row['featured_media_id']);
|
||||
$image = null === $item ? null : ['alt' => $item['alt'] ?? '', 'variants' => $item['variants']];
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => $document['title'] ?? $row['path'],
|
||||
'href' => '/'.$row['path'],
|
||||
'excerpt' => $row['excerpt'] ?? $document['description'] ?? '',
|
||||
'author' => $row['author'],
|
||||
'publishedAt' => $row['published_at'],
|
||||
'image' => $image,
|
||||
'terms' => $this->termsFor($row['id']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<array{slug: string, name: string, href: string}>>
|
||||
*/
|
||||
public function termsFor(string $pageId): array
|
||||
{
|
||||
$terms = ['category' => [], 'tag' => []];
|
||||
foreach ($this->db->fetchAllAssociative('SELECT t.kind, t.slug, t.name FROM term t JOIN page_term pt ON pt.term_id = t.id WHERE pt.page_id = ? ORDER BY t.name', [$pageId]) as $row) {
|
||||
$terms[$row['kind']][] = ['slug' => $row['slug'], 'name' => $row['name'], 'href' => '/'.self::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug']];
|
||||
}
|
||||
|
||||
return $terms;
|
||||
}
|
||||
|
||||
public function findTerm(string $kind, string $slug, string $locale = 'de'): ?array
|
||||
{
|
||||
$row = $this->db->fetchAssociative('SELECT id, kind, slug, name FROM term WHERE website_id = ? AND locale = ? AND kind = ? AND slug = ?', ['default', $locale, $kind, $slug]);
|
||||
|
||||
return false === $row ? null : $row;
|
||||
}
|
||||
|
||||
/** @return list<array{id: string, kind: string, slug: string, name: string}> */
|
||||
public function allTerms(string $locale = 'de'): array
|
||||
{
|
||||
return $this->db->fetchAllAssociative('SELECT id, kind, slug, name FROM term WHERE website_id = ? AND locale = ? ORDER BY kind, name', ['default', $locale]);
|
||||
}
|
||||
|
||||
/** Term names of one page, as the comma separated list the metadata form edits. */
|
||||
public function namesFor(string $pageId): array
|
||||
{
|
||||
$terms = $this->termsFor($pageId);
|
||||
|
||||
return [
|
||||
'category' => implode(', ', array_column($terms['category'], 'name')),
|
||||
'tag' => implode(', ', array_column($terms['tag'], 'name')),
|
||||
];
|
||||
}
|
||||
|
||||
/** Removes term assignments of a deleted page. Unused terms stay; they are reusable labels. */
|
||||
public function forget(string $pageId): void
|
||||
{
|
||||
$this->db->executeStatement('DELETE FROM page_term WHERE page_id = ?', [$pageId]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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>>
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user