Kopf, Fuß und verknüpfte Sektionen als Blockdokumente (Paket G, Teil 1)
Migration 20260912001000 legt die Tabelle partial an: Blockdokumente, die keine Seiten sind. Kopf- und Fußbereich je einmal, wiederverwendbare Sektionen beliebig viele - im selben Editor gestaltbar wie eine Seite, weil der Editor seine Schnittstelle aus einem Datenattribut nimmt statt sie fest zu kennen. Im Template steht keine Marke und keine Navigation mehr. Ein neuer Block menu zeigt einen Menüplatz; die Einträge bleiben in der Menüverwaltung. Eine Seite braucht genau eine Hauptüberschrift, ein geteilter Bereich keine - sonst hätte jede Seite, die ihn trägt, zwei. Geteilte Bereiche haben keinen Entwurf und keine Veröffentlichung: sie sind überall sichtbar, gespeichert heißt live. Verknüpft statt kopiert: partialref speichert nur die Adresse, die Blöcke bleiben im Bereich, und eine Änderung erreicht jede Seite, die sie einbindet. Zyklen abgewiesen, Verschachtelung auf drei Ebenen begrenzt, eine entfernte Sektion hinterlässt keine Lücke und der Verweis bleibt erhalten. Die Abschnittsvorlagen kopieren weiterhin. Ein echter Fehler kam dabei heraus, der jede dunkle Sektion mit Spalten betraf: .block-column>.block-text:first-child setzt eine Farbe für helle Hintergründe und gewinnt auf Spezifität gegen .tone-ink - gemessen 1,87:1. Geprüft: 165 PHP-Tests / 660 Assertions unter PHP 8.2.12 und 8.4.22, 15 JavaScript-Modelltests, Editortest mit axe ohne Verstöße, öffentliche Browserprüfung, Migration nach Backup angewendet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
33e4ac0c54
commit
d0938eec69
26 files changed
+955
-56
No files matched your search
@@ -5,6 +5,7 @@ namespace App\Command;
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
@@ -14,7 +15,7 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
#[AsCommand(name: 'cms:seed-demo', description: 'Create missing demo pages without overwriting existing content.')]
|
||||
final class SeedDemoCommand extends Command
|
||||
{
|
||||
public function __construct(private Connection $db, private PageRepository $pages, private MenuRepository $menus)
|
||||
public function __construct(private Connection $db, private PageRepository $pages, private MenuRepository $menus, private PartialRepository $partials)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -37,6 +38,7 @@ final class SeedDemoCommand extends Command
|
||||
$output->writeln('Created: '.$slug);
|
||||
}
|
||||
$this->seedMenus($output);
|
||||
$this->seedAreas($output);
|
||||
});
|
||||
|
||||
return Command::SUCCESS;
|
||||
@@ -72,4 +74,17 @@ final class SeedDemoCommand extends Command
|
||||
$output->writeln('Menu created: '.$slot);
|
||||
}
|
||||
}
|
||||
|
||||
/** Only creates a missing area, so an edited header is never overwritten by seeding again. */
|
||||
private function seedAreas(OutputInterface $output): void
|
||||
{
|
||||
foreach (DemoContent::areas() as $kind => $area) {
|
||||
if (null !== $this->partials->findByKind($kind)) {
|
||||
$output->writeln('Area unchanged: '.$kind);
|
||||
continue;
|
||||
}
|
||||
$this->partials->create($kind, 'default', $area['name'], $area['document']);
|
||||
$output->writeln('Area created: '.$kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,52 @@ final class DemoContent
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The header and footer a fresh installation starts with. They are ordinary block documents:
|
||||
* everything in them can be changed, moved or removed in the editor. No main heading, because
|
||||
* every page brings its own.
|
||||
*
|
||||
* @return array<string, array{name: string, document: array}>
|
||||
*/
|
||||
public static function areas(): array
|
||||
{
|
||||
$row = static fn (string $id, array $children): array => [
|
||||
'id' => $id, 'type' => 'row',
|
||||
'layout' => ['design' => ['desktop' => ['columns' => 'two', 'gap' => 'medium'], 'mobile' => ['columns' => 'one']]],
|
||||
'children' => $children,
|
||||
];
|
||||
$column = static fn (string $id, array $children): array => ['id' => $id, 'type' => 'column', 'children' => $children];
|
||||
|
||||
return [
|
||||
'header' => ['name' => 'Kopfbereich', 'document' => [
|
||||
'schemaVersion' => 11,
|
||||
'title' => 'Kopfbereich',
|
||||
'description' => 'Erscheint auf jeder Seite über dem Inhalt.',
|
||||
'blocks' => [
|
||||
self::section('area-header', [
|
||||
$row('area-header-row', [
|
||||
$column('area-header-brand', [self::text('area-header-name', 'kant cms')]),
|
||||
$column('area-header-nav', [['id' => 'area-header-menu', 'type' => 'menu', 'props' => ['slot' => 'header']]]),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
]],
|
||||
'footer' => ['name' => 'Fußbereich', 'document' => [
|
||||
'schemaVersion' => 11,
|
||||
'title' => 'Fußbereich',
|
||||
'description' => 'Erscheint auf jeder Seite unter dem Inhalt.',
|
||||
'blocks' => [
|
||||
self::section('area-footer', [
|
||||
$row('area-footer-row', [
|
||||
$column('area-footer-text', [self::text('area-footer-claim', 'Eine Grundlage. Viele Möglichkeiten.')]),
|
||||
$column('area-footer-nav', [['id' => 'area-footer-menu', 'type' => 'menu', 'props' => ['slot' => 'footer']]]),
|
||||
]),
|
||||
], 'ink'),
|
||||
],
|
||||
]],
|
||||
];
|
||||
}
|
||||
|
||||
private static function article(string $title, string $description, string $heading, array $sections): array
|
||||
{
|
||||
$blocks = [self::section('article-hero', [self::text('article-label', 'KANT CMS / ENTWICKLUNG'), self::heading('article-heading', $heading, 1)], 'mint')];
|
||||
|
||||
@@ -21,11 +21,18 @@ final class DocumentValidator
|
||||
public const V8_TYPES = ['form'];
|
||||
/** Schema v9: the breadcrumb trail, built from the page hierarchy rather than from content. */
|
||||
public const V9_TYPES = ['breadcrumb'];
|
||||
/** Schema v11: a link to a reusable section, resolved when the page is served. */
|
||||
public const V11_TYPES = ['partialref', 'menu'];
|
||||
|
||||
public function validate(array $document): void
|
||||
/**
|
||||
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
|
||||
* reusable section that appears on many pages must not bring one
|
||||
* of its own, or every page carrying it would have two.
|
||||
*/
|
||||
public function validate(array $document, bool $requireHeading = true): void
|
||||
{
|
||||
$schemaVersion = $document['schemaVersion'] ?? null;
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], true), 'Unsupported document schema.');
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], 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.');
|
||||
@@ -35,7 +42,10 @@ final class DocumentValidator
|
||||
foreach ($document['blocks'] as $block) {
|
||||
$this->block($block, $ids, $headings, 0, $schemaVersion, null);
|
||||
}
|
||||
$this->require(1 === $headings, 'A page requires exactly one main heading.');
|
||||
$this->require(
|
||||
$requireHeading ? 1 === $headings : 0 === $headings,
|
||||
$requireHeading ? 'A page requires exactly one main heading.' : 'A shared area must not contain a main heading.',
|
||||
);
|
||||
}
|
||||
|
||||
private function block(mixed $block, array &$ids, int &$headings, int $depth, int $schemaVersion, ?string $parentType): void
|
||||
@@ -50,7 +60,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) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || $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) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
|
||||
// An accordion entry only ever renders inside an accordion; at the top level or anywhere
|
||||
// else it would produce a details element without its group.
|
||||
$this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.');
|
||||
@@ -147,6 +157,15 @@ final class DocumentValidator
|
||||
if ('tabitem' === $type) {
|
||||
$this->text($props['title'] ?? null, 160);
|
||||
}
|
||||
if ('menu' === $type) {
|
||||
// The entries live in the menu administration; the block only says which slot to show.
|
||||
$this->require(isset(MenuRepository::SLOTS[$props['slot'] ?? '']), 'Unknown menu slot.');
|
||||
}
|
||||
if ('partialref' === $type) {
|
||||
// Only the address of the section is content. Its blocks live in the partial, so one
|
||||
// change reaches every page that links it and a page cannot alter a shared section.
|
||||
$this->require(is_string($props['partial'] ?? null) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props['partial']) && strlen($props['partial']) <= 80, 'Invalid section address.');
|
||||
}
|
||||
if ('form' === $type) {
|
||||
// Only the address is content. Fields, recipient and consent text stay in the database,
|
||||
// so a stored document can never redirect a submission or change what is asked.
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Domain\StaleWrite;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Header, footer and reusable sections – block documents that are not pages.
|
||||
*
|
||||
* They use the same block contracts, the same validator and the same editor as a page, so a
|
||||
* section behaves everywhere the same way. What differs is the heading rule: a page needs exactly
|
||||
* one main heading, while a header, a footer or a section that appears on many pages must not
|
||||
* bring one of its own.
|
||||
*
|
||||
* A page links a section instead of copying it. Copying already exists as the section presets;
|
||||
* linking means one change reaches every page that uses it, which is the whole point.
|
||||
*/
|
||||
final class PartialRepository
|
||||
{
|
||||
public const KINDS = ['header' => 'Kopfbereich', 'footer' => 'Fußbereich', 'section' => 'Wiederverwendbare Sektion'];
|
||||
/** A linked section may itself link one, but not deeper – and never in a circle. */
|
||||
public const MAX_NESTING = 3;
|
||||
|
||||
public function __construct(private Connection $db, private DocumentValidator $validator)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return list<array{id: string, kind: string, slug: string, name: string, document: array, version: int}> */
|
||||
public function all(string $locale = 'de'): array
|
||||
{
|
||||
return array_map($this->present(...), $this->db->fetchAllAssociative(
|
||||
'SELECT * FROM partial WHERE website_id = ? AND locale = ? ORDER BY kind, name',
|
||||
['default', $locale],
|
||||
));
|
||||
}
|
||||
|
||||
public function find(string $id): ?array
|
||||
{
|
||||
$row = $this->db->fetchAssociative('SELECT * FROM partial WHERE id = ? AND website_id = ?', [$id, 'default']);
|
||||
|
||||
return false === $row ? null : $this->present($row);
|
||||
}
|
||||
|
||||
public function findByKind(string $kind, string $slug = 'default', string $locale = 'de'): ?array
|
||||
{
|
||||
$row = $this->db->fetchAssociative(
|
||||
'SELECT * FROM partial WHERE website_id = ? AND locale = ? AND kind = ? AND slug = ?',
|
||||
['default', $locale, $kind, $slug],
|
||||
);
|
||||
|
||||
return false === $row ? null : $this->present($row);
|
||||
}
|
||||
|
||||
private function present(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => $row['id'], 'kind' => $row['kind'], 'slug' => $row['slug'], 'name' => $row['name'],
|
||||
'document' => json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR),
|
||||
'version' => (int) $row['version'],
|
||||
];
|
||||
}
|
||||
|
||||
public function create(string $kind, string $slug, string $name, array $document, string $locale = 'de'): string
|
||||
{
|
||||
if (!isset(self::KINDS[$kind])) {
|
||||
throw new InvalidPage('Unbekannte Art von Bereich.');
|
||||
}
|
||||
$slug = strtolower(trim($slug));
|
||||
if (1 !== preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $slug) || strlen($slug) > 80) {
|
||||
throw new InvalidPage('Die Adresse darf nur Kleinbuchstaben, Ziffern und einzelne Bindestriche enthalten.');
|
||||
}
|
||||
$name = trim($name);
|
||||
if ('' === $name || mb_strlen($name) > 120) {
|
||||
throw new InvalidPage('Bitte gib einen Namen mit höchstens 120 Zeichen an.');
|
||||
}
|
||||
// Header and footer exist once; several would be ambiguous at render time.
|
||||
if ('section' !== $kind && null !== $this->findByKind($kind, 'default', $locale)) {
|
||||
throw new InvalidPage(sprintf('Es gibt bereits einen %s.', self::KINDS[$kind]));
|
||||
}
|
||||
$this->validator->validate($document, false);
|
||||
if (null !== $this->findByKind($kind, $slug, $locale)) {
|
||||
throw new InvalidPage(sprintf('„%s“ ist als Adresse bereits vergeben.', $slug));
|
||||
}
|
||||
$id = bin2hex(random_bytes(16));
|
||||
$this->db->insert('partial', [
|
||||
'id' => $id, 'website_id' => 'default', 'locale' => $locale,
|
||||
'kind' => $kind, 'slug' => 'section' === $kind ? $slug : 'default', 'name' => $name,
|
||||
'document' => json_encode($document, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||
'version' => 1,
|
||||
]);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function save(string $id, array $document, int $expectedVersion): void
|
||||
{
|
||||
$this->validator->validate($document, false);
|
||||
$this->db->transactional(function () use ($id, $document, $expectedVersion): void {
|
||||
// A section must not end up containing itself, directly or through another section.
|
||||
$this->guardAgainstCycles($id, $document);
|
||||
$changed = $this->db->executeStatement(
|
||||
'UPDATE partial SET document = ?, version = version + 1 WHERE id = ? AND version = ?',
|
||||
[json_encode($document, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), $id, $expectedVersion],
|
||||
);
|
||||
if (1 !== $changed) {
|
||||
throw new StaleWrite('Der Bereich wurde inzwischen geändert. Lade die Seite neu, bevor du speicherst.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function delete(string $id): void
|
||||
{
|
||||
$partial = $this->find($id);
|
||||
if (null === $partial) {
|
||||
throw new InvalidPage('Diesen Bereich gibt es nicht mehr.');
|
||||
}
|
||||
if ('section' !== $partial['kind']) {
|
||||
throw new InvalidPage('Kopf- und Fußbereich können nicht gelöscht werden. Leere sie stattdessen.');
|
||||
}
|
||||
// A page that links a removed section shows a placeholder instead of losing its layout,
|
||||
// so removal is allowed – but the editor should know where it is still in use.
|
||||
$this->db->executeStatement('DELETE FROM partial WHERE id = ?', [$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a linked section for rendering.
|
||||
*
|
||||
* @return array{name: string, document: array}|null
|
||||
*/
|
||||
public function resolveSection(string $slug, string $locale = 'de'): ?array
|
||||
{
|
||||
$partial = $this->findByKind('section', $slug, $locale);
|
||||
|
||||
return null === $partial ? null : ['name' => $partial['name'], 'document' => $partial['document']];
|
||||
}
|
||||
|
||||
/** @throws InvalidPage when the document would make a section contain itself */
|
||||
private function guardAgainstCycles(string $id, array $document, int $depth = 0): void
|
||||
{
|
||||
$partial = $this->find($id);
|
||||
if (null === $partial || 'section' !== $partial['kind']) {
|
||||
return;
|
||||
}
|
||||
foreach ($this->referencedSlugs($document) as $slug) {
|
||||
if ($slug === $partial['slug']) {
|
||||
throw new InvalidPage('Eine Sektion kann sich nicht selbst enthalten.');
|
||||
}
|
||||
if ($depth + 1 >= self::MAX_NESTING) {
|
||||
throw new InvalidPage(sprintf('Sektionen dürfen höchstens %d Ebenen tief verschachtelt werden.', self::MAX_NESTING));
|
||||
}
|
||||
$nested = $this->findByKind('section', $slug, $partial['locale'] ?? 'de');
|
||||
if (null !== $nested) {
|
||||
$this->guardAgainstCycles($id, $nested['document'], $depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function referencedSlugs(array $document): array
|
||||
{
|
||||
$slugs = [];
|
||||
$walk = function (array $blocks) use (&$walk, &$slugs): void {
|
||||
foreach ($blocks as $block) {
|
||||
if ('partialref' === ($block['type'] ?? null) && is_string($block['props']['partial'] ?? null)) {
|
||||
$slugs[] = $block['props']['partial'];
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($document['blocks'] ?? []);
|
||||
|
||||
return array_values(array_unique($slugs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Which pages and sections link a given section. Shown before deleting, so nobody removes
|
||||
* something that is still in use without knowing it.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function usedBy(string $slug, string $locale = 'de'): array
|
||||
{
|
||||
$users = [];
|
||||
foreach ($this->db->fetchAllAssociative(
|
||||
'SELECT p.path, r.document FROM page p JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.locale = ? AND p.deleted_at IS NULL',
|
||||
['default', $locale],
|
||||
) as $row) {
|
||||
if (in_array($slug, $this->referencedSlugs(json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR)), true)) {
|
||||
$users[] = '/'.$row['path'];
|
||||
}
|
||||
}
|
||||
foreach ($this->all($locale) as $partial) {
|
||||
if ($partial['slug'] !== $slug && in_array($slug, $this->referencedSlugs($partial['document']), true)) {
|
||||
$users[] = $partial['name'];
|
||||
}
|
||||
}
|
||||
|
||||
return $users;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Content\DemoContent;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\PostRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\StyleCompiler;
|
||||
@@ -24,7 +25,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 PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private CsrfTokenManagerInterface $csrf)
|
||||
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CsrfTokenManagerInterface $csrf)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -325,12 +326,26 @@ final class EditorController extends AbstractController
|
||||
$this->validator->validate($document);
|
||||
|
||||
$theme = $this->theme->load();
|
||||
$areas = [];
|
||||
foreach (['header', 'footer'] as $kind) {
|
||||
$area = $this->partials->findByKind($kind);
|
||||
if (null !== $area) {
|
||||
$areas[$kind] = $area['document'];
|
||||
}
|
||||
}
|
||||
$sections = [];
|
||||
foreach ($this->partials->referencedSlugs($document) as $slug) {
|
||||
$section = $this->partials->resolveSection($slug);
|
||||
if (null !== $section) {
|
||||
$sections[$slug] = $section['document'];
|
||||
}
|
||||
}
|
||||
$navigation = [];
|
||||
foreach ($this->menus->all() as $slot => $menu) {
|
||||
$navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path']);
|
||||
}
|
||||
|
||||
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'needsTabs' => false, 'pageStyles' => $this->theme->css($theme).$this->styles->compileDocument($document), 'googleFonts' => $this->theme->googleFontsUrl($theme)]);
|
||||
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'areas' => $areas, 'sections' => $sections, 'needsTabs' => false, 'pageStyles' => $this->theme->css($theme).$this->styles->compileDocument($document), 'googleFonts' => $this->theme->googleFontsUrl($theme)]);
|
||||
}
|
||||
if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion']) || $body['expectedVersion'] < 0) {
|
||||
throw new \InvalidArgumentException();
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Controller;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\PostRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
@@ -17,7 +18,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 Theme $theme, private StyleCompiler $styles, private string $secret, private string $siteOrigin)
|
||||
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 Theme $theme, private StyleCompiler $styles, private string $secret, private string $siteOrigin)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -38,10 +39,15 @@ final class PageController extends AbstractController
|
||||
$page = $this->pages->findPublished($path);
|
||||
$styles = '';
|
||||
$googleFonts = null;
|
||||
$areas = $this->areas();
|
||||
if (null !== $page) {
|
||||
$theme = $this->theme->load();
|
||||
// Theme first, block styles after, so a block always wins over the site defaults.
|
||||
$sections = $this->sections($page['document'], $areas);
|
||||
$styles = $this->theme->css($theme).$this->styles->compileDocument($page['document']);
|
||||
foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
|
||||
$styles .= $this->styles->compileDocument($extra);
|
||||
}
|
||||
$googleFonts = $this->theme->googleFontsUrl($theme);
|
||||
}
|
||||
if (null === $page) {
|
||||
@@ -50,7 +56,7 @@ final class PageController extends AbstractController
|
||||
if (null !== $redirect && 301 === $redirect['status']) {
|
||||
return $this->shell($this->redirect('/'.$redirect['to'], 301));
|
||||
}
|
||||
$response = $this->render('not-found.html.twig', [], new Response(status: null !== $redirect ? 410 : 404));
|
||||
$response = $this->render('not-found.html.twig', ['areas' => $areas], new Response(status: null !== $redirect ? 410 : 404));
|
||||
} else {
|
||||
$response = $this->render('page.html.twig', [
|
||||
'page' => $page,
|
||||
@@ -62,6 +68,8 @@ final class PageController extends AbstractController
|
||||
'forms' => $this->formStates($page, $request->query->getString('gesendet')),
|
||||
'breadcrumbs' => $this->breadcrumbs($page),
|
||||
'structuredData' => $this->structuredData($page),
|
||||
'areas' => $areas,
|
||||
'sections' => $sections,
|
||||
'pageStyles' => $styles,
|
||||
'googleFonts' => $googleFonts,
|
||||
]);
|
||||
@@ -240,6 +248,8 @@ final class PageController extends AbstractController
|
||||
$base = null === $term ? '/beitraege' : '/'.PostRepository::ARCHIVE_PREFIX[$kind].'/'.$term['slug'];
|
||||
|
||||
return $this->shell($this->render('archive.html.twig', [
|
||||
'areas' => $this->areas(),
|
||||
'navigation' => $this->navigation(''),
|
||||
'term' => $term,
|
||||
'heading' => null === $term ? 'Beiträge' : $term['name'],
|
||||
'result' => $result,
|
||||
@@ -291,6 +301,58 @@ final class PageController extends AbstractController
|
||||
return $lists;
|
||||
}
|
||||
|
||||
/**
|
||||
* The header and footer documents. Absent areas simply render nothing, so a site without a
|
||||
* header is a valid site rather than a broken one.
|
||||
*
|
||||
* @return array<string, array>
|
||||
*/
|
||||
private function areas(): array
|
||||
{
|
||||
$areas = [];
|
||||
foreach (['header', 'footer'] as $kind) {
|
||||
$partial = $this->partials->findByKind($kind);
|
||||
if (null !== $partial) {
|
||||
$areas[$kind] = $partial['document'];
|
||||
}
|
||||
}
|
||||
|
||||
return $areas;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* in the editor, so a visitor never sees a gap explained.
|
||||
*
|
||||
* @return array<string, array>
|
||||
*/
|
||||
private function sections(array $document, array $areas): array
|
||||
{
|
||||
$resolved = [];
|
||||
$pending = $this->partials->referencedSlugs($document);
|
||||
foreach ($areas as $area) {
|
||||
$pending = [...$pending, ...$this->partials->referencedSlugs($area)];
|
||||
}
|
||||
for ($depth = 0; $depth < PartialRepository::MAX_NESTING && [] !== $pending; ++$depth) {
|
||||
$next = [];
|
||||
foreach (array_unique($pending) as $slug) {
|
||||
if (isset($resolved[$slug])) {
|
||||
continue;
|
||||
}
|
||||
$section = $this->partials->resolveSection($slug);
|
||||
if (null === $section) {
|
||||
continue;
|
||||
}
|
||||
$resolved[$slug] = $section['document'];
|
||||
$next = [...$next, ...$this->partials->referencedSlugs($section['document'])];
|
||||
}
|
||||
$pending = $next;
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<array>>
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\StyleCompiler;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Domain\StaleWrite;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
|
||||
/**
|
||||
* Header, footer and reusable sections in the same editor as a page.
|
||||
*
|
||||
* The editor front end takes its API base from a data attribute, so it works against pages and
|
||||
* against areas without a second implementation. Areas have no draft and no publication: they are
|
||||
* shared, so a saved change is live. The editor hides the publish button for them.
|
||||
*/
|
||||
#[Route('/admin/partials')]
|
||||
final class PartialController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private PartialRepository $partials,
|
||||
private DocumentValidator $validator,
|
||||
private MenuRepository $menus,
|
||||
private Theme $theme,
|
||||
private StyleCompiler $styles,
|
||||
private CsrfTokenManagerInterface $csrf,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('', name: 'admin_partials', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$partials = [];
|
||||
foreach ($this->partials->all() as $partial) {
|
||||
$partial['usedBy'] = 'section' === $partial['kind'] ? $this->partials->usedBy($partial['slug']) : [];
|
||||
$partials[] = $partial;
|
||||
}
|
||||
|
||||
return $this->render('admin/partials.html.twig', ['partials' => $partials, 'kinds' => PartialRepository::KINDS]);
|
||||
}
|
||||
|
||||
#[Route('/new', name: 'admin_partial_create', methods: ['POST'])]
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('partials', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
$name = trim($request->request->getString('name'));
|
||||
try {
|
||||
$id = $this->partials->create(
|
||||
$request->request->getString('kind', 'section'),
|
||||
$request->request->getString('slug') ?: $this->slugify($name),
|
||||
$name,
|
||||
// A shared area starts with one empty section, so there is something to click on.
|
||||
['schemaVersion' => 11, 'title' => '' === $name ? 'Bereich' : $name, 'description' => 'Wird an jeder Stelle gezeigt, die ihn einbindet.', 'blocks' => [
|
||||
['id' => 'b_'.bin2hex(random_bytes(8)), 'type' => 'section', 'layout' => ['tone' => 'paper', 'width' => 'normal'], 'children' => []],
|
||||
]],
|
||||
);
|
||||
} catch (InvalidPage|InvalidDocument $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_partial_editor', ['id' => $id]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/delete', name: 'admin_partial_delete', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function delete(Request $request, string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('partials', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
try {
|
||||
$this->partials->delete($id);
|
||||
} catch (InvalidPage $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->back('Die Sektion wurde entfernt. Seiten, die sie einbinden, zeigen an dieser Stelle nichts mehr.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/{id}', name: 'admin_partial_editor', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
|
||||
public function editor(string $id): Response
|
||||
{
|
||||
return $this->render('admin/editor.html.twig', ['partial' => $this->partial($id), 'kinds' => PartialRepository::KINDS]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/document', name: 'admin_partial_document', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
|
||||
public function document(string $id): JsonResponse
|
||||
{
|
||||
return $this->json($this->payload($this->partial($id)));
|
||||
}
|
||||
|
||||
#[Route('/{id}/draft', name: 'admin_partial_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
#[Route('/{id}/preview', name: 'admin_partial_preview', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function write(Request $request, string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('editor', $request->headers->get('X-CSRF-Token'))) {
|
||||
return $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'], 403);
|
||||
}
|
||||
if (strlen($request->getContent()) > 270000) {
|
||||
return $this->json(['error' => 'Der Bereich ist zu groß (maximal 256 KiB Inhalt).'], 413);
|
||||
}
|
||||
$partial = $this->partial($id);
|
||||
try {
|
||||
$body = json_decode($request->getContent(), true, 64, JSON_THROW_ON_ERROR);
|
||||
$document = $body['document'] ?? null;
|
||||
if (!is_array($body) || !is_array($document)) {
|
||||
throw new \InvalidArgumentException();
|
||||
}
|
||||
if ('admin_partial_preview' === $request->attributes->get('_route')) {
|
||||
$this->validator->validate($document, false);
|
||||
|
||||
return $this->renderPreview($partial, $document);
|
||||
}
|
||||
if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion'])) {
|
||||
throw new \InvalidArgumentException();
|
||||
}
|
||||
$this->partials->save($id, $document, $body['expectedVersion']);
|
||||
$partial['document'] = $document;
|
||||
$partial['version'] = $body['expectedVersion'] + 1;
|
||||
|
||||
return $this->json($this->payload($partial));
|
||||
} catch (StaleWrite $e) {
|
||||
return $this->json(['error' => $e->getMessage(), 'conflict' => true], 409);
|
||||
} catch (InvalidDocument|InvalidPage $e) {
|
||||
return $this->json(['error' => 'Bitte prüfe die Inhalte: '.$e->getMessage()], 422);
|
||||
} catch (\JsonException|\InvalidArgumentException) {
|
||||
return $this->json(['error' => 'Die Anfrage enthält kein gültiges Dokument.'], 422);
|
||||
}
|
||||
}
|
||||
|
||||
/** The area on its own, in the shell of a page, so the editor preview looks like the result. */
|
||||
private function renderPreview(array $partial, array $document): Response
|
||||
{
|
||||
$theme = $this->theme->load();
|
||||
$navigation = [];
|
||||
foreach ($this->menus->all() as $slot => $menu) {
|
||||
$navigation[$slot] = $this->menus->resolve($menu['items'], '');
|
||||
}
|
||||
$sections = [];
|
||||
foreach ($this->partials->referencedSlugs($document) as $slug) {
|
||||
$section = $this->partials->resolveSection($slug);
|
||||
if (null !== $section) {
|
||||
$sections[$slug] = $section['document'];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('page.html.twig', [
|
||||
'page' => ['locale' => 'de', 'path' => '', 'type' => 'page', 'slug' => '', 'document' => $document],
|
||||
'canonical' => '',
|
||||
'editing' => true,
|
||||
'navigation' => $navigation,
|
||||
'areas' => [],
|
||||
'sections' => $sections,
|
||||
'postLists' => [],
|
||||
'postMeta' => null,
|
||||
'needsTabs' => false,
|
||||
'forms' => [],
|
||||
'pageStyles' => $this->theme->css($theme).$this->styles->compileDocument($document),
|
||||
'googleFonts' => $this->theme->googleFontsUrl($theme),
|
||||
]);
|
||||
}
|
||||
|
||||
private function partial(string $id): array
|
||||
{
|
||||
return $this->partials->find($id) ?? throw $this->createNotFoundException('Bereich nicht gefunden.');
|
||||
}
|
||||
|
||||
private function payload(array $partial): array
|
||||
{
|
||||
return [
|
||||
'document' => $partial['document'],
|
||||
'version' => $partial['version'],
|
||||
'revisionId' => null,
|
||||
'publishedRevisionId' => null,
|
||||
'csrfToken' => $this->csrf->getToken('editor')->getValue(),
|
||||
];
|
||||
}
|
||||
|
||||
private function slugify(string $name): string
|
||||
{
|
||||
$slug = strtr(mb_strtolower($name, 'UTF-8'), ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']);
|
||||
|
||||
return substr(trim(preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '', '-'), 0, 80);
|
||||
}
|
||||
|
||||
private function back(string $message, string $type = 'error'): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
|
||||
return $this->redirectToRoute('admin_partials');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user