'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); 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); $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 */ 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; } }