Frei wählbare Startseite; Standarddateien waren lokal unerreichbar
Eine Quelle entscheidet jetzt, welche Seite unter / antwortet: SiteSettings::homePath() und publicPath(). Das Literal home kommt im Anwendungscode nicht mehr vor - öffentliche Routen, Canonical, Sitemap, llms.txt, Menüauflösung, Beitragslisten und der Formularrücksprung benutzen dieselben zwei Methoden. Die gewählte Seite muss veröffentlicht und nicht im Papierkorb sein, ihre eigene Adresse leitet dauerhaft auf /, und als Wurzel ist sie gegen Umbenennen und Papierkorb gesperrt. Zweiter echter Fehler, den nur ein HTTP-Aufruf zeigt: /sitemap.xml, /robots.txt, /llms.txt und /feed.xml waren lokal nie erreichbar. Der eingebaute PHP-Server antwortet für fehlende Pfade mit Dateiendung selbst mit 404, ohne den Frontcontroller zu fragen; alle Kernel-Tests bestanden trotzdem, weil sie die Anwendung direkt aufrufen. bin/dev-router.php bildet die Rewrite-Regel des Hostings nach, Start- und CI-Befehl nutzen es, und die Browserprüfung ruft die vier Adressen jetzt über HTTP auf. Barrierefreiheitsbefunde werden nach Ursache getrennt: ein Kontrastfehler aus Theme oder Stylesheet bleibt ein Defekt und bricht den Lauf ab, einer aus einer im Editor gesetzten Blockfarbe wird bei jedem Lauf mit Seite, Farbe und Verhältnis genannt und in reports/browser.json geschrieben, färbt aber keinen Codewechsel rot. Geprüft: 162 PHP-Tests / 636 Assertions unter PHP 8.2.12 und 8.4.22, 15 JavaScript-Modelltests, vollständiger Editortest mit axe, wieder grüne öffentliche Browserprüfung und Lighthouse mit je drei Läufen mobil und Desktop, alle fünf Kategorien 100. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6afad30956
commit
33e4ac0c54
21 files changed
+266
-49
No files matched your search
@@ -18,7 +18,7 @@ final class MenuRepository
|
||||
public const MAX_ITEMS = 60;
|
||||
public const MAX_DEPTH = 2;
|
||||
|
||||
public function __construct(private Connection $db)
|
||||
public function __construct(private Connection $db, private SiteSettings $settings)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -82,14 +82,14 @@ final class MenuRepository
|
||||
$pages[$page['id']] = $page;
|
||||
}
|
||||
|
||||
return $this->resolveLevel($items, $pages, $currentPath);
|
||||
return $this->resolveLevel($items, $pages, $currentPath, $this->settings->homePath());
|
||||
}
|
||||
|
||||
private function resolveLevel(array $items, array $pages, string $currentPath): array
|
||||
private function resolveLevel(array $items, array $pages, string $currentPath, string $homePath): array
|
||||
{
|
||||
$links = [];
|
||||
foreach ($items as $item) {
|
||||
$children = $this->resolveLevel($item['children'] ?? [], $pages, $currentPath);
|
||||
$children = $this->resolveLevel($item['children'] ?? [], $pages, $currentPath, $homePath);
|
||||
$label = $item['label'] ?? '';
|
||||
if ('page' === $item['type']) {
|
||||
$page = $pages[$item['pageId']] ?? null;
|
||||
@@ -98,7 +98,7 @@ final class MenuRepository
|
||||
array_push($links, ...$children);
|
||||
continue;
|
||||
}
|
||||
$path = 'home' === $page['path'] ? '/' : '/'.$page['path'];
|
||||
$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.
|
||||
|
||||
@@ -12,7 +12,7 @@ final class PageRepository
|
||||
public const RESERVED_SLUGS = ['admin', 'media', 'assets', 'favicon', 'kategorie', 'schlagwort', 'feed', 'beitraege'];
|
||||
public const MAX_DEPTH = 4;
|
||||
|
||||
public function __construct(private Connection $db, private DocumentValidator $validator)
|
||||
public function __construct(private Connection $db, private DocumentValidator $validator, private SiteSettings $settings)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -94,11 +94,10 @@ final class PageRepository
|
||||
if ((int) $page['version'] !== $expectedVersion) {
|
||||
throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.');
|
||||
}
|
||||
if ('home' === $page['path']) {
|
||||
// The path 'home' is assumed as the site root in several places (public routing, canonical,
|
||||
// sitemap, llms.txt, menu resolution). Making it configurable is its own step; until
|
||||
// then the start page keeps its address rather than half of the system following it.
|
||||
throw new InvalidPage('Die Startseite behält ihre Adresse. Eine andere Seite als Startseite zu wählen ist noch nicht möglich.');
|
||||
if ($page['path'] === $this->settings->homePath()) {
|
||||
// The root answers at "/". Renaming it would move the whole site to a new address,
|
||||
// which is a settings decision, not an editing one.
|
||||
throw new InvalidPage('Diese Seite ist die Startseite. Wähle unter Einstellungen zuerst eine andere Seite als Startseite.');
|
||||
}
|
||||
|
||||
$newPath = $slug;
|
||||
@@ -169,8 +168,8 @@ final class PageRepository
|
||||
if ((int) $page['version'] !== $expectedVersion) {
|
||||
throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.');
|
||||
}
|
||||
if ('home' === $page['path']) {
|
||||
throw new InvalidPage('Die Startseite kann nicht in den Papierkorb.');
|
||||
if ($page['path'] === $this->settings->homePath()) {
|
||||
throw new InvalidPage('Die Startseite kann nicht in den Papierkorb. Wähle unter Einstellungen zuerst eine andere Seite als Startseite.');
|
||||
}
|
||||
|
||||
return $this->db->executeStatement(
|
||||
|
||||
@@ -17,7 +17,7 @@ final class PostRepository
|
||||
public const ARCHIVE_PREFIX = ['category' => 'kategorie', 'tag' => 'schlagwort'];
|
||||
public const PER_PAGE = 10;
|
||||
|
||||
public function __construct(private Connection $db, private MediaLibrary $media)
|
||||
public function __construct(private Connection $db, private MediaLibrary $media, private SiteSettings $settings)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ final class PostRepository
|
||||
|
||||
return [
|
||||
'title' => $document['title'] ?? $row['path'],
|
||||
'href' => '/'.$row['path'],
|
||||
'href' => $this->settings->publicPath($row['path']),
|
||||
'excerpt' => $row['excerpt'] ?? $document['description'] ?? '',
|
||||
'author' => $row['author'],
|
||||
'publishedAt' => $row['published_at'],
|
||||
|
||||
@@ -25,6 +25,8 @@ final class SiteSettings
|
||||
'localBusiness' => false,
|
||||
'llmsTxt' => false,
|
||||
'robotsExtra' => '',
|
||||
// Which stored page answers at the site root. Everything else derives from this.
|
||||
'homePath' => 'home',
|
||||
];
|
||||
|
||||
public function __construct(private Connection $db)
|
||||
@@ -82,12 +84,37 @@ final class SiteSettings
|
||||
}
|
||||
$values['robotsExtra'] = implode("\n", array_slice($extra, 0, 30));
|
||||
|
||||
$home = trim(trim((string) ($input['homePath'] ?? self::DEFAULTS['homePath'])), '/');
|
||||
if (1 !== preg_match('#^[a-z0-9]+(?:[-/][a-z0-9]+)*$#D', $home)) {
|
||||
throw new InvalidPage('Die Startseite braucht eine gültige Adresse.');
|
||||
}
|
||||
// A root that points nowhere would answer every visit with 404, so it must exist.
|
||||
if (false === $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND path = ? AND deleted_at IS NULL', ['default', $home])) {
|
||||
throw new InvalidPage(sprintf('Es gibt keine Seite mit der Adresse /%s.', $home));
|
||||
}
|
||||
$values['homePath'] = $home;
|
||||
|
||||
$this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
|
||||
json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||
'default',
|
||||
]);
|
||||
}
|
||||
|
||||
/** The stored path of the page that answers at the site root. */
|
||||
public function homePath(): string
|
||||
{
|
||||
return $this->load()['homePath'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a stored page path into its public address. The root page answers at "/" and must not
|
||||
* also be reachable under its own path, or the same content would live at two addresses.
|
||||
*/
|
||||
public function publicPath(string $storedPath, ?string $homePath = null): string
|
||||
{
|
||||
return $storedPath === ($homePath ?? $this->homePath()) ? '/' : '/'.$storedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured data for the organisation, built from the maintained values only. Returns null
|
||||
* while nothing is maintained: an empty Organization block would be noise, not data.
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Controller;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -25,6 +26,7 @@ final class FormController extends AbstractController
|
||||
private FormRepository $forms,
|
||||
private PageRepository $pages,
|
||||
private MenuRepository $menus,
|
||||
private SiteSettings $settings,
|
||||
private MailerInterface $mailer,
|
||||
private string $secret,
|
||||
private string $siteOrigin,
|
||||
@@ -35,7 +37,7 @@ final class FormController extends AbstractController
|
||||
public function submit(Request $request, string $slug): Response
|
||||
{
|
||||
$form = $this->forms->findBySlug($slug);
|
||||
$path = trim($request->request->getString('page'), '/') ?: 'home';
|
||||
$path = trim($request->request->getString('page'), '/') ?: $this->settings->homePath();
|
||||
$page = $this->pages->findPublished($path);
|
||||
if (null === $form || null === $page) {
|
||||
throw $this->createNotFoundException('Formular oder Seite nicht gefunden.');
|
||||
@@ -61,7 +63,7 @@ final class FormController extends AbstractController
|
||||
}
|
||||
|
||||
// Post/Redirect/Get so a reload does not send the message twice.
|
||||
return $this->redirect(('home' === $path ? '/' : '/'.$path).'?gesendet='.rawurlencode($slug).'#'.rawurlencode($request->request->getString('block')), 303);
|
||||
return $this->redirect($this->settings->publicPath($path).'?gesendet='.rawurlencode($slug).'#'.rawurlencode($request->request->getString('block')), 303);
|
||||
}
|
||||
|
||||
/** The signed timestamp makes the age of the form checkable without storing anything. */
|
||||
@@ -133,7 +135,7 @@ final class FormController extends AbstractController
|
||||
|
||||
$response = $this->render('page.html.twig', [
|
||||
'page' => $page,
|
||||
'canonical' => rtrim($this->siteOrigin, '/').('home' === $page['path'] ? '/' : '/'.$page['path']),
|
||||
'canonical' => rtrim($this->siteOrigin, '/').$this->settings->publicPath($page['path']),
|
||||
'navigation' => $navigation,
|
||||
'postLists' => [],
|
||||
'postMeta' => null,
|
||||
|
||||
@@ -27,10 +27,12 @@ final class PageController extends AbstractController
|
||||
|
||||
#[Route('/', name: 'home', methods: ['GET'])]
|
||||
#[Route('/{path}', name: 'page', requirements: ['path' => self::PATH], methods: ['GET'])]
|
||||
public function page(Request $request, string $path = 'home'): Response
|
||||
public function page(Request $request, string $path = ''): Response
|
||||
{
|
||||
// One canonical address for the home page.
|
||||
if ('home' === $path && '/home' === $request->getPathInfo()) {
|
||||
$home = $this->settings->homePath();
|
||||
$path = '' === $path ? $home : $path;
|
||||
// One canonical address for the root page: its own path redirects to "/".
|
||||
if ($path === $home && '/' !== $request->getPathInfo()) {
|
||||
return $this->redirectToRoute('home', status: 301);
|
||||
}
|
||||
$page = $this->pages->findPublished($path);
|
||||
@@ -52,7 +54,7 @@ final class PageController extends AbstractController
|
||||
} else {
|
||||
$response = $this->render('page.html.twig', [
|
||||
'page' => $page,
|
||||
'canonical' => rtrim($this->siteOrigin, '/').('home' === $path ? '/' : '/'.$path),
|
||||
'canonical' => rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $home),
|
||||
'navigation' => $this->navigation($path),
|
||||
'postLists' => $this->postLists($page['document']),
|
||||
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null,
|
||||
@@ -140,7 +142,7 @@ final class PageController extends AbstractController
|
||||
*/
|
||||
private function breadcrumbs(array $page): array
|
||||
{
|
||||
if ('home' === $page['path']) {
|
||||
if ($page['path'] === $this->settings->homePath()) {
|
||||
return [];
|
||||
}
|
||||
$trail = [];
|
||||
@@ -166,7 +168,7 @@ final class PageController extends AbstractController
|
||||
{
|
||||
$origin = rtrim($this->siteOrigin, '/');
|
||||
$blocks = [];
|
||||
if ('home' === $page['path']) {
|
||||
if ($page['path'] === $this->settings->homePath()) {
|
||||
$organisation = $this->settings->organisation($this->settings->load(), $origin, (string) $page['website_name']);
|
||||
if (null !== $organisation) {
|
||||
$blocks[] = $organisation;
|
||||
|
||||
@@ -28,6 +28,7 @@ final class SeoController extends AbstractController
|
||||
public function sitemap(): Response
|
||||
{
|
||||
$origin = rtrim($this->siteOrigin, '/');
|
||||
$home = $this->settings->homePath();
|
||||
$entries = [];
|
||||
// Only published pages that are allowed to be indexed. A draft or a noindex page in the
|
||||
// sitemap would ask a search engine to fetch something it must not list.
|
||||
@@ -36,7 +37,7 @@ final class SeoController extends AbstractController
|
||||
['default'],
|
||||
) as $row) {
|
||||
$entries[] = [
|
||||
'loc' => $origin.('home' === $row['path'] ? '/' : '/'.$row['path']),
|
||||
'loc' => $origin.$this->settings->publicPath($row['path'], $home),
|
||||
'lastmod' => substr((string) ($row['published_at'] ?? $row['created_at']), 0, 10),
|
||||
];
|
||||
}
|
||||
@@ -80,6 +81,7 @@ final class SeoController extends AbstractController
|
||||
throw $this->createNotFoundException('llms.txt ist für diese Website nicht aktiviert.');
|
||||
}
|
||||
$origin = rtrim($this->siteOrigin, '/');
|
||||
$home = $settings['homePath'];
|
||||
$name = (string) $this->db->fetchOne('SELECT name FROM website WHERE id = ?', ['default']);
|
||||
$lines = ['# '.('' !== $settings['legalName'] ? $settings['legalName'] : $name), ''];
|
||||
if ('' !== $settings['description']) {
|
||||
@@ -98,7 +100,7 @@ final class SeoController extends AbstractController
|
||||
foreach ($rows as $row) {
|
||||
$document = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$summary = trim((string) ($row['excerpt'] ?? $document['description'] ?? ''));
|
||||
$lines[] = sprintf('- [%s](%s)%s', $document['title'] ?? $row['path'], $origin.('home' === $row['path'] ? '/' : '/'.$row['path']), '' === $summary ? '' : ': '.$summary);
|
||||
$lines[] = sprintf('- [%s](%s)%s', $document['title'] ?? $row['path'], $origin.$this->settings->publicPath($row['path'], $home), '' === $summary ? '' : ': '.$summary);
|
||||
}
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Domain\InvalidPage;
|
||||
@@ -12,16 +13,26 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class SettingsController extends AbstractController
|
||||
{
|
||||
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects)
|
||||
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/settings', name: 'admin_settings', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$choosable = [];
|
||||
foreach ($this->pages->listForEditor() as $page) {
|
||||
// Only a published page can serve as the root; a draft would answer every visit with 404.
|
||||
if ('page' === $page['type'] && null !== $page['published_revision_id']) {
|
||||
$document = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR);
|
||||
$choosable[] = ['path' => $page['path'], 'title' => $document['title'] ?? $page['path']];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('admin/settings.html.twig', [
|
||||
'settings' => $this->settings->load(),
|
||||
'redirects' => $this->redirects->all(),
|
||||
'choosableHome' => $choosable,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user