Handelsregister: HRB 731961 Umsatzsteuer-Identifikationsnummer: DE 815 618 082"],
+ ['Elektro Bernhard Regenerative Energie GmbH', "Eigenständige Gesellschaft, unabhängig von der Elektro Bernhard GmbH. Ooser Hauptstr. 51 76532 Baden-Baden Vertreten durch Ridvan Kantarci. Handelsregister: HRB 741275"],
+ ['Gestaltung und Umsetzung', 'Kant-IT Solutions'],
+ ['Bildnachweise', 'Logo, Teamfoto und Elektroinstallationsmotiv aus dem bestehenden Webauftritt von Elektro Bernhard. Markenbezeichnungen gehören ihren jeweiligen Inhabern.'],
+ ]),
+ ],
+], 'Impressum | Elektro Bernhard GmbH');
+
+$anlegen('datenschutzerklaerung', null, [
+ 'schemaVersion' => $SCHEMA, 'title' => 'Datenschutzhinweise',
+ 'description' => 'Informationen zur Verarbeitung personenbezogener Daten bei Elektro Bernhard: Websitebesuch, Kontaktaufnahme und Ihre Rechte.',
+ 'blocks' => [
+ $seitenkopf('datenschutz', 'Ihre Daten', 'Datenschutzhinweise', 'Hier erfahren Sie, wie wir Daten beim Besuch unserer Website und bei Ihrer Kontaktaufnahme verarbeiten.'),
+ $textkoerper('datenschutz', [
+ ['1. Verantwortlicher', "Elektro Bernhard GmbH, Ooser Hauptstr. 51, 76532 Baden-Baden. Geschäftsführer: Ridvan Kantarci. Telefon: +49 7221 3944909. E-Mail: $mail."],
+ ['2. Besuch der Website', 'Beim Aufruf verarbeitet der Hostingdienst technisch erforderliche Verbindungsdaten, insbesondere IP-Adresse, Zeitpunkt, angeforderte Adresse, Browserinformationen und HTTP-Status. Das dient der sicheren Bereitstellung und Fehleranalyse. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Umfang und Dauer der Serverprotokollierung richten sich nach der eingesetzten Hostingkonfiguration.'],
+ ['3. Lokale Inhalte und Cookies', 'Die Website selbst setzt keine Analyse- oder Werbecookies ein. Bilder, Schriften und Gestaltung werden vom eigenen Webserver geladen. Es werden keine Trackingdienste oder Karten beim Seitenaufruf eingebettet.'],
+ ['4. Kontaktanfragen', 'Wenn Sie uns kontaktieren, verarbeiten wir die von Ihnen angegebenen Kontakt- und Inhaltsdaten zur Bearbeitung Ihrer Anfrage. Rechtsgrundlage ist bei vertragsbezogenen Anfragen Art. 6 Abs. 1 lit. b DSGVO, ansonsten unser berechtigtes Interesse an der Bearbeitung von Anfragen gemäß Art. 6 Abs. 1 lit. f DSGVO.
Das Kontaktformular dieser Website überträgt Ihre Angaben an unseren Server und versendet sie als E-Mail an uns. NOCH ZU PRÜFEN: Hostinganbieter, Verarbeitungsländer, Auftragsverarbeitung und konkrete Aufbewahrungsdauer ergänzen.
Wir löschen Anfragedaten, wenn sie für die Bearbeitung nicht mehr erforderlich sind und keine gesetzlichen Aufbewahrungspflichten oder berechtigten Gründe für eine weitere Speicherung bestehen.'],
+ ['5. Externe Links', 'Google Maps, Google-Bewertungen und unsere Profile in sozialen Netzwerken sind als Links eingebunden. Erst beim Öffnen eines solchen Links wird die jeweilige externe Website aufgerufen. Dort gelten die Datenschutzinformationen des betreffenden Anbieters.'],
+ ['6. Ihre Rechte', "Unter den gesetzlichen Voraussetzungen haben Sie Rechte auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit. Sie können einer Verarbeitung auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO widersprechen. Eine erteilte Einwilligung können Sie mit Wirkung für die Zukunft widerrufen.
Außerdem können Sie sich bei einer Datenschutzaufsichtsbehörde beschweren, beispielsweise beim Landesbeauftragten für den Datenschutz und die Informationsfreiheit Baden-Württemberg.
Für Fragen wenden Sie sich bitte an $mail."],
+ ]),
+ ],
+], 'Datenschutz | Elektro Bernhard GmbH');
+
+$fehlerId = $anlegen('404', null, [
+ 'schemaVersion' => $SCHEMA, 'title' => 'Seite nicht gefunden',
+ 'description' => 'Die gesuchte Seite existiert nicht oder wurde verschoben. Über die Startseite finden Sie zurück.',
+ 'blocks' => [
+ $seitenkopf('fehler', 'Fehler 404', 'Seite nicht gefunden', 'Die gesuchte Seite existiert nicht oder wurde verschoben.', [$button('fehler-knopf', 'Zur Startseite', '/', ['desktop' => ['margin' => ['top' => '20px']]])]),
+ ],
+]);
+$db->executeStatement('UPDATE page SET robots = ? WHERE id = ?', ['noindex', $fehlerId]);
+
+// ---------------------------------------------------------------------------------------------
+// Menüs, Einstellungen, Weiterleitungen, Konto
+// ---------------------------------------------------------------------------------------------
+
+$seite = static fn (string $label, string $slug, string $anchor = ''): array => ['label' => $label, 'type' => 'page', 'pageId' => $erstellt[$slug], 'anchor' => $anchor, 'children' => []];
+
+$menus->save('header', 'Hauptnavigation', [
+ $seite('Leistungen', 'leistungen'),
+ $seite('Über uns', 'home', 'ueber-uns'),
+ $seite('Solarenergie', 'regenerative-energie'),
+ $seite('Kontakt', 'kontakt'),
+], 0);
+$menus->save('secondary', 'Notdienst', [
+ ['label' => '24h Notdienst '.$notdienst, 'type' => 'url', 'url' => 'tel:+4915125245851', 'icon' => 'phone', 'iconPosition' => 'before', 'children' => []],
+], 0);
+$menus->save('footer', 'Fußzeile', [
+ $seite('Unsere Leistungen', 'leistungen'),
+ $seite('Das Unternehmen', 'home', 'ueber-uns'),
+ $seite('Regenerative Energie', 'regenerative-energie'),
+ $seite('Kontakt & Anfahrt', 'kontakt'),
+], 0);
+$menus->save('legal', 'Rechtliches', [$seite('Impressum', 'impressum'), $seite('Datenschutz', 'datenschutzerklaerung')], 0);
+echo "Menüs angelegt\n";
+
+$settings->assignNotFoundPage($fehlerId, '');
+$settings->save([
+ 'legalName' => 'Elektro Bernhard GmbH',
+ 'description' => 'Elektrofachbetrieb in Baden-Baden, seit über 35 Jahren. Elektroinstallation für Neu- und Altbau, Smart Home, Beratung und Planung sowie ein 24h-Notdienst unter 0151 25245851. Bürozeiten Mo–Do 7:00–16:30 Uhr, Fr 7:00–13:00 Uhr.',
+ 'phone' => $buero, 'email' => $mail,
+ 'street' => 'Ooser Hauptstr. 51', 'postalCode' => '76532', 'city' => 'Baden-Baden', 'country' => 'DE',
+ // Die Profile, die der bestehende Auftritt selbst verlinkt - einmal hier, gezeigt vom Baustein
+ // „Soziale Profile“ in Kopfleiste und Fuß, geführt als sameAs in den strukturierten Daten.
+ 'social' => [
+ 'facebook' => 'https://www.facebook.com/elektrobernhardgmbh',
+ 'instagram' => 'https://www.instagram.com/elektrobernhardgmbh/',
+ 'google' => 'https://g.page/ElektroBernhardbadenbaden',
+ ],
+ 'localBusiness' => true, 'llmsTxt' => true,
+ // Der Kopfbereich schwebt über dem dunklen Seitenkopf und bleibt beim Scrollen oben.
+ 'stickyHeader' => true, 'headerOverlay' => true,
+ 'motion' => 'always',
+ 'homePath' => 'home', 'privacyPath' => 'datenschutzerklaerung', 'imprintPath' => 'impressum',
+ 'consentBanner' => false, 'scrollTop' => true, 'scrollTopColor' => '@rot', 'scrollTopInk' => '@papier', 'postMetaHeader' => true,
+]);
+echo "Einstellungen gespeichert\n";
+
+foreach (['/datenschutz' => '/datenschutzerklaerung', '/service' => '/leistungen', '/ueber-uns' => '/home'] as $von => $nach) {
+ $redirects->save($von, $nach, 301);
+}
+echo "Weiterleitungen angelegt\n";
+
+$passwort = bin2hex(random_bytes(12));
+$db->insert('cms_user', ['id' => bin2hex(random_bytes(16)), 'username' => 'umut', 'password_hash' => password_hash($passwort, PASSWORD_BCRYPT), 'roles' => '["ROLE_ADMIN"]']);
+
+echo "\nFertig.\nVerwaltung: Benutzer „umut“, Passwort: $passwort\n";
diff --git a/src/Content/DocumentValidator.php b/src/Content/DocumentValidator.php
index f353d23..151dde8 100644
--- a/src/Content/DocumentValidator.php
+++ b/src/Content/DocumentValidator.php
@@ -33,6 +33,8 @@ final class DocumentValidator
public const V19_TYPES = ['legal'];
/** Schema v15: a single icon, a progress bar and a slider that works without a script. */
public const V15_TYPES = ['icon', 'progress', 'slider'];
+ /** Schema v23: die sozialen Profile aus den Einstellungen, als Symbolreihe an jeder Stelle. */
+ public const V23_TYPES = ['social'];
/**
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
@@ -42,7 +44,7 @@ final class DocumentValidator
public function validate(array $document, bool $requireHeading = true): void
{
$schemaVersion = $document['schemaVersion'] ?? null;
- $this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22], true), 'Unsupported document schema.');
+ $this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23], true), 'Unsupported document schema.');
$this->text($document['title'] ?? null, 160);
$this->text($document['description'] ?? null, 320);
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.');
@@ -85,7 +87,7 @@ final class DocumentValidator
// page: a deactivated plugin must never make stored content unreadable. Nothing from
// props is rendered for such a block, so unvalidated values never reach the output.
$plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type);
- $this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
+ $this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || ($schemaVersion >= 23 && in_array($type, self::V23_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
// An accordion entry only ever renders inside an accordion; at the top level or anywhere
// else it would produce a details element without its group.
$this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.');
@@ -154,7 +156,7 @@ final class DocumentValidator
$this->text($props['text'] ?? null, $limit);
}
if (!$plugin && array_key_exists('newTab', $props)) {
- $this->require($schemaVersion >= 20 && in_array($type, ['button', 'image', 'iconcard', 'cta', 'icon'], true), 'Neue Link-Tabs brauchen Schema v20 und einen verlinkbaren Block.');
+ $this->require($schemaVersion >= 20 && in_array($type, ['button', 'image', 'iconcard', 'cta', 'icon', 'social'], true), 'Neue Link-Tabs brauchen Schema v20 und einen verlinkbaren Block.');
$this->require(is_bool($props['newTab']), 'In neuem Tab öffnen ist an oder aus.');
}
foreach (($block['style'] ?? []) as $values) {
@@ -287,6 +289,19 @@ final class DocumentValidator
$this->url($props['href']);
}
}
+ if ('social' === $type) {
+ // Nur die Darstellung ist Inhalt des Blocks. Welche Profile es gibt, steht in den
+ // Einstellungen - ein Dokument kann kein Profil behaupten, das die Website nicht hat.
+ $this->require(in_array($props['size'] ?? null, self::ICON_SIZES, true), sprintf('Die Symbolgröße ist eine aus %s.', implode(', ', self::ICON_SIZES)));
+ $this->require(isset(SocialProfiles::SHAPES[$props['shape'] ?? '']), 'Unbekannte Form der Symbolfläche.');
+ $this->require(isset(SocialProfiles::LAYOUTS[$props['layout'] ?? '']), 'Unbekannte Anordnung der Symbole.');
+ $this->require(is_bool($props['labels'] ?? null), 'Die Beschriftung der Symbole ist an oder aus.');
+ $networks = $props['networks'] ?? [];
+ $this->require(is_array($networks) && array_is_list($networks), 'Die Auswahl der Netzwerke ist eine Liste.');
+ foreach ($networks as $network) {
+ $this->require(is_string($network) && isset(SocialProfiles::NETWORKS[$network]), 'Unbekanntes Netzwerk in der Auswahl.');
+ }
+ }
if ('progress' === $type) {
$this->text($props['label'] ?? null, 120);
$this->require(is_int($props['value'] ?? null) && $props['value'] >= 0 && $props['value'] <= 100, 'Der Wert liegt zwischen 0 und 100.');
diff --git a/src/Content/SiteSettings.php b/src/Content/SiteSettings.php
index 9af77ab..ef105d3 100644
--- a/src/Content/SiteSettings.php
+++ b/src/Content/SiteSettings.php
@@ -24,6 +24,9 @@ final class SiteSettings
'city' => '',
'country' => 'DE',
'sameAs' => [],
+ // Die sozialen Profile, je Netzwerk eine Adresse (siehe SocialProfiles). Der Baustein
+ // `social` zeigt sie an; die strukturierten Daten führen sie als sameAs mit.
+ 'social' => [],
'localBusiness' => false,
'llmsTxt' => false,
// Der Kopfbereich bleibt beim Scrollen oben stehen. Eine Eigenschaft der Website und
@@ -133,6 +136,7 @@ final class SiteSettings
$values['sameAs'][] = $profile;
}
$values['sameAs'] = array_slice(array_unique($values['sameAs']), 0, 20);
+ $values['social'] = SocialProfiles::clean($input['social'] ?? []);
$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.');
@@ -353,8 +357,11 @@ final class SiteSettings
'addressCountry' => $settings['country'],
], static fn (string $value): bool => '' !== $value);
}
- if ([] !== $settings['sameAs']) {
- $data['sameAs'] = $settings['sameAs'];
+ // 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'] ?? [])]));
+ if ([] !== $sameAs) {
+ $data['sameAs'] = $sameAs;
}
return $data;
diff --git a/src/Content/SocialProfiles.php b/src/Content/SocialProfiles.php
new file mode 100644
index 0000000..f0276d3
--- /dev/null
+++ b/src/Content/SocialProfiles.php
@@ -0,0 +1,79 @@
+ 'Facebook',
+ 'instagram' => 'Instagram',
+ 'linkedin' => 'LinkedIn',
+ 'youtube' => 'YouTube',
+ 'tiktok' => 'TikTok',
+ 'x' => 'X',
+ 'threads' => 'Threads',
+ 'pinterest' => 'Pinterest',
+ 'xing' => 'Xing',
+ 'whatsapp' => 'WhatsApp',
+ 'google' => 'Google-Unternehmensprofil',
+ ];
+
+ /** Beispieladressen für die Eingabefelder. */
+ public const PLACEHOLDERS = [
+ 'facebook' => 'https://www.facebook.com/…',
+ 'instagram' => 'https://www.instagram.com/…',
+ 'linkedin' => 'https://www.linkedin.com/company/…',
+ 'youtube' => 'https://www.youtube.com/@…',
+ 'tiktok' => 'https://www.tiktok.com/@…',
+ 'x' => 'https://x.com/…',
+ 'threads' => 'https://www.threads.net/@…',
+ 'pinterest' => 'https://www.pinterest.de/…',
+ 'xing' => 'https://www.xing.com/pages/…',
+ 'whatsapp' => 'https://wa.me/49…',
+ 'google' => 'https://g.page/…',
+ ];
+
+ public const SHAPES = ['plain' => 'Nur Symbol', 'circle' => 'Runde Fläche', 'square' => 'Eckige Fläche'];
+ public const LAYOUTS = ['horizontal' => 'Nebeneinander', 'vertical' => 'Untereinander'];
+
+ /**
+ * Prüft die Eingabe der Einstellungen und gibt nur gefüllte, gültige Adressen zurück.
+ *
+ * @param mixed $input das Feld `social` des Formulars: Netzwerk → Adresse
+ *
+ * @return array
+ *
+ * @throws InvalidPage
+ */
+ public static function clean(mixed $input): array
+ {
+ $profiles = [];
+ foreach (self::NETWORKS as $key => $label) {
+ $url = trim((string) (is_array($input) ? ($input[$key] ?? '') : ''));
+ if ('' === $url) {
+ continue;
+ }
+ if (strlen($url) > 400 || !str_starts_with($url, 'https://') || false === filter_var($url, FILTER_VALIDATE_URL) || 1 === preg_match('/[\s"<>\\\\]/', $url)) {
+ throw new InvalidPage(sprintf('Die Adresse für %s muss mit https:// beginnen und eine gültige Adresse sein.', $label));
+ }
+ $profiles[$key] = $url;
+ }
+
+ return $profiles;
+ }
+}
diff --git a/src/Controller/SettingsController.php b/src/Controller/SettingsController.php
index a5249c7..adce5dd 100644
--- a/src/Controller/SettingsController.php
+++ b/src/Controller/SettingsController.php
@@ -9,6 +9,7 @@ use App\Content\NotFoundPage;
use App\Content\PageRepository;
use App\Content\RedirectRepository;
use App\Content\SiteSettings;
+use App\Content\SocialProfiles;
use App\Content\Theme;
use App\Domain\InvalidPage;
use App\Domain\StaleWrite;
@@ -47,6 +48,8 @@ final class SettingsController extends AbstractController
'images' => $this->media->page(1, false, 96)['items'],
// Fuer die Farbfelder: dieselbe Palette, die auch der Editor anbietet.
'palette' => $this->theme->load()['colors'],
+ 'socialNetworks' => SocialProfiles::NETWORKS,
+ 'socialPlaceholders' => SocialProfiles::PLACEHOLDERS,
]);
}
diff --git a/templates/admin/settings.html.twig b/templates/admin/settings.html.twig
index 46e4283..a57eb3d 100644
--- a/templates/admin/settings.html.twig
+++ b/templates/admin/settings.html.twig
@@ -95,7 +95,19 @@
-
Eine HTTPS-Adresse je Zeile, höchstens zwanzig.
+
Eine HTTPS-Adresse je Zeile, höchstens zwanzig. Die sozialen Profile unten stehen automatisch mit in den strukturierten Daten.
+
Braucht mindestens Straße und Ort. Sinnvoll bei einem Standort mit Kundenverkehr.
diff --git a/templates/blocks/block.html.twig b/templates/blocks/block.html.twig
index aaea832..82bddc5 100644
--- a/templates/blocks/block.html.twig
+++ b/templates/blocks/block.html.twig
@@ -170,6 +170,26 @@
{% else %}
{{ iconBody }}
{% endif %}
+{% elseif type == 'social' %}
+ {# Die Profile kommen aus den Einstellungen, der Block sagt nur, wie sie aussehen. Eine
+ Liste, weil es eine ist; ohne Beschriftung trägt jeder Link seinen Namen unsichtbar. #}
+ {% set profiles = siteSettings.load().social|default({}) %}
+ {% set chosen = props.networks|default([]) %}
+ {% set shown = [] %}
+ {% for key, name in constant('App\\Content\\SocialProfiles::NETWORKS') %}
+ {% if profiles[key]|default('') and (chosen is empty or key in chosen) %}{% set shown = shown|merge([{key: key, name: name, url: profiles[key]}]) %}{% endif %}
+ {% endfor %}
+ {% if shown is not empty %}
+
Noch keine sozialen Profile eingetragen. Sie stehen unter Einstellungen → Unternehmensdaten → Soziale Profile.
+ {% else %}
+
+ {% endif %}
{% elseif type == 'progress' %}
{# A progress bar as a real meter: the number is in the markup, not only in the width of a
coloured div, so it is readable without seeing it. #}
diff --git a/templates/blocks/social-icon.html.twig b/templates/blocks/social-icon.html.twig
new file mode 100644
index 0000000..44313a2
--- /dev/null
+++ b/templates/blocks/social-icon.html.twig
@@ -0,0 +1,17 @@
+{# Ein Symbol je Netzwerk, gezeichnet im Strich des übrigen Symbolsatzes (icon.html.twig): keine
+ Markenbilder, keine Schrift, keine fremde Datei. Die Form ist so weit vereinfacht, dass sie
+ als Umriss erkennbar bleibt und in jeder Größe und Farbe funktioniert. #}
+{% set shapes = {
+ facebook: '',
+ instagram: '',
+ linkedin: '',
+ youtube: '',
+ tiktok: '',
+ x: '',
+ threads: '',
+ pinterest: '',
+ xing: '',
+ whatsapp: '',
+ google: ''
+} %}
+
diff --git a/tests/CmsTest.php b/tests/CmsTest.php
index 1fadb11..82ca426 100644
--- a/tests/CmsTest.php
+++ b/tests/CmsTest.php
@@ -3568,6 +3568,64 @@ final class CmsTest extends TestCase
yield 'slider missing a device' => ['slider', ['desktopVisible' => 3, 'tabletVisible' => 2, 'snap' => true]];
}
+ public function testSocialProfilesAreMaintainedOnceAndShownWhereverTheBlockStands(): void
+ {
+ $this->site->save(['country' => 'DE', 'homePath' => 'home', 'sameAs' => 'https://kant-it.de', 'social' => [
+ 'instagram' => 'https://www.instagram.com/elektrobernhardgmbh/', 'facebook' => 'https://www.facebook.com/elektrobernhardgmbh',
+ 'x' => '',
+ ]]);
+ self::assertSame(['facebook' => 'https://www.facebook.com/elektrobernhardgmbh', 'instagram' => 'https://www.instagram.com/elektrobernhardgmbh/'], $this->site->load()['social']);
+
+ $document = DemoContent::starter('Mit Profilen');
+ $document['schemaVersion'] = 23;
+ $document['blocks'][] = ['id' => 'so1', 'type' => 'social', 'props' => ['size' => 32, 'shape' => 'circle', 'layout' => 'horizontal', 'labels' => false, 'networks' => []]];
+ $document['blocks'][] = ['id' => 'so2', 'type' => 'social', 'props' => ['size' => 20, 'shape' => 'plain', 'layout' => 'vertical', 'labels' => true, 'networks' => ['instagram'], 'newTab' => false]];
+ $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
+ $html = $this->request('/')->getContent();
+ // Die Reihenfolge ist die der festen Liste, nicht die der Eingabe; jeder Link trägt seinen Namen.
+ self::assertMatchesRegularExpression('#
]*>.*facebook\.com.*instagram\.com.*
#s', $html);
+ self::assertStringContainsString('Facebook', $html);
+ self::assertStringContainsString('href="https://www.facebook.com/elektrobernhardgmbh" target="_blank" rel="noopener"', $html);
+ // Der zweite Block zeigt nur Instagram, beschriftet, im selben Tab.
+ preg_match('#