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
@@ -15,11 +15,13 @@ final class DocumentValidator
|
||||
public const V7_TYPES = ['tabs', 'tabitem'];
|
||||
/** Schema v8: a form block references a form by its address; the definition lives in the database. */
|
||||
public const V8_TYPES = ['form'];
|
||||
/** Schema v9: the breadcrumb trail, built from the page hierarchy rather than from content. */
|
||||
public const V9_TYPES = ['breadcrumb'];
|
||||
|
||||
public function validate(array $document): void
|
||||
{
|
||||
$schemaVersion = $document['schemaVersion'] ?? null;
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8], true), 'Unsupported document schema.');
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9], true), 'Unsupported document schema.');
|
||||
$this->text($document['title'] ?? null, 160);
|
||||
$this->text($document['description'] ?? null, 320);
|
||||
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.');
|
||||
@@ -44,7 +46,7 @@ final class DocumentValidator
|
||||
// page: a deactivated plugin must never make stored content unreadable. Nothing from
|
||||
// props is rendered for such a block, so unvalidated values never reach the output.
|
||||
$plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type);
|
||||
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
|
||||
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
|
||||
// An accordion entry only ever renders inside an accordion; at the top level or anywhere
|
||||
// else it would produce a details element without its group.
|
||||
$this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.');
|
||||
|
||||
@@ -143,7 +143,7 @@ final class PageRepository
|
||||
public function findPublished(string $path, string $locale = 'de'): ?array
|
||||
{
|
||||
$page = $this->db->fetchAssociative(
|
||||
'SELECT p.id, p.slug, p.path, p.type, p.locale, p.author, p.excerpt, p.published_at, r.document, r.id AS revision_id, w.name AS website_name FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id JOIN website w ON w.id = p.website_id WHERE p.path = ? AND p.locale = ? AND p.website_id = ?',
|
||||
'SELECT p.id, p.slug, p.path, p.type, p.locale, p.author, p.excerpt, p.published_at, p.robots, p.og_media_id, r.document, r.id AS revision_id, w.name AS website_name FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id JOIN website w ON w.id = p.website_id WHERE p.path = ? AND p.locale = ? AND p.website_id = ?',
|
||||
[$path, $locale, 'default'],
|
||||
);
|
||||
if (false === $page) {
|
||||
@@ -157,7 +157,7 @@ final class PageRepository
|
||||
|
||||
public function listForEditor(): array
|
||||
{
|
||||
return $this->db->fetchAllAssociative('SELECT p.id, p.slug, p.path, p.parent_id, p.type, p.locale, p.version, p.published_revision_id, p.draft_revision_id, r.document FROM page p LEFT JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? ORDER BY p.path', ['default']);
|
||||
return $this->db->fetchAllAssociative('SELECT p.id, p.slug, p.path, p.parent_id, p.type, p.locale, p.version, p.published_revision_id, p.draft_revision_id, p.excerpt, p.author, p.published_at, p.featured_media_id, p.robots, p.og_media_id, r.document FROM page p LEFT JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? ORDER BY p.path', ['default']);
|
||||
}
|
||||
|
||||
public function findForEditor(string $id): ?array
|
||||
|
||||
@@ -40,18 +40,27 @@ final class PostRepository
|
||||
throw new InvalidPage('Das Datum braucht die Form JJJJ-MM-TT, wahlweise mit Uhrzeit.');
|
||||
}
|
||||
$mediaId = trim((string) ($values['featuredMediaId'] ?? ''));
|
||||
if ('' !== $mediaId && (1 !== preg_match('/^[a-f0-9]{32}$/D', $mediaId) || null === $this->media->find($mediaId))) {
|
||||
throw new InvalidPage('Das gewählte Beitragsbild gibt es nicht.');
|
||||
$ogMediaId = trim((string) ($values['ogMediaId'] ?? ''));
|
||||
foreach (['Beitragsbild' => $mediaId, 'Vorschaubild' => $ogMediaId] as $label => $candidate) {
|
||||
if ('' !== $candidate && (1 !== preg_match('/^[a-f0-9]{32}$/D', $candidate) || null === $this->media->find($candidate))) {
|
||||
throw new InvalidPage(sprintf('Das gewählte %s gibt es nicht.', $label));
|
||||
}
|
||||
}
|
||||
$robots = (string) ($values['robots'] ?? 'index');
|
||||
if (!in_array($robots, ['index', 'noindex'], true)) {
|
||||
throw new InvalidPage('Für die Indexierung sind nur „index“ und „noindex“ vorgesehen.');
|
||||
}
|
||||
|
||||
$this->db->transactional(function () use ($pageId, $excerpt, $author, $date, $mediaId, $categories, $tags, $expectedVersion): void {
|
||||
$this->db->transactional(function () use ($pageId, $excerpt, $author, $date, $mediaId, $ogMediaId, $robots, $categories, $tags, $expectedVersion): void {
|
||||
$changed = $this->db->executeStatement(
|
||||
'UPDATE page SET excerpt = ?, author = ?, featured_media_id = ?, published_at = ?, version = version + 1 WHERE id = ? AND version = ?',
|
||||
'UPDATE page SET excerpt = ?, author = ?, featured_media_id = ?, published_at = ?, robots = ?, og_media_id = ?, version = version + 1 WHERE id = ? AND version = ?',
|
||||
[
|
||||
'' === $excerpt ? null : $excerpt,
|
||||
'' === $author ? null : $author,
|
||||
'' === $mediaId ? null : $mediaId,
|
||||
'' === $date ? null : str_replace('T', ' ', 10 === strlen($date) ? $date.' 00:00' : $date).':00',
|
||||
$robots,
|
||||
'' === $ogMediaId ? null : $ogMediaId,
|
||||
$pageId,
|
||||
$expectedVersion,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\InvalidPage;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Addresses that moved or are deliberately gone. Checked before a page is answered with 404, so a
|
||||
* migrated site keeps every old link working.
|
||||
*/
|
||||
final class RedirectRepository
|
||||
{
|
||||
public const MAX = 2000;
|
||||
|
||||
public function __construct(private Connection $db)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return list<array{id: string, from: string, to: ?string, status: int}> */
|
||||
public function all(): array
|
||||
{
|
||||
return array_map(static fn (array $row): array => [
|
||||
'id' => $row['id'], 'from' => $row['from_path'], 'to' => $row['to_path'], 'status' => (int) $row['status'],
|
||||
], $this->db->fetchAllAssociative('SELECT * FROM redirect WHERE website_id = ? ORDER BY from_path', ['default']));
|
||||
}
|
||||
|
||||
/** @return array{to: ?string, status: int}|null */
|
||||
public function find(string $path): ?array
|
||||
{
|
||||
$row = $this->db->fetchAssociative('SELECT to_path, status FROM redirect WHERE website_id = ? AND from_path = ?', ['default', trim($path, '/')]);
|
||||
|
||||
return false === $row ? null : ['to' => $row['to_path'], 'status' => (int) $row['status']];
|
||||
}
|
||||
|
||||
public function save(string $from, string $to, int $status): void
|
||||
{
|
||||
$from = trim(trim($from), '/');
|
||||
$to = trim(trim($to), '/');
|
||||
if (!in_array($status, [301, 410], true)) {
|
||||
throw new InvalidPage('Erlaubt sind 301 für eine verschobene Adresse und 410 für eine entfernte.');
|
||||
}
|
||||
if (1 !== preg_match('#^[a-z0-9]+(?:[-/][a-z0-9]+)*$#D', $from) || strlen($from) > 400) {
|
||||
throw new InvalidPage('Die alte Adresse darf nur Kleinbuchstaben, Ziffern, Bindestriche und Schrägstriche enthalten.');
|
||||
}
|
||||
if (301 === $status) {
|
||||
if (1 !== preg_match('#^[a-z0-9]+(?:[-/][a-z0-9]+)*$#D', $to) || strlen($to) > 400) {
|
||||
throw new InvalidPage('Das Ziel darf nur Kleinbuchstaben, Ziffern, Bindestriche und Schrägstriche enthalten.');
|
||||
}
|
||||
if ($from === $to) {
|
||||
throw new InvalidPage('Eine Adresse kann nicht auf sich selbst weiterleiten.');
|
||||
}
|
||||
// One hop only: a chain would cost an extra round trip and can become a loop.
|
||||
$next = $this->find($to);
|
||||
if (null !== $next) {
|
||||
throw new InvalidPage(sprintf('Das Ziel /%s leitet selbst weiter. Zeige direkt auf das Endziel.', $to));
|
||||
}
|
||||
}
|
||||
if (self::MAX <= (int) $this->db->fetchOne('SELECT COUNT(*) FROM redirect WHERE website_id = ?', ['default'])
|
||||
&& null === $this->find($from)) {
|
||||
throw new InvalidPage(sprintf('Mehr als %d Weiterleitungen sind nicht vorgesehen.', self::MAX));
|
||||
}
|
||||
|
||||
$this->db->transactional(function () use ($from, $to, $status): void {
|
||||
$this->db->executeStatement('DELETE FROM redirect WHERE website_id = ? AND from_path = ?', ['default', $from]);
|
||||
$this->db->insert('redirect', [
|
||||
'id' => bin2hex(random_bytes(16)), 'website_id' => 'default',
|
||||
'from_path' => $from, 'to_path' => 301 === $status ? $to : null,
|
||||
'status' => $status, 'created_at' => gmdate('Y-m-d H:i:s'),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function delete(string $id): void
|
||||
{
|
||||
$this->db->executeStatement('DELETE FROM redirect WHERE id = ? AND website_id = ?', [$id, 'default']);
|
||||
}
|
||||
|
||||
/** Records that an address moved, used when a page is renamed. Never overwrites an entry. */
|
||||
public function remember(string $from, string $to): void
|
||||
{
|
||||
if ($from === $to || null !== $this->find($from)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$this->save($from, $to, 301);
|
||||
} catch (InvalidPage) {
|
||||
// A path the rules reject is simply not remembered; renaming must not fail for it.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\InvalidPage;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Company data and site wide switches. The structured data the pages emit is built from exactly
|
||||
* these values, so what a search engine reads is what an editor maintains here – never a separate,
|
||||
* invisible copy.
|
||||
*/
|
||||
final class SiteSettings
|
||||
{
|
||||
public const DEFAULTS = [
|
||||
'legalName' => '',
|
||||
'description' => '',
|
||||
'phone' => '',
|
||||
'email' => '',
|
||||
'street' => '',
|
||||
'postalCode' => '',
|
||||
'city' => '',
|
||||
'country' => 'DE',
|
||||
'sameAs' => [],
|
||||
'localBusiness' => false,
|
||||
'llmsTxt' => false,
|
||||
'robotsExtra' => '',
|
||||
];
|
||||
|
||||
public function __construct(private Connection $db)
|
||||
{
|
||||
}
|
||||
|
||||
public function load(): array
|
||||
{
|
||||
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']);
|
||||
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
|
||||
|
||||
return [...self::DEFAULTS, ...(is_array($stored) ? $stored : [])];
|
||||
}
|
||||
|
||||
public function save(array $input): void
|
||||
{
|
||||
$values = self::DEFAULTS;
|
||||
foreach (['legalName' => 200, 'description' => 300, 'phone' => 40, 'email' => 120, 'street' => 120, 'postalCode' => 16, 'city' => 120] as $key => $max) {
|
||||
$values[$key] = mb_substr(trim((string) ($input[$key] ?? '')), 0, $max);
|
||||
}
|
||||
if ('' !== $values['email'] && false === filter_var($values['email'], FILTER_VALIDATE_EMAIL)) {
|
||||
throw new InvalidPage('Bitte gib eine gültige E-Mail-Adresse an.');
|
||||
}
|
||||
if ('' !== $values['phone'] && 1 !== preg_match('/^\+?[0-9 ()\/-]{3,32}$/D', $values['phone'])) {
|
||||
throw new InvalidPage('Bitte gib eine gültige Telefonnummer an.');
|
||||
}
|
||||
$country = strtoupper(trim((string) ($input['country'] ?? 'DE')));
|
||||
if (1 !== preg_match('/^[A-Z]{2}$/D', $country)) {
|
||||
throw new InvalidPage('Das Land wird als zweibuchstabiger Code angegeben, zum Beispiel DE.');
|
||||
}
|
||||
$values['country'] = $country;
|
||||
foreach (preg_split('/[\r\n,]+/', (string) ($input['sameAs'] ?? '')) ?: [] as $profile) {
|
||||
$profile = trim($profile);
|
||||
if ('' === $profile) {
|
||||
continue;
|
||||
}
|
||||
if (!str_starts_with($profile, 'https://') || false === filter_var($profile, FILTER_VALIDATE_URL)) {
|
||||
throw new InvalidPage(sprintf('„%s“ ist keine HTTPS-Adresse.', $profile));
|
||||
}
|
||||
$values['sameAs'][] = $profile;
|
||||
}
|
||||
$values['sameAs'] = array_slice(array_unique($values['sameAs']), 0, 20);
|
||||
$values['localBusiness'] = (bool) ($input['localBusiness'] ?? false);
|
||||
if ($values['localBusiness'] && ('' === $values['street'] || '' === $values['city'])) {
|
||||
throw new InvalidPage('Ein lokales Unternehmen braucht mindestens Straße und Ort.');
|
||||
}
|
||||
$values['llmsTxt'] = (bool) ($input['llmsTxt'] ?? false);
|
||||
// Only comment and simple directive lines; nothing that could smuggle in a second policy.
|
||||
$extra = [];
|
||||
foreach (preg_split('/\R/', (string) ($input['robotsExtra'] ?? '')) ?: [] as $line) {
|
||||
$line = trim($line);
|
||||
if ('' !== $line && 1 === preg_match('/^(#.{0,180}|(?:Disallow|Allow|Crawl-delay|User-agent):[ \t][^\s][^\r\n]{0,180})$/D', $line)) {
|
||||
$extra[] = $line;
|
||||
}
|
||||
}
|
||||
$values['robotsExtra'] = implode("\n", array_slice($extra, 0, 30));
|
||||
|
||||
$this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
|
||||
json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||
'default',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
public function organisation(array $settings, string $origin, string $siteName): ?array
|
||||
{
|
||||
$hasAddress = '' !== $settings['street'] && '' !== $settings['city'];
|
||||
if ('' === $settings['legalName'] && !$hasAddress && '' === $settings['phone']) {
|
||||
return null;
|
||||
}
|
||||
$data = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => $settings['localBusiness'] && $hasAddress ? 'LocalBusiness' : 'Organization',
|
||||
'name' => '' !== $settings['legalName'] ? $settings['legalName'] : $siteName,
|
||||
'url' => $origin.'/',
|
||||
];
|
||||
foreach (['description' => 'description', 'phone' => 'telephone', 'email' => 'email'] as $key => $property) {
|
||||
if ('' !== $settings[$key]) {
|
||||
$data[$property] = $settings[$key];
|
||||
}
|
||||
}
|
||||
if ($hasAddress) {
|
||||
$data['address'] = array_filter([
|
||||
'@type' => 'PostalAddress',
|
||||
'streetAddress' => $settings['street'],
|
||||
'postalCode' => $settings['postalCode'],
|
||||
'addressLocality' => $settings['city'],
|
||||
'addressCountry' => $settings['country'],
|
||||
], static fn (string $value): bool => '' !== $value);
|
||||
}
|
||||
if ([] !== $settings['sameAs']) {
|
||||
$data['sameAs'] = $settings['sameAs'];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -31,9 +31,7 @@ final class EditorController extends AbstractController
|
||||
foreach ($pages as &$page) {
|
||||
$page['title'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR)['title'] ?? $page['slug'];
|
||||
$page['depth'] = substr_count((string) $page['path'], '/');
|
||||
if ('post' === $page['type']) {
|
||||
$page['terms'] = $this->posts->namesFor($page['id']);
|
||||
}
|
||||
$page['terms'] = 'post' === $page['type'] ? $this->posts->namesFor($page['id']) : ['category' => '', 'tag' => ''];
|
||||
}
|
||||
unset($page);
|
||||
|
||||
@@ -88,6 +86,8 @@ final class EditorController extends AbstractController
|
||||
'author' => $request->request->getString('author'),
|
||||
'publishedAt' => $request->request->getString('publishedAt'),
|
||||
'featuredMediaId' => $request->request->getString('featuredMediaId'),
|
||||
'ogMediaId' => $request->request->getString('ogMediaId'),
|
||||
'robots' => $request->request->getString('robots', 'index'),
|
||||
],
|
||||
$terms($request->request->getString('categories')),
|
||||
$terms($request->request->getString('tags')),
|
||||
|
||||
@@ -6,6 +6,8 @@ use App\Content\FormRepository;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PostRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -13,13 +15,13 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class PageController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private string $secret, private string $siteOrigin)
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private string $secret, private string $siteOrigin)
|
||||
{
|
||||
}
|
||||
|
||||
// A segment is a slug; up to four of them form the public path. The leading lookahead keeps
|
||||
// the CMS routes (/admin, /media, /assets) out of the catch-all, which matches before them.
|
||||
private const PATH = '(?!admin(?:/|$)|media/|assets/|kategorie/|schlagwort/|beitraege(?:/|$)|feed\.xml$)[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){0,3}';
|
||||
private const PATH = '(?!admin(?:/|$)|media/|assets/|kategorie/|schlagwort/|beitraege(?:/|$)|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$)[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){0,3}';
|
||||
|
||||
#[Route('/', name: 'home', methods: ['GET'])]
|
||||
#[Route('/{path}', name: 'page', requirements: ['path' => self::PATH], methods: ['GET'])]
|
||||
@@ -31,7 +33,12 @@ final class PageController extends AbstractController
|
||||
}
|
||||
$page = $this->pages->findPublished($path);
|
||||
if (null === $page) {
|
||||
$response = $this->render('not-found.html.twig', [], new Response(status: 404));
|
||||
// An address that moved or was deliberately removed answers before the 404 does.
|
||||
$redirect = $this->redirects->find($path);
|
||||
if (null !== $redirect && 301 === $redirect['status']) {
|
||||
return $this->shell($this->redirect('/'.$redirect['to'], 301));
|
||||
}
|
||||
$response = $this->render('not-found.html.twig', [], new Response(status: null !== $redirect ? 410 : 404));
|
||||
} else {
|
||||
$response = $this->render('page.html.twig', [
|
||||
'page' => $page,
|
||||
@@ -41,6 +48,8 @@ final class PageController extends AbstractController
|
||||
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null,
|
||||
'needsTabs' => $this->hasType($page['document'], 'tabs'),
|
||||
'forms' => $this->formStates($page, $request->query->getString('gesendet')),
|
||||
'breadcrumbs' => $this->breadcrumbs($page),
|
||||
'structuredData' => $this->structuredData($page),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -96,6 +105,79 @@ final class PageController extends AbstractController
|
||||
return $states;
|
||||
}
|
||||
|
||||
/**
|
||||
* The trail from the home page down to this one, resolved from the stored hierarchy rather
|
||||
* than from content, so it cannot claim a page that does not exist.
|
||||
*
|
||||
* @return list<array{name: string, href: string}>
|
||||
*/
|
||||
private function breadcrumbs(array $page): array
|
||||
{
|
||||
if ('home' === $page['path']) {
|
||||
return [];
|
||||
}
|
||||
$trail = [];
|
||||
$prefix = '';
|
||||
foreach (explode('/', (string) $page['path']) as $segment) {
|
||||
$prefix = '' === $prefix ? $segment : $prefix.'/'.$segment;
|
||||
$ancestor = $this->pages->findPublished($prefix);
|
||||
if (null !== $ancestor) {
|
||||
$trail[] = ['name' => $ancestor['document']['title'] ?? $prefix, 'href' => '/'.$prefix];
|
||||
}
|
||||
}
|
||||
|
||||
return $trail;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-LD built from values that are visible on the page or maintained in the settings.
|
||||
* Nothing is invented here; a field without a maintained value is left out.
|
||||
*
|
||||
* @return list<array>
|
||||
*/
|
||||
private function structuredData(array $page): array
|
||||
{
|
||||
$origin = rtrim($this->siteOrigin, '/');
|
||||
$blocks = [];
|
||||
if ('home' === $page['path']) {
|
||||
$organisation = $this->settings->organisation($this->settings->load(), $origin, (string) $page['website_name']);
|
||||
if (null !== $organisation) {
|
||||
$blocks[] = $organisation;
|
||||
}
|
||||
}
|
||||
if ('post' === $page['type']) {
|
||||
$article = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'Article',
|
||||
'headline' => mb_substr((string) ($page['document']['title'] ?? ''), 0, 110),
|
||||
'mainEntityOfPage' => $origin.'/'.$page['path'],
|
||||
];
|
||||
foreach ([['description', $page['excerpt'] ?? $page['document']['description'] ?? ''], ['datePublished', $page['published_at'] ?? '']] as [$key, $value]) {
|
||||
if ('' !== (string) $value && null !== $value) {
|
||||
$article[$key] = 'datePublished' === $key ? str_replace(' ', 'T', (string) $value).'Z' : (string) $value;
|
||||
}
|
||||
}
|
||||
if (null !== ($page['author'] ?? null) && '' !== $page['author']) {
|
||||
$article['author'] = ['@type' => 'Person', 'name' => $page['author']];
|
||||
}
|
||||
$blocks[] = $article;
|
||||
}
|
||||
$trail = $this->breadcrumbs($page);
|
||||
if (count($trail) > 1) {
|
||||
$blocks[] = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'BreadcrumbList',
|
||||
'itemListElement' => array_map(
|
||||
static fn (int $index, array $item): array => ['@type' => 'ListItem', 'position' => $index + 1, 'name' => $item['name'], 'item' => $origin.$item['href']],
|
||||
array_keys($trail),
|
||||
$trail,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return $blocks;
|
||||
}
|
||||
|
||||
/** True when the stored document contains at least one block of that type. */
|
||||
private function hasType(array $document, string $type): bool
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Domain\InvalidPage;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class SettingsController extends AbstractController
|
||||
{
|
||||
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/settings', name: 'admin_settings', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
return $this->render('admin/settings.html.twig', [
|
||||
'settings' => $this->settings->load(),
|
||||
'redirects' => $this->redirects->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/settings/company', name: 'admin_settings_save', methods: ['POST'])]
|
||||
public function save(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
try {
|
||||
$this->settings->save($request->request->all());
|
||||
} catch (InvalidPage $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->back('Die Unternehmensdaten wurden gespeichert.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/admin/settings/redirects', name: 'admin_redirect_save', methods: ['POST'])]
|
||||
public function saveRedirect(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
$remove = $request->request->getString('remove');
|
||||
if ('' !== $remove) {
|
||||
$this->redirects->delete($remove);
|
||||
|
||||
return $this->back('Die Weiterleitung wurde entfernt.', 'success');
|
||||
}
|
||||
try {
|
||||
$this->redirects->save(
|
||||
$request->request->getString('from'),
|
||||
$request->request->getString('to'),
|
||||
$request->request->getInt('status', 301),
|
||||
);
|
||||
} catch (InvalidPage $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->back('Die Weiterleitung wurde gespeichert.', 'success');
|
||||
}
|
||||
|
||||
private function back(string $message, string $type = 'error'): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
|
||||
return $this->redirectToRoute('admin_settings');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user