'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, private RichText $richText = new RichText()) { } /** @return list */ 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'], ]; } /** * The header or footer areas a page can choose from, by address. * * @return array address => name */ public function choices(string $kind, string $locale = 'de'): array { $choices = []; foreach ($this->db->fetchAllAssociative( 'SELECT slug, name FROM partial WHERE website_id = ? AND locale = ? AND kind = ? ORDER BY slug = ? DESC, name', ['default', $locale, $kind, 'default'], ) as $row) { $choices[$row['slug']] = $row['name']; } return $choices; } 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.'); } // Several headers and footers are allowed since pages may choose one: the first one // keeps the address "default" and is what a page without an own choice gets. if ('section' !== $kind && null === $this->findByKind($kind, 'default', $locale)) { $slug = 'default'; } $document = $this->richText->normaliseDocument($document); $this->validator->validate($document, false, true); 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' => $slug, '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 { $document = $this->richText->normaliseDocument($document); $this->validator->validate($document, false, true); $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 */ /** * Dieselbe Sektion mehrfach auf einer Seite: die zweite und jede weitere Verknüpfung bekommt * eine Kopie mit eigenen Kennungen (`id-2`, `id-3`) und ohne Sprungziele, damit die Seite keine * doppelten IDs trägt - ein Anker, ein Stil und jede Skriptsuche träfen sonst nur die erste. * * Gezählt wird in Renderreihenfolge über alle Dokumente einer Seite hinweg (`$seen` teilen sich * Kopf, Inhalt und Fuß); verschachtelte Verknüpfungen in einer Sektion zählen mit, bis zur * Verschachtelungsgrenze. `$sections` bekommt die Kopien unter `slug~n`, der Verweis im * zurückgegebenen Dokument zeigt dorthin. * * @param array $sections die aufgelösten Sektionen, werden um Kopien ergänzt * @param array $seen Zähler je Slug * @param array $pristine die Sektionen, wie sie gespeichert sind */ public function expandRepeatedLinks(array $document, array &$sections, array &$seen, array $pristine, int $depth = 0): array { $walk = function (array $blocks) use (&$walk, &$sections, &$seen, $pristine, $depth): array { foreach ($blocks as $i => $block) { $slug = 'partialref' === ($block['type'] ?? null) && is_string($block['props']['partial'] ?? null) ? $block['props']['partial'] : null; if (null !== $slug && isset($pristine[$slug])) { $n = $seen[$slug] = ($seen[$slug] ?? 0) + 1; if (1 === $n) { if ($depth < self::MAX_NESTING) { $sections[$slug] = $this->expandRepeatedLinks($pristine[$slug], $sections, $seen, $pristine, $depth + 1); } } else { $key = $slug.'~'.$n; $copy = $this->renamed($pristine[$slug], '-'.$n); $sections[$key] = $depth < self::MAX_NESTING ? $this->expandRepeatedLinks($copy, $sections, $seen, $pristine, $depth + 1) : $copy; $blocks[$i]['props']['partial'] = $key; } } if (isset($block['children']) && is_array($block['children'])) { $blocks[$i]['children'] = $walk($block['children']); } } return $blocks; }; $document['blocks'] = $walk($document['blocks'] ?? []); return $document; } /** Ein Dokument mit angehängter Kennung an jedem Block, ohne Sprungziele. */ private function renamed(array $document, string $suffix): array { $walk = static function (array $blocks) use (&$walk, $suffix): array { foreach ($blocks as $i => $block) { if (isset($block['id'])) { $blocks[$i]['id'] = substr((string) $block['id'], 0, 64 - strlen($suffix)).$suffix; } unset($blocks[$i]['anchor']); if (isset($block['children']) && is_array($block['children'])) { $blocks[$i]['children'] = $walk($block['children']); } } return $blocks; }; $document['blocks'] = $walk($document['blocks'] ?? []); return $document; } 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 */ 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; } }