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

+8
View File
@@ -2,6 +2,14 @@
Stand: 27.10.2026. Arbeitstitel KantSites; Produktname offen. Stand: 27.10.2026. Arbeitstitel KantSites; Produktname offen.
## ADR 126 – Unternehmensart, Standort und Logo in den strukturierten Daten (09.11.2026)
**Kontext:** Bei Luximmo fiel auf, dass LocalBusiness immer der allgemeine Typ war und weder Logo noch Koordinaten kannte.
**Entscheidung:** Drei Einstellungen unter „Einstellungen“: `businessType` aus einer festen Liste von LocalBusiness-Unterarten, `latitude`/`longitude` als Paar, `organisationLogoMediaId` aus der Mediathek. Das Logo ist bewusst getrennt vom sichtbaren Logo im Kopfbereich: Das kann eingebettetes SVG sein und hat dann keine eigene Adresse. Die Liste ist fest, damit kein Tippfehler einen ungültigen Typ erzeugt.
**Folgen:** Keine Migration: Die Werte liegen im JSON der Einstellungen, fehlende Schlüssel fallen auf die Standardwerte, ausgegeben wird dann genau das Bisherige. Typ und Koordinaten gelten nur für ein lokales Unternehmen mit Anschrift, das Logo auch für eine Organisation. Eine Installation älter als diese Fassung ignoriert die Werte aus einem Inhaltspaket und gibt sie nicht aus.
## ADR 125 – Videos: eigene Bibliothek, Baustein und Sektionshintergrund (Dokumentschema v28, 04.11.2026) ## ADR 125 – Videos: eigene Bibliothek, Baustein und Sektionshintergrund (Dokumentschema v28, 04.11.2026)
**Kontext:** Der Hero von kant-it.de soll ein Video im Hintergrund bekommen. Die Mediathek nahm nur Bilder und PDFs, die Alpaka-Videos von Hüfner und die Drews-Videos blieben deshalb draußen. Ein `<video>` in „Eigenes HTML“ ginge, verlangt aber Dateien von Hand auf dem Server und kann weder Bewegung reduzieren noch ein Anhalten anbieten. **Kontext:** Der Hero von kant-it.de soll ein Video im Hintergrund bekommen. Die Mediathek nahm nur Bilder und PDFs, die Alpaka-Videos von Hüfner und die Drews-Videos blieben deshalb draußen. Ein `<video>` in „Eigenes HTML“ ginge, verlangt aber Dateien von Hand auf dem Server und kann weder Bewegung reduzieren noch ein Anhalten anbieten.
+1
View File
@@ -843,6 +843,7 @@ details.card > summary:hover { background: #fafbf8 }
.favicon-current{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--muted)} .favicon-current{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--muted)}
.favicon-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px} .favicon-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
.favicon-preview{border-radius:6px;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.12)} .favicon-preview{border-radius:6px;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.12)}
.logo-preview{object-fit:contain}
.favicon-editor{display:grid;grid-template-columns:minmax(0,1fr) 220px;gap:20px;align-items:start;margin-top:14px} .favicon-editor{display:grid;grid-template-columns:minmax(0,1fr) 220px;gap:20px;align-items:start;margin-top:14px}
.favicon-editor[hidden]{display:none} .favicon-editor[hidden]{display:none}
.favicon-stage{position:relative;justify-self:start;max-width:100%;line-height:0;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/16px 16px;user-select:none;touch-action:none} .favicon-stage{position:relative;justify-self:start;max-width:100%;line-height:0;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/16px 16px;user-select:none;touch-action:none}
+41
View File
@@ -0,0 +1,41 @@
import { openMedia } from './media.js';
/*
* The logo for the structured data: one image from the media library, shown as a small preview.
* It is saved with the form, like every other value on this screen.
*/
const field = document.querySelector('[data-logo-field]');
if (field) {
const input = field.querySelector('[name="organisationLogoMediaId"]');
const current = field.querySelector('.favicon-current');
const choose = field.querySelector('[data-logo-choose]');
const remove = field.querySelector('[data-logo-remove]');
const show = (label, src = '') => {
current.replaceChildren();
if (src) {
const image = new Image(64, 64);
image.className = 'favicon-preview logo-preview';
image.alt = '';
image.src = src;
current.append(image);
}
const text = document.createElement('span');
text.textContent = label;
current.append(text);
choose.textContent = input.value ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen';
remove.hidden = !input.value;
};
choose.addEventListener('click', () => openMedia({ needsAlt: false, onChoose: items => {
if (!items.length) return;
input.value = items[0].id;
show(`„${items[0].title}“ – wird beim Speichern übernommen.`, items[0].variants[0].src);
choose.focus();
} }));
remove.addEventListener('click', () => {
input.value = '';
show('Kein Logo gewählt – wird beim Speichern übernommen.');
choose.focus();
});
}
+63 -1
View File
@@ -14,6 +14,26 @@ use Doctrine\DBAL\Exception\TableNotFoundException;
*/ */
final class SiteSettings 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 = [ public const DEFAULTS = [
'legalName' => '', 'legalName' => '',
'description' => '', 'description' => '',
@@ -32,6 +52,12 @@ final class SiteSettings
'openingHours' => [], 'openingHours' => [],
'areaServed' => [], 'areaServed' => [],
'localBusiness' => false, '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, 'llmsTxt' => false,
// Der Kopfbereich bleibt beim Scrollen oben stehen. Eine Eigenschaft der Website und // Der Kopfbereich bleibt beim Scrollen oben stehen. Eine Eigenschaft der Website und
// nicht des Dokuments: kleben kann nur das Element, das den Bereich umschliesst. // 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'])) { if ($values['localBusiness'] && ('' === $values['street'] || '' === $values['city'])) {
throw new InvalidPage('Ein lokales Unternehmen braucht mindestens Straße und Ort.'); 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['llmsTxt'] = (bool) ($input['llmsTxt'] ?? false);
$values['stickyHeader'] = (bool) ($input['stickyHeader'] ?? false); $values['stickyHeader'] = (bool) ($input['stickyHeader'] ?? false);
$values['headerOverlay'] = (bool) ($input['headerOverlay'] ?? false); $values['headerOverlay'] = (bool) ($input['headerOverlay'] ?? false);
@@ -512,6 +551,20 @@ final class SiteSettings
return $storedPath === ($homePath ?? $this->homePath()) ? '/' : '/'.$storedPath; 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 * 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. * 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']) { if ('' === $settings['legalName'] && !$hasAddress && '' === $settings['phone']) {
return null; return null;
} }
$lokal = $settings['localBusiness'] && $hasAddress;
$data = [ $data = [
'@context' => 'https://schema.org', '@context' => 'https://schema.org',
'@type' => $settings['localBusiness'] && $hasAddress ? 'LocalBusiness' : 'Organization', '@type' => $lokal ? ($settings['businessType'] ?? 'LocalBusiness') : 'Organization',
'name' => '' !== $settings['legalName'] ? $settings['legalName'] : $siteName, 'name' => '' !== $settings['legalName'] ? $settings['legalName'] : $siteName,
'url' => $origin.'/', 'url' => $origin.'/',
]; ];
@@ -542,6 +596,14 @@ final class SiteSettings
'addressCountry' => $settings['country'], 'addressCountry' => $settings['country'],
], static fn (string $value): bool => '' !== $value); ], 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 // Die sozialen Profile sind dieselbe Aussage wie sameAs - "dieses Unternehmen ist auch
// das hier" -, deshalb stehen sie ohne zweite Pflege mit in der Liste. // das hier" -, deshalb stehen sie ohne zweite Pflege mit in der Liste.
$sameAs = array_values(array_unique([...$settings['sameAs'], ...array_values($settings['social'] ?? [])])); $sameAs = array_values(array_unique([...$settings['sameAs'], ...array_values($settings['social'] ?? [])]));
+1
View File
@@ -57,6 +57,7 @@ final class SettingsController extends AbstractController
return $this->render('admin/settings.html.twig', [ return $this->render('admin/settings.html.twig', [
'settings' => $settings, 'settings' => $settings,
'businessTypes' => SiteSettings::BUSINESS_TYPES,
'legalSelection' => $legal, 'legalSelection' => $legal,
'notFoundSelection' => $this->notFoundPage->selected(), 'notFoundSelection' => $this->notFoundPage->selected(),
'redirects' => $this->redirects->all($request->query->getInt('seite', 1)), 'redirects' => $this->redirects->all($request->query->getInt('seite', 1)),
+27 -1
View File
@@ -4,7 +4,7 @@
{% block heading %}Unternehmen und Adressen{% endblock %} {% block heading %}Unternehmen und Adressen{% endblock %}
{% block lead %}Aus diesen Angaben entstehen die strukturierten Daten der Startseite. Was hier steht, ist genau das, was Suchmaschinen lesen – es gibt keine zweite, unsichtbare Fassung.{% endblock %} {% block lead %}Aus diesen Angaben entstehen die strukturierten Daten der Startseite. Was hier steht, ist genau das, was Suchmaschinen lesen – es gibt keine zweite, unsichtbare Fassung.{% endblock %}
{% block styles %}<link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %} {% block styles %}<link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %}
{% block scripts %}<script type="module" src="/assets/page-picker.js?v={{ kant_version() }}"></script><script type="module" src="/assets/favicon-field.js?v={{ kant_version() }}"></script>{% endblock %} {% block scripts %}<script type="module" src="/assets/page-picker.js?v={{ kant_version() }}"></script><script type="module" src="/assets/favicon-field.js?v={{ kant_version() }}"></script><script type="module" src="/assets/logo-field.js?v={{ kant_version() }}"></script>{% endblock %}
{% block content %} {% block content %}
{% if missingLegal is not empty %} {% if missingLegal is not empty %}
@@ -175,6 +175,32 @@
<p class="field-help">Ein Ort je Zeile, höchstens vierzig. Erscheint als <code>areaServed</code> in den strukturierten Daten.</p> <p class="field-help">Ein Ort je Zeile, höchstens vierzig. Erscheint als <code>areaServed</code> in den strukturierten Daten.</p>
<label class="check-label" for="localBusiness"><input id="localBusiness" name="localBusiness" type="checkbox" value="1"{{ settings.localBusiness ? ' checked' : '' }}> Als lokales Unternehmen auszeichnen</label> <label class="check-label" for="localBusiness"><input id="localBusiness" name="localBusiness" type="checkbox" value="1"{{ settings.localBusiness ? ' checked' : '' }}> Als lokales Unternehmen auszeichnen</label>
<p class="field-help">Braucht mindestens Straße und Ort. Sinnvoll bei einem Standort mit Kundenverkehr.</p> <p class="field-help">Braucht mindestens Straße und Ort. Sinnvoll bei einem Standort mit Kundenverkehr.</p>
<label for="businessType">Art des Unternehmens</label>
<select id="businessType" name="businessType">
{% for wert, name in businessTypes %}<option value="{{ wert }}"{{ settings.businessType|default('LocalBusiness') == wert ? ' selected' : '' }}>{{ name }}</option>{% endfor %}
</select>
<p class="field-help">Gilt nur für ein lokales Unternehmen und steht nur in den strukturierten Daten.</p>
<div class="fields">
<div>
<label for="latitude">Breitengrad</label>
<input id="latitude" name="latitude" inputmode="decimal" value="{{ settings.latitude|default('') }}" placeholder="48.79381">
</div>
<div>
<label for="longitude">Längengrad</label>
<input id="longitude" name="longitude" inputmode="decimal" value="{{ settings.longitude|default('') }}" placeholder="8.33237">
</div>
</div>
<p class="field-help">Der Standort als Koordinaten, freiwillig. Beide Werte oder keinen.</p>
<h3 id="logo-title">Logo für Suchmaschinen</h3>
<div class="favicon-field" data-logo-field aria-labelledby="logo-title" role="group">
<input type="hidden" name="organisationLogoMediaId" value="{{ settings.organisationLogoMediaId|default('') }}">
<div class="favicon-current"><span>{{ settings.organisationLogoMediaId|default('') ? 'Logo gewählt.' : 'Kein Logo gewählt.' }}</span></div>
<div class="favicon-actions">
<button type="button" class="secondary" data-logo-choose>{{ settings.organisationLogoMediaId|default('') ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen' }}</button>
<button type="button" class="text-button danger-text" data-logo-remove{{ settings.organisationLogoMediaId|default('') ? '' : ' hidden' }}>Logo entfernen</button>
</div>
</div>
<p class="field-help">Ein Bild aus der Mediathek, mindestens 112 Pixel breit und hoch. Es steht nur in den strukturierten Daten; das sichtbare Logo im Kopfbereich bleibt davon unberührt.</p>
<label class="check-label" for="llmsTxt"><input id="llmsTxt" name="llmsTxt" type="checkbox" value="1"{{ settings.llmsTxt ? ' checked' : '' }}> <code>llms.txt</code> ausliefern</label> <label class="check-label" for="llmsTxt"><input id="llmsTxt" name="llmsTxt" type="checkbox" value="1"{{ settings.llmsTxt ? ' checked' : '' }}> <code>llms.txt</code> ausliefern</label>
<p class="field-help">Eine maschinenlesbare Liste der veröffentlichten, indexierbaren Seiten. Optional. Kein Rankingfaktor und keine Voraussetzung für irgendeine Suchfunktion; ohne Aktivierung antwortet die Adresse mit 404.</p> <p class="field-help">Eine maschinenlesbare Liste der veröffentlichten, indexierbaren Seiten. Optional. Kein Rankingfaktor und keine Voraussetzung für irgendeine Suchfunktion; ohne Aktivierung antwortet die Adresse mit 404.</p>
<div class="section-head"><h2 id="favicon-title">Symbol der Website</h2><span class="muted">Favicon im Browser-Reiter und auf dem Startbildschirm</span></div> <div class="section-head"><h2 id="favicon-title">Symbol der Website</h2><span class="muted">Favicon im Browser-Reiter und auf dem Startbildschirm</span></div>
+47
View File
@@ -4450,6 +4450,53 @@ display: flex', '#b1{display:flex;color:red}'];
self::assertSame(['Baden-Baden', 'Rastatt', 'Karlsruhe', 'Offenburg'], $data['areaServed']); self::assertSame(['Baden-Baden', 'Rastatt', 'Karlsruhe', 'Offenburg'], $data['areaServed']);
} }
public function testBusinessTypeLocationAndLogoReachTheStructuredData(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
$basis = ['country' => 'DE', 'homePath' => 'home', 'legalName' => 'Luximmo UG', 'street' => 'Hauptstraße 169', 'city' => 'Gaggenau', 'localBusiness' => '1'];
$daten = function (): array {
preg_match('#<script type="application/ld\+json">(.*?)</script>#s', $this->request('/')->getContent(), $match);
return json_decode(html_entity_decode($match[1], ENT_QUOTES), true);
};
// Ohne Angaben bleibt alles, wie es war: bestehende Websites ändern sich nicht.
$this->site->save($basis);
$vorher = $daten();
self::assertSame('LocalBusiness', $vorher['@type']);
self::assertArrayNotHasKey('geo', $vorher);
self::assertArrayNotHasKey('logo', $vorher);
$mediaId = bin2hex(random_bytes(16));
$this->db->insert('media', ['id' => $mediaId, 'title' => 'Logo', 'alt' => '', 'variants' => json_encode([['src' => '/media/'.$mediaId.'/480', 'width' => 480, 'height' => 420], ['src' => '/media/'.$mediaId.'/640', 'width' => 640, 'height' => 561]], JSON_THROW_ON_ERROR), 'archived' => 0, 'version' => 1, 'created_at' => '2026-11-09 00:00:00']);
$eingabe = [...$basis, 'businessType' => 'RealEstateAgent', 'latitude' => '48,79381', 'longitude' => '8.33237', 'organisationLogoMediaId' => $mediaId];
$this->site->save($eingabe);
$nachher = $daten();
self::assertSame('RealEstateAgent', $nachher['@type']);
self::assertSame(['@type' => 'GeoCoordinates', 'latitude' => 48.79381, 'longitude' => 8.33237], $nachher['geo']);
self::assertStringEndsWith('/media/'.$mediaId.'/640', $nachher['logo']);
// Erneutes Speichern der geladenen Werte verliert nichts.
$this->site->save([...$this->site->load(), 'sameAs' => '']);
self::assertSame($nachher, $daten());
// Ohne „lokal“ gibt es weder Unterart noch Standort; das Logo gilt für jede Organisation.
$this->site->save([...$eingabe, 'localBusiness' => '']);
$organisation = $daten();
self::assertSame('Organization', $organisation['@type']);
self::assertArrayNotHasKey('geo', $organisation);
self::assertArrayHasKey('logo', $organisation);
foreach ([['businessType' => 'Geheimdienst'], ['latitude' => '91', 'longitude' => '8'], ['latitude' => 'abc', 'longitude' => '8'], ['latitude' => '48.1'], ['organisationLogoMediaId' => str_repeat('a', 32)]] as $falsch) {
try {
$this->site->save([...$basis, ...$falsch]);
self::fail('Ungültige Angabe angenommen: '.json_encode($falsch));
} catch (InvalidPage) {
$this->addToAssertionCount(1);
}
}
}
public function testReSavingLoadedSettingsKeepsHoursAndServiceArea(): void public function testReSavingLoadedSettingsKeepsHoursAndServiceArea(): void
{ {
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'hours' => ['Monday' => ['opens' => '07:30', 'closes' => '17:00']], 'areaServed' => 'Bühl $this->site->save(['country' => 'DE', 'homePath' => 'home', 'hours' => ['Monday' => ['opens' => '07:30', 'closes' => '17:00']], 'areaServed' => 'Bühl