SEO, Weiterleitungen und strukturierte Daten (Arbeitspaket E)
Migration 20260912000800 ergänzt page.robots, page.og_media_id, website.settings und die Tabelle redirect. Alles Maschinenlesbare wird aus dem veröffentlichten Bestand abgeleitet, nichts separat gepflegt: die Sitemap listet nur veröffentlichte, indexierbare Seiten samt Beitragsübersicht und Begriffsarchiven, und dieselbe Einstellung setzt auch das noindex-Meta - beide Aussagen können nicht auseinanderlaufen. Strukturierte Daten entstehen nur aus gepflegten Werten: ohne Angaben wird kein leerer Organization-Block ausgegeben. JSON-LD ist ein Datenblock, kein Skript, daher bleibt script-src 'none' unberührt. Weiterleitungen greifen vor dem 404, aber nach der Seitensuche: eine vorhandene Seite gewinnt immer, sonst könnte ein alter Eintrag eine neue Seite unerreichbar machen. Ketten werden abgewiesen. robots.txt nimmt zusätzliche Zeilen nur nach festem Muster an, damit ein Eintrag die Datei nicht umdeuten kann. llms.txt ist standardmäßig aus. Geprüft: 128 PHP-Tests / 507 Assertions unter PHP 8.2.12 und 8.4.22, 14 JavaScript-Modelltests, Lint/Format/Container. Dazu eine reine Sichtprüfung im Browser auf einer lokalen Demoseite - weiterhin keine axe- oder Lighthouse-Messung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d43bd91069
commit
a40c2bb27e
30 files changed
+1012
-38
No files matched your search
@@ -0,0 +1,126 @@
|
||||
<?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, '/');
|
||||
$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' ORDER BY p.path",
|
||||
['default'],
|
||||
) as $row) {
|
||||
$entries[] = [
|
||||
'loc' => $origin.('home' === $row['path'] ? '/' : '/'.$row['path']),
|
||||
'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 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, '/');
|
||||
$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.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.('home' === $row['path'] ? '/' : '/'.$row['path']), '' === $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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user