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:
kantarciandClaude Opus 5 committed 2026-09-12 14:59:55 +02:00
1 parent 6afad30956
commit 33e4ac0c54
21 files changed
+266 -49

No files matched your search

+5 -3
View File
@@ -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,
+8 -6
View File
@@ -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;
+4 -2
View File
@@ -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[] = '';
}
+12 -1
View File
@@ -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,
]);
}