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>
155 lines
6.5 KiB
PHP
155 lines
6.5 KiB
PHP
<?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' => '',
|
||
// Which stored page answers at the site root. Everything else derives from this.
|
||
'homePath' => 'home',
|
||
];
|
||
|
||
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));
|
||
|
||
$home = trim(trim((string) ($input['homePath'] ?? self::DEFAULTS['homePath'])), '/');
|
||
if (1 !== preg_match('#^[a-z0-9]+(?:[-/][a-z0-9]+)*$#D', $home)) {
|
||
throw new InvalidPage('Die Startseite braucht eine gültige Adresse.');
|
||
}
|
||
// A root that points nowhere would answer every visit with 404, so it must exist.
|
||
if (false === $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND path = ? AND deleted_at IS NULL', ['default', $home])) {
|
||
throw new InvalidPage(sprintf('Es gibt keine Seite mit der Adresse /%s.', $home));
|
||
}
|
||
$values['homePath'] = $home;
|
||
|
||
$this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
|
||
json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||
'default',
|
||
]);
|
||
}
|
||
|
||
/** The stored path of the page that answers at the site root. */
|
||
public function homePath(): string
|
||
{
|
||
return $this->load()['homePath'];
|
||
}
|
||
|
||
/**
|
||
* Turns a stored page path into its public address. The root page answers at "/" and must not
|
||
* also be reachable under its own path, or the same content would live at two addresses.
|
||
*/
|
||
public function publicPath(string $storedPath, ?string $homePath = null): string
|
||
{
|
||
return $storedPath === ($homePath ?? $this->homePath()) ? '/' : '/'.$storedPath;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
}
|