Add business type, location and logo to the structured data

Three optional settings extend LocalBusiness: a subtype from a fixed list, coordinates as a pair, and a logo from the media library. Without them the output stays as it was, and stored settings need no migration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5.5 committed 2026-11-09 08:02:38 +01:00
1 parent d75c295f59
commit f2c04b311f
7 files changed
+188 -2

No files matched your search

+63 -1
View File
@@ -14,6 +14,26 @@ use Doctrine\DBAL\Exception\TableNotFoundException;
*/
final class SiteSettings
{
/** Die Unterarten von schema.org/LocalBusiness, die sich in den Einstellungen wählen lassen. */
public const BUSINESS_TYPES = [
'LocalBusiness' => 'Allgemein (LocalBusiness)',
'RealEstateAgent' => 'Immobilien (RealEstateAgent)',
'HomeAndConstructionBusiness' => 'Handwerk und Bau allgemein',
'Electrician' => 'Elektriker',
'Plumber' => 'Sanitär',
'HVACBusiness' => 'Heizung und Klima',
'HousePainter' => 'Maler',
'RoofingContractor' => 'Dachdecker',
'GeneralContractor' => 'Bauunternehmen',
'AccountingService' => 'Steuern und Buchhaltung',
'FinancialService' => 'Finanzdienstleistung',
'LegalService' => 'Rechtsberatung',
'MedicalBusiness' => 'Medizin und Pflege',
'TravelAgency' => 'Reisebüro',
'Restaurant' => 'Restaurant',
'Store' => 'Geschäft',
];
public const DEFAULTS = [
'legalName' => '',
'description' => '',
@@ -32,6 +52,12 @@ final class SiteSettings
'openingHours' => [],
'areaServed' => [],
'localBusiness' => false,
// Genauer als „lokales Unternehmen“, dazu der Standort und ein Logo mit eigener Adresse:
// alles nur in den strukturierten Daten, leer bleibt es dort weg (ADR 126).
'businessType' => 'LocalBusiness',
'latitude' => '',
'longitude' => '',
'organisationLogoMediaId' => '',
'llmsTxt' => false,
// Der Kopfbereich bleibt beim Scrollen oben stehen. Eine Eigenschaft der Website und
// nicht des Dokuments: kleben kann nur das Element, das den Bereich umschliesst.
@@ -240,6 +266,19 @@ final class SiteSettings
if ($values['localBusiness'] && ('' === $values['street'] || '' === $values['city'])) {
throw new InvalidPage('Ein lokales Unternehmen braucht mindestens Straße und Ort.');
}
$values['businessType'] = (string) ($input['businessType'] ?? 'LocalBusiness');
if (!isset(self::BUSINESS_TYPES[$values['businessType']])) {
throw new InvalidPage('Diese Art von Unternehmen steht nicht zur Auswahl.');
}
$values['latitude'] = $this->coordinate($input['latitude'] ?? '', 90, 'Breitengrad');
$values['longitude'] = $this->coordinate($input['longitude'] ?? '', 180, 'Längengrad');
if (('' === $values['latitude']) !== ('' === $values['longitude'])) {
throw new InvalidPage('Für den Standort braucht es Breiten- und Längengrad, oder keinen von beiden.');
}
$values['organisationLogoMediaId'] = trim((string) ($input['organisationLogoMediaId'] ?? ''));
if ('' !== $values['organisationLogoMediaId'] && (1 !== preg_match('/^[a-f0-9]{32}$/D', $values['organisationLogoMediaId']) || null === $this->media->find($values['organisationLogoMediaId']))) {
throw new InvalidPage('Das gewählte Logo gibt es nicht (mehr).');
}
$values['llmsTxt'] = (bool) ($input['llmsTxt'] ?? false);
$values['stickyHeader'] = (bool) ($input['stickyHeader'] ?? false);
$values['headerOverlay'] = (bool) ($input['headerOverlay'] ?? false);
@@ -512,6 +551,20 @@ final class SiteSettings
return $storedPath === ($homePath ?? $this->homePath()) ? '/' : '/'.$storedPath;
}
/** Eine Koordinate als Dezimalzahl; das Komma der deutschen Schreibweise wird zum Punkt. */
private function coordinate(mixed $input, int $limit, string $label): string
{
$value = str_replace(',', '.', trim((string) $input));
if ('' === $value) {
return '';
}
if (1 !== preg_match('/^-?\d{1,3}(?:\.\d{1,7})?$/D', $value) || abs((float) $value) > $limit) {
throw new InvalidPage(sprintf('Der %s ist keine gültige Koordinate, zum Beispiel 48.79381.', $label));
}
return $value;
}
/**
* 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.
@@ -522,9 +575,10 @@ final class SiteSettings
if ('' === $settings['legalName'] && !$hasAddress && '' === $settings['phone']) {
return null;
}
$lokal = $settings['localBusiness'] && $hasAddress;
$data = [
'@context' => 'https://schema.org',
'@type' => $settings['localBusiness'] && $hasAddress ? 'LocalBusiness' : 'Organization',
'@type' => $lokal ? ($settings['businessType'] ?? 'LocalBusiness') : 'Organization',
'name' => '' !== $settings['legalName'] ? $settings['legalName'] : $siteName,
'url' => $origin.'/',
];
@@ -542,6 +596,14 @@ final class SiteSettings
'addressCountry' => $settings['country'],
], static fn (string $value): bool => '' !== $value);
}
if ($lokal && '' !== ($settings['latitude'] ?? '') && '' !== ($settings['longitude'] ?? '')) {
$data['geo'] = ['@type' => 'GeoCoordinates', 'latitude' => (float) $settings['latitude'], 'longitude' => (float) $settings['longitude']];
}
// Eine gelöschte Datei darf keine tote Adresse in die Daten bringen.
$logo = '' !== ($settings['organisationLogoMediaId'] ?? '') ? $this->media->find($settings['organisationLogoMediaId']) : null;
if (null !== $logo && [] !== $logo['variants']) {
$data['logo'] = $origin.end($logo['variants'])['src'];
}
// Die sozialen Profile sind dieselbe Aussage wie sameAs - "dieses Unternehmen ist auch
// das hier" -, deshalb stehen sie ohne zweite Pflege mit in der Liste.
$sameAs = array_values(array_unique([...$settings['sameAs'], ...array_values($settings['social'] ?? [])]));