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:
1 parent
d75c295f59
commit
f2c04b311f
7 files changed
+188
-2
No files matched your search
@@ -2,6 +2,14 @@
|
||||
|
||||
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)
|
||||
|
||||
**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.
|
||||
|
||||
@@ -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-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)}
|
||||
.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[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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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'] ?? [])]));
|
||||
|
||||
@@ -57,6 +57,7 @@ final class SettingsController extends AbstractController
|
||||
|
||||
return $this->render('admin/settings.html.twig', [
|
||||
'settings' => $settings,
|
||||
'businessTypes' => SiteSettings::BUSINESS_TYPES,
|
||||
'legalSelection' => $legal,
|
||||
'notFoundSelection' => $this->notFoundPage->selected(),
|
||||
'redirects' => $this->redirects->all($request->query->getInt('seite', 1)),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% 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 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 %}
|
||||
|
||||
{% 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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -4450,6 +4450,53 @@ display: flex', '#b1{display:flex;color:red}'];
|
||||
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
|
||||
{
|
||||
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'hours' => ['Monday' => ['opens' => '07:30', 'closes' => '17:00']], 'areaServed' => 'Bühl
|
||||
|
||||
Reference in new issue
Block a user