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,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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user