In der wechselseitigen Beitragsliste stand "Weiterlesen" als reine Beschriftung da: sichtbar wie ein Knopf, aber nicht anklickbar. Jetzt ein Link, mit dem Beitragstitel im Linktext fuer Vorlesesoftware. Jedes Menue holte beim Aufloesen die vollstaendige Seitenliste selbst. Mit zwei Menuen fiel das nicht auf, mit vier - Kopf, zwei Listen im Fuss, Rechtszeile - waren es vier gleiche Abfragen je Seitenaufruf. Einmal je Anfrage gemerkt: 21 -> 18 Abfragen auf der Startseite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
188 lines
8.5 KiB
PHP
188 lines
8.5 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];
|
|
}
|
|
|
|
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 ('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);
|
|
if (!$allowed) {
|
|
throw new InvalidMenu('Erlaubt sind HTTPS-Adressen, lokale Pfade sowie mailto: und tel:.');
|
|
}
|
|
}
|
|
}
|