Files
KantSites/src/Content/MenuRepository.php
T
kantarciandClaude Opus 5.5 e15b2517b1 Translate pages, areas, menus, forms and posts field by field instead of rebuilding them
A site can have further languages (Settings → Languages). A page is built once; each language
adds only its texts, keyed by the stable block IDs, plus its own address under /en/… (ADR 111,
document schema v27, migration 20261009000100 for page_translation).

- Editor: DE | EN switch, a list of all texts with original, state (missing, translated,
  original changed) and filter; structure stays with the main language. Undo, drafts,
  publishing and revisions cover all languages at once.
- Website: /en and translated slugs, lang, canonical, hreflang with x-default, og:locale,
  sitemap; links to translated pages follow the language; a language switch block.
- Header, footer and linked sections translate the same way; menu entries carry labels per
  language; fixed template texts come from a small dictionary.
- Forms: labels, help, options, button and success message per language; messages and the
  step script speak the language, the submission and the email stay in the main language.
- Posts, archives, category names, consent text and the 404 page follow the language.
- Single-language sites see none of it and never touch the new table.

The forms administration becomes an overview with one row per form and its own edit page;
fields are one line each with details collapsed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:56:52 +02:00

224 lines
11 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;
/**
* @param string|null $language eine weitere Sprache (ADR 111): Einträge zeigen dann ihre
* Beschriftung in dieser Sprache, sofern sie eine haben
*/
public function resolve(array $items, string $currentPath, string $locale = 'de', ?string $language = null): 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(), $language);
}
private function resolveLevel(array $items, array $pages, string $currentPath, string $homePath, ?string $language = null): array
{
$links = [];
foreach ($items as $item) {
$children = $this->resolveLevel($item['children'] ?? [], $pages, $currentPath, $homePath, $language);
$translated = null === $language ? '' : trim((string) ($item['labels'][$language] ?? ''));
$label = '' !== $translated ? $translated : ($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'] : []), ...(isset($item['button']) ? ['button' => $item['button']] : [])];
}
return $links;
}
/** Die Farben der Menü-Buttons aller Plätze, fürs Stylesheet der Seite (ADR 110). */
public function buttonCss(array $themeButtons, string $locale = 'de'): string
{
return MenuButton::css($this->all($locale), $themeButtons);
}
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.');
}
// Beschriftungen in weiteren Sprachen (ADR 111): je Sprache ein Text, wie die
// Beschriftung höchstens 80 Zeichen. Eine leere zeigt die Beschriftung der Hauptsprache.
if (array_key_exists('labels', $item)) {
if (!is_array($item['labels']) || ([] !== $item['labels'] && array_is_list($item['labels']))) {
throw new InvalidMenu('Die übersetzten Beschriftungen sind ungültig.');
}
foreach ($item['labels'] as $code => $text) {
if (!is_string($code) || !isset(Languages::CATALOG[$code]) || !is_string($text) || '' === trim($text) || mb_strlen($text) > 80) {
throw new InvalidMenu('Eine übersetzte Beschriftung hat höchstens 80 Zeichen und gehört zu einer bekannten Sprache.');
}
}
}
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.');
}
if (array_key_exists('button', $item)) {
MenuButton::validate($item['button'], $item);
}
$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:.');
}
}
}