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:
kantarciandClaude Opus 5 committed 2026-09-12 12:43:00 +02:00
1 parent d43bd91069
commit a40c2bb27e
30 files changed
+1012 -38

No files matched your search

+127
View File
@@ -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;
}
}