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:
kantarciandClaude Opus 5 committed 2026-09-12 16:38:08 +02:00
1 parent 33e4ac0c54
commit d0938eec69
26 files changed
+955 -56

No files matched your search

+202
View File
@@ -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;
}
}