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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user