Files
KantSites/src/Content/MenuRepository.php
T
kantarci 3391fea58b
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Add visual button presets, jump targets and menu controls
2026-09-14 13:47:49 +02:00

198 lines
9.4 KiB
PHP

<?php
namespace App\Content;
use App\Domain\InvalidMenu;
use App\Domain\StaleWrite;
use Doctrine\DBAL\Connection;
/**
* Menus are small enough to live as one validated item tree per slot. An item points at a page
* (optionally at an anchor inside it) or at an external address. Page references are resolved when
* the menu is rendered, never stored as a path: renaming or deleting a page must not silently
* produce a dead link.
*/
final class MenuRepository
{
/**
* The places a menu can be maintained for. They are not positions on the page - a menu block
* can stand anywhere - but separate sets of entries: a header needs other links than a
* footer, and legal links are their own short list.
*/
public const SLOTS = [
'header' => 'Hauptnavigation',
'secondary' => 'Zweite Navigation',
'footer' => 'Fußzeile',
'legal' => 'Rechtliches',
];
public const MAX_ITEMS = 60;
public const MAX_DEPTH = 2;
public function __construct(private Connection $db, private SiteSettings $settings)
{
}
/**
* @return array<string, array{id: string, name: string, items: array, version: int}>
*/
public function all(string $locale = 'de'): array
{
$menus = [];
foreach ($this->db->fetchAllAssociative('SELECT id, slot, name, items, version FROM menu WHERE website_id = ? AND locale = ?', ['default', $locale]) as $row) {
$menus[$row['slot']] = [
'id' => $row['id'], 'name' => $row['name'], 'version' => (int) $row['version'],
'items' => json_decode($row['items'], true, 512, JSON_THROW_ON_ERROR),
];
}
return $menus;
}
public function save(string $slot, string $name, array $items, int $expectedVersion, string $locale = 'de'): void
{
if (!isset(self::SLOTS[$slot])) {
throw new InvalidMenu('Unbekannter Menüplatz.');
}
$name = trim($name);
if ('' === $name || mb_strlen($name) > 120) {
throw new InvalidMenu('Bitte gib einen Namen mit höchstens 120 Zeichen an.');
}
$this->validate($items);
$this->db->transactional(function () use ($slot, $name, $items, $expectedVersion, $locale): void {
$encoded = json_encode($items, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
$existing = $this->db->fetchAssociative('SELECT id, version FROM menu WHERE website_id = ? AND locale = ? AND slot = ?', ['default', $locale, $slot]);
if (false === $existing) {
if (0 !== $expectedVersion) {
throw new StaleWrite('Das Menü wurde inzwischen angelegt. Lade die Seite neu.');
}
$this->db->insert('menu', ['id' => bin2hex(random_bytes(16)), 'website_id' => 'default', 'locale' => $locale, 'slot' => $slot, 'name' => $name, 'items' => $encoded, 'version' => 1]);
return;
}
$changed = $this->db->executeStatement(
'UPDATE menu SET name = ?, items = ?, version = version + 1 WHERE id = ? AND version = ?',
[$name, $encoded, $existing['id'], $expectedVersion],
);
if (1 !== $changed) {
throw new StaleWrite('Das Menü wurde inzwischen geändert. Lade die Seite neu, bevor du speicherst.');
}
});
}
/**
* Turns the stored tree into links. Items whose page is gone or not published are dropped:
* a deleted page must not break the navigation or expose a draft.
*
* @return list<array{label: string, href: string, current: bool, children: list<array>}>
*/
/**
* Die Seitenliste, die jedes Menue zum Aufloesen braucht - einmal je Anfrage.
*
* Vorher holte jedes Menue sie selbst. Mit zwei Menuen fiel das nicht auf, mit vieren - Kopf,
* zwei Listen im Fuss und die Rechtszeile - waren es vier gleiche Abfragen je Seitenaufruf.
*
* @var array<string, array<string, mixed>>|null
*/
private ?array $pages = null;
public function resolve(array $items, string $currentPath, string $locale = 'de'): array
{
if (null === $this->pages) {
$this->pages = [];
foreach ($this->db->fetchAllAssociative('SELECT id, path, published_revision_id, draft_revision_id FROM page WHERE website_id = ? AND locale = ? AND deleted_at IS NULL', ['default', $locale]) as $page) {
$this->pages[$page['id']] = $page;
}
}
return $this->resolveLevel($items, $this->pages, $currentPath, $this->settings->homePath());
}
private function resolveLevel(array $items, array $pages, string $currentPath, string $homePath): array
{
$links = [];
foreach ($items as $item) {
$children = $this->resolveLevel($item['children'] ?? [], $pages, $currentPath, $homePath);
$label = $item['label'] ?? '';
if ('page' === $item['type']) {
$page = $pages[$item['pageId']] ?? null;
if (null === $page || null === $page['published_revision_id']) {
// Keep any resolvable children rather than dropping a whole branch.
array_push($links, ...$children);
continue;
}
$path = $this->settings->publicPath($page['path'], $homePath);
$anchor = $item['anchor'] ?? '';
$href = $path.('' === $anchor ? '' : '#'.$anchor);
// An anchor link is never the current page on its own; it jumps inside one.
$current = '' === $anchor && $page['path'] === $currentPath;
} else {
$href = $item['url'];
$current = false;
}
if ('' === trim($label)) {
continue;
}
$links[] = ['label' => $label, 'href' => $href, 'current' => $current, 'children' => $children, ...(($item['newTab'] ?? false) ? ['newTab' => true] : []), ...(!empty($item['icon']) ? ['icon' => $item['icon'], 'iconPosition' => $item['iconPosition'] ?? 'before'] : [])];
}
return $links;
}
private function validate(array $items, int $depth = 1, int &$count = 0): void
{
if (!array_is_list($items)) {
throw new InvalidMenu('Die Menüstruktur ist ungültig.');
}
if ([] !== $items && $depth > self::MAX_DEPTH) {
throw new InvalidMenu(sprintf('Mehr als %d Menüebenen sind nicht vorgesehen.', self::MAX_DEPTH));
}
foreach ($items as $item) {
if (++$count > self::MAX_ITEMS) {
throw new InvalidMenu(sprintf('Ein Menü fasst höchstens %d Einträge.', self::MAX_ITEMS));
}
if (!is_array($item) || !is_string($item['label'] ?? null) || '' === trim($item['label']) || mb_strlen($item['label']) > 80) {
throw new InvalidMenu('Jeder Eintrag braucht eine Beschriftung mit höchstens 80 Zeichen.');
}
if (array_key_exists('newTab', $item) && !is_bool($item['newTab'])) {
throw new InvalidMenu('In neuem Tab öffnen ist an oder aus.');
}
if (isset($item['icon']) && (!is_string($item['icon']) || !in_array($item['icon'], ['', ...DocumentValidator::ICONS], true))) {
throw new InvalidMenu('Unbekanntes Menü-Icon.');
}
if (isset($item['iconPosition']) && !in_array($item['iconPosition'], ['before', 'after'], true)) {
throw new InvalidMenu('Ein Icon steht vor oder nach dem Text.');
}
if ('page' === ($item['type'] ?? null)) {
if (!is_string($item['pageId'] ?? null) || 1 !== preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $item['pageId'])) {
throw new InvalidMenu('Für einen Seiteneintrag fehlt die Seite.');
}
$anchor = $item['anchor'] ?? '';
if (!is_string($anchor) || ('' !== $anchor && 1 !== preg_match('/^[a-zA-Z][a-zA-Z0-9_-]{0,64}$/D', $anchor))) {
throw new InvalidMenu('Ein Sprungziel besteht aus Buchstaben, Ziffern, Bindestrich und Unterstrich und beginnt mit einem Buchstaben.');
}
} elseif ('url' === ($item['type'] ?? null)) {
$this->url($item['url'] ?? null);
} else {
throw new InvalidMenu('Unbekannte Zielart.');
}
$this->validate($item['children'] ?? [], $depth + 1, $count);
}
}
private function url(mixed $url): void
{
if (!is_string($url) || strlen($url) > 2048 || preg_match('/[\x00-\x20\\\\]/', $url)) {
throw new InvalidMenu('Die Adresse ist leer, zu lang oder enthält unerlaubte Zeichen.');
}
// tel: and mailto: are real navigation targets on the pilot site, so they belong here.
$allowed = str_starts_with($url, 'https://') && false !== filter_var($url, FILTER_VALIDATE_URL);
$allowed = $allowed || (str_starts_with($url, '/') && !str_starts_with($url, '//'));
$allowed = $allowed || 1 === preg_match('/^mailto:[^@\s]+@[^@\s]+$/D', $url);
$allowed = $allowed || 1 === preg_match('/^tel:\+?[0-9 ()-]{3,32}$/D', $url);
$allowed = $allowed || 1 === preg_match('/^#[a-zA-Z][a-zA-Z0-9_-]{0,63}$/D', $url);
if (!$allowed) {
throw new InvalidMenu('Erlaubt sind HTTPS-Adressen, lokale Pfade, Sprungziele wie #kontakt sowie mailto: und tel:.');
}
}
}