Files
KantSites/src/Controller/SeoController.php
T
kantarciandClaude Opus 5 33e4ac0c54 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>
2026-09-12 14:59:55 +02:00

129 lines
5.5 KiB
PHP

<?php
namespace App\Controller;
use App\Content\PostRepository;
use App\Content\SiteSettings;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* The machine readable surface of a site: sitemap, robots and the optional llms.txt. Everything
* here is derived from published content, never maintained separately, so it cannot drift apart
* from what a visitor actually sees.
*/
final class SeoController extends AbstractController
{
public function __construct(
private Connection $db,
private SiteSettings $settings,
private PostRepository $posts,
private string $siteOrigin,
) {
}
#[Route('/sitemap.xml', name: 'sitemap', methods: ['GET'])]
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.
foreach ($this->db->fetchAllAssociative(
"SELECT p.path, p.type, p.published_at, r.created_at FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL ORDER BY p.path",
['default'],
) as $row) {
$entries[] = [
'loc' => $origin.$this->settings->publicPath($row['path'], $home),
'lastmod' => substr((string) ($row['published_at'] ?? $row['created_at']), 0, 10),
];
}
// Archives exist only while they list something.
if ([] !== $this->posts->published(1, null, 1)['posts']) {
$entries[] = ['loc' => $origin.'/beitraege', 'lastmod' => null];
}
foreach ($this->db->fetchAllAssociative(
'SELECT DISTINCT t.kind, t.slug FROM term t JOIN page_term pt ON pt.term_id = t.id JOIN page p ON p.id = pt.page_id AND p.published_revision_id IS NOT NULL AND p.deleted_at IS NULL ORDER BY t.kind, t.slug',
) as $row) {
$entries[] = ['loc' => $origin.'/'.PostRepository::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug'], 'lastmod' => null];
}
return $this->xml($this->renderView('seo/sitemap.xml.twig', ['entries' => $entries]));
}
#[Route('/robots.txt', name: 'robots', methods: ['GET'])]
public function robots(): Response
{
$settings = $this->settings->load();
$lines = ['User-agent: *', 'Disallow: /admin', 'Disallow: /formular/'];
if ('' !== $settings['robotsExtra']) {
$lines[] = $settings['robotsExtra'];
}
$lines[] = '';
$lines[] = 'Sitemap: '.rtrim($this->siteOrigin, '/').'/sitemap.xml';
return $this->text(implode("\n", $lines)."\n");
}
/**
* A machine readable summary of public content. Optional and switched off by default. It is
* no ranking factor and no requirement for any search product; it simply saves an agent the
* crawl. Only published, indexable pages appear.
*/
#[Route('/llms.txt', name: 'llms', methods: ['GET'])]
public function llms(): Response
{
$settings = $this->settings->load();
if (!$settings['llmsTxt']) {
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']) {
$lines[] = '> '.$settings['description'];
$lines[] = '';
}
foreach (['page' => '## Seiten', 'post' => '## Beiträge'] as $type => $heading) {
$rows = $this->db->fetchAllAssociative(
"SELECT p.path, p.excerpt, r.document FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL AND p.type = ? ORDER BY p.path",
['default', $type],
);
if ([] === $rows) {
continue;
}
$lines[] = $heading;
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.$this->settings->publicPath($row['path'], $home), '' === $summary ? '' : ': '.$summary);
}
$lines[] = '';
}
return $this->text(implode("\n", $lines));
}
private function text(string $body): Response
{
return $this->harden(new Response($body, 200, ['Content-Type' => 'text/plain; charset=UTF-8']));
}
private function xml(string $body): Response
{
return $this->harden(new Response($body, 200, ['Content-Type' => 'application/xml; charset=UTF-8']));
}
private function harden(Response $response): Response
{
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Cache-Control', 'no-store');
return $response;
}
}