Files
KantSites/src/Content/SiteSettings.php
T
kantarciandClaude Fable 5.1 0bdcc7f90e
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Make the consent notice styleable and keep the back-to-top button reachable in 1.14.1
The back-to-top button was hidden as long as the consent question was open,
on every device - and on a phone the question tends to stay open. Instead
of `display:none` it now anchors itself 12px above the bar wherever the bar
covers its corner: below 800px for the centred card, at every width for the
full-width bar and the corner card. Browsers without anchor positioning keep
hiding it there. The bar renders before the button so it can be an anchor,
and the reserved bottom padding grows to 320px below 620px, where the bar
with its default text is up to 299px tall.

The notice itself gets a face. Settings → Einwilligung now carries layout
(card, bar, corner), shape (soft, round, square - one setting for the box and
its buttons), a heading and four colours, each as #hex or @palette key.
`Theme::cssColor()` resolves both, replacing the copy inside the back-to-top
code; `Consent::styles()` writes the values as `--kant-consent-*` into the
hashed style block. The base layout changes with it: an accent stripe and
shield icon, equal-width buttons, a disclosure with a chevron, underlined
legal links, a two-column bar on wide screens. Declining stays an outlined
button of the same size next to accepting. The archive and the consent page
carry the same variables - the archive had no palette at all until now.

In the editor, rows with an own value are labelled in orange rather than
bold with a dot; the device chips sit 6px apart with the focus ring inside
the gap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 06:57:38 +02:00

478 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Content;
use App\Domain\InvalidPage;
use App\Domain\StaleWrite;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception\TableNotFoundException;
/**
* 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' => [],
// 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' => [],
// Öffnungszeiten je Wochentag (opens/closes als HH:MM) und das Einsatzgebiet als Liste
// von Ortsnamen. Beides landet in LocalBusiness, weil eine Suchmaschine sonst rät.
'openingHours' => [],
'areaServed' => [],
'localBusiness' => false,
'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.
'stickyHeader' => false,
// Der Kopfbereich liegt über dem Inhalt statt darüber zu stehen - der Flatsome-Aufbau mit
// transparentem Kopf über dem Hero. Welche Farben er beim Scrollen bekommt, bestimmt die
// Gestaltung des Kopfbereichs selbst („beim Scrollen" unter Hintergrund).
'headerOverlay' => false,
// Einblendungen und Hover-Bewegungen. „always" spielt sie wie Flatsome bei jedem
// Besucher ab; „respect" hält sich an die Browservorgabe „Bewegung reduzieren", die
// manche Systeme melden, ohne dass jemand sie bewusst eingestellt hat.
'motion' => 'always',
// Datum, Autor und Kategorie über dem Beitrag. Aus, wenn der Beitrag seinen eigenen
// Kopf mitbringt - sonst steht die Zeile über einem Bild, das den Titel schon trägt.
'postMetaHeader' => true,
// Das Symbol der Website im Browser-Reiter: ein Bild aus der Mediathek, aus dem beim
// Speichern quadratische PNG-Dateien entstehen. Leer heißt: das mitgelieferte Zeichen.
'faviconMediaId' => '',
// Der Knopf „nach oben". Aus, weil eine kurze Seite ihn nicht braucht; alles daran ist
// einstellbar, weil er auf jeder Seite in der Ecke klebt und deshalb zur Gestaltung
// passen muss. Eingeblendet wird er beim Scrollen - ohne eine Zeile JavaScript.
'scrollTop' => false,
'scrollTopPosition' => 'rechts',
'scrollTopShape' => 'rund',
'scrollTopSize' => 48,
'scrollTopColor' => '',
'scrollTopInk' => '',
'scrollTopLabel' => 'Nach oben',
'scrollTopAfter' => 400,
// Cloudflare Turnstile fuer Formulare. Aus, weil eine Website ohne Spamwelle keinen
// Dritten einbinden soll - und weil die Pruefung JavaScript verlangt.
'turnstile' => false,
'turnstileSiteKey' => '',
'turnstileSecret' => '',
'robotsExtra' => '',
// Which stored page answers at the site root. Everything else derives from this.
'homePath' => 'home',
'notFoundPageId' => '',
// The consent question. Off by default, because a site that loads nothing from third
// parties has nothing to ask about, and a banner without a reason is only in the way.
'consentBanner' => false,
'consentGroups' => ['external'],
'consentText' => '',
// Sein Aussehen: Anordnung, Form, Farben, Überschrift. Leer heißt Theme-Standard.
'consentLayout' => 'karte',
'consentShape' => 'weich',
'consentTitle' => '',
'consentColor' => '',
'consentInk' => '',
'consentAccent' => '',
'consentAccentInk' => '',
'privacyPath' => '',
'imprintPath' => '',
];
/** Die Wochentage, wie schema.org sie nennt, mit deutscher Beschriftung für das Formular. */
public const DAYS = ['Monday' => 'Montag', 'Tuesday' => 'Dienstag', 'Wednesday' => 'Mittwoch', 'Thursday' => 'Donnerstag', 'Friday' => 'Freitag', 'Saturday' => 'Samstag', 'Sunday' => 'Sonntag'];
public function __construct(private Connection $db, private MediaLibrary $media)
{
}
/**
* Die Öffnungszeiten aus dem Formular: je Tag ein Paar aus Öffnen und Schließen; ein Tag ohne
* beide Angaben gilt als geschlossen und fehlt in der Liste.
*
* @return list<array{day: string, opens: string, closes: string}>
*/
private function openingHours(mixed $input): array
{
$hours = [];
if (is_array($input) && array_is_list($input)) {
// Die gespeicherte Form: eine Liste von {day, opens, closes} wird zur Formularform.
$map = [];
foreach ($input as $row) {
if (is_array($row) && isset($row['day'])) {
$map[(string) $row['day']] = ['opens' => $row['opens'] ?? '', 'closes' => $row['closes'] ?? ''];
}
}
$input = $map;
}
foreach (self::DAYS as $day => $label) {
$row = is_array($input) && is_array($input[$day] ?? null) ? $input[$day] : [];
$opens = trim((string) ($row['opens'] ?? ''));
$closes = trim((string) ($row['closes'] ?? ''));
if ('' === $opens && '' === $closes) {
continue;
}
foreach ([$opens, $closes] as $time) {
if (1 !== preg_match('/^([01]\d|2[0-3]):[0-5]\d$/D', $time)) {
throw new InvalidPage(sprintf('Die Öffnungszeit für %s braucht Öffnen und Schließen als Uhrzeit wie 07:30.', $label));
}
}
if ($opens >= $closes) {
throw new InvalidPage(sprintf('%s: die Schließzeit muss nach der Öffnungszeit liegen.', $label));
}
$hours[] = ['day' => $day, 'opens' => $opens, 'closes' => $closes];
}
return $hours;
}
/** Das Einsatzgebiet: ein Ortsname je Zeile, höchstens vierzig. */
private function areaServed(string $input): array
{
$places = [];
foreach (preg_split('/[\r\n,]+/', $input) ?: [] as $place) {
$place = trim($place);
if ('' === $place) {
continue;
}
if (mb_strlen($place) > 80 || 1 === preg_match('/[<>"]/', $place)) {
throw new InvalidPage(sprintf('„%s“ ist kein Ortsname.', mb_substr($place, 0, 40)));
}
$places[] = $place;
}
if (count($places) > 40) {
throw new InvalidPage('Mehr als vierzig Orte im Einsatzgebiet sind nicht vorgesehen.');
}
return array_values(array_unique($places));
}
/**
* Remembered for the length of one request.
*
* These settings are asked for several times per page - by the renderer, by the structured
* data, by the consent gate, by the templates - and each time it was the same row again. The
* value is dropped on every save, so nothing stale survives a change.
*/
private ?array $loaded = null;
public function load(): array
{
if (null !== $this->loaded) {
return $this->loaded;
}
try {
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']);
} catch (TableNotFoundException) {
// Vor der Installation gibt es die Tabelle noch nicht. Fragen tun trotzdem schon
// Seiten, die es in dem Moment geben kann - der Installer selbst und die
// Nicht-gefunden-Seite. Die Standardwerte sind dort die richtige Antwort.
return $this->loaded = self::DEFAULTS;
}
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
return $this->loaded = [...self::DEFAULTS, ...(is_array($stored) ? $stored : [])];
}
public function save(array $input): void
{
$values = self::DEFAULTS;
// Assigned separately, so saving company data cannot clear the error page.
$values['notFoundPageId'] = $this->load()['notFoundPageId'];
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['social'] = SocialProfiles::clean($input['social'] ?? []);
// Zwei Formen kommen hier an: die des Formulars (hours[Monday][opens], Orte als Text) und
// die gespeicherte (openingHours als Liste, areaServed als Liste), wenn ein anderer Bildschirm
// die geladenen Werte unverändert zurückgibt. Beide müssen dasselbe ergeben, sonst löscht
// das Anlegen der Rechtsseiten die Öffnungszeiten.
$values['openingHours'] = $this->openingHours($input['hours'] ?? $input['openingHours'] ?? []);
$areaInput = $input['areaServed'] ?? '';
$values['areaServed'] = $this->areaServed(is_array($areaInput) ? implode('
', array_map('strval', $areaInput)) : (string) $areaInput);
$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);
$values['stickyHeader'] = (bool) ($input['stickyHeader'] ?? false);
$values['headerOverlay'] = (bool) ($input['headerOverlay'] ?? false);
$favicon = trim((string) ($input['faviconMediaId'] ?? ''));
if ('' !== $favicon) {
if (1 !== preg_match('/^[a-f0-9]{32}$/D', $favicon) || null === $this->media->find($favicon)) {
throw new InvalidPage('Das gewählte Symbolbild gibt es nicht (mehr).');
}
// Die Dateien entstehen hier und nicht bei jedem Seitenaufruf. Schlägt das fehl, wird
// die Einstellung gar nicht erst gespeichert - ein Verweis auf ein fehlendes Symbol
// wäre schlimmer als das alte Symbol.
try {
$this->media->renderFavicon($favicon);
} catch (\RuntimeException $error) {
throw new InvalidPage('Aus diesem Bild konnte kein Symbol erzeugt werden: '.$error->getMessage());
}
}
$values['faviconMediaId'] = $favicon;
foreach (['turnstileSiteKey' => 100, 'turnstileSecret' => 100] as $key => $max) {
$wert = trim((string) ($input[$key] ?? ''));
if ('' !== $wert && 1 !== preg_match('/^[A-Za-z0-9_:.-]{8,'.$max.'}$/D', $wert)) {
throw new InvalidPage('Die Turnstile-Schlüssel bestehen aus Buchstaben, Ziffern und den Zeichen _ : . - und sind mindestens acht Zeichen lang.');
}
$values[$key] = $wert;
}
$values['scrollTop'] = (bool) ($input['scrollTop'] ?? false);
$values['scrollTopPosition'] = in_array($input['scrollTopPosition'] ?? '', ['rechts', 'links'], true) ? $input['scrollTopPosition'] : 'rechts';
$values['scrollTopShape'] = in_array($input['scrollTopShape'] ?? '', ['rund', 'weich', 'eckig'], true) ? $input['scrollTopShape'] : 'rund';
$values['scrollTopSize'] = max(36, min(72, (int) ($input['scrollTopSize'] ?? 48)));
// Ab wie vielen Pixeln er erscheint. Null heisst: sofort.
$values['scrollTopAfter'] = max(0, min(4000, (int) ($input['scrollTopAfter'] ?? 400)));
$values['scrollTopLabel'] = mb_substr(trim((string) ($input['scrollTopLabel'] ?? '')), 0, 60) ?: 'Nach oben';
foreach (['scrollTopColor', 'scrollTopInk'] as $feld) {
$farbe = trim((string) ($input[$feld] ?? ''));
if ('' !== $farbe && 1 !== preg_match(StyleSchema::COLOR, $farbe)) {
throw new InvalidPage('Die Farben des Knopfes „nach oben" werden als #1a2b3c oder als @schlüssel aus der Palette angegeben.');
}
$values[$feld] = $farbe;
}
$values['turnstile'] = (bool) ($input['turnstile'] ?? false);
if ($values['turnstile'] && ('' === $values['turnstileSiteKey'] || '' === $values['turnstileSecret'])) {
// Eingeschaltet ohne Schluessel hiesse: jedes Formular lehnt jede Absendung ab.
throw new InvalidPage('Für die Turnstile-Prüfung werden beide Schlüssel gebraucht – der öffentliche und der geheime.');
}
$values['postMetaHeader'] = (bool) ($input['postMetaHeader'] ?? false);
$values['motion'] = in_array($input['motion'] ?? '', ['always', 'respect'], true) ? $input['motion'] : 'always';
// Only comment and simple directive lines; nothing that could smuggle in a second policy.
// Leerzeilen bleiben erhalten (hoechstens eine am Stueck): eine robots.txt mit eigenen
// Regeln je Bot besteht aus Bloecken, und ohne Trennung liest die niemand mehr. Das Limit
// liegt bei 200 Zeilen, weil allein die Liste der KI-Crawler laenger ist als dreissig.
$extra = [];
foreach (preg_split('/\R/', (string) ($input['robotsExtra'] ?? '')) ?: [] as $line) {
$line = trim($line);
if ('' === $line) {
if ([] !== $extra && '' !== end($extra)) {
$extra[] = '';
}
continue;
}
if (1 === preg_match('/^(#.{0,180}|(?:Disallow|Allow|Crawl-delay|User-agent):[ \t][^\s][^\r\n]{0,180})$/D', $line)) {
$extra[] = $line;
}
}
$values['robotsExtra'] = trim(implode("\n", array_slice($extra, 0, 200)));
$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.
$homeId = $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND path = ? AND deleted_at IS NULL', ['default', $home]);
if (false === $homeId) {
throw new InvalidPage(sprintf('Es gibt keine Seite mit der Adresse /%s.', $home));
}
if ($homeId === $values['notFoundPageId']) {
throw new InvalidPage('Die 404-Seite kann nicht gleichzeitig die Startseite sein.');
}
$values['homePath'] = $home;
$values['consentBanner'] = (bool) ($input['consentBanner'] ?? false);
$values['consentGroups'] = array_values(array_intersect(
array_keys(Consent::GROUPS),
array_map(static fn (mixed $group): string => (string) $group, (array) ($input['consentGroups'] ?? [])),
));
$values['consentText'] = mb_substr(trim((string) ($input['consentText'] ?? '')), 0, 600);
$values['consentLayout'] = in_array($input['consentLayout'] ?? '', ['karte', 'leiste', 'ecke'], true) ? $input['consentLayout'] : 'karte';
$values['consentShape'] = in_array($input['consentShape'] ?? '', ['weich', 'rund', 'eckig'], true) ? $input['consentShape'] : 'weich';
$values['consentTitle'] = mb_substr(trim((string) ($input['consentTitle'] ?? '')), 0, 80);
foreach (['consentColor', 'consentInk', 'consentAccent', 'consentAccentInk'] as $feld) {
$farbe = trim((string) ($input[$feld] ?? ''));
if ('' !== $farbe && 1 !== preg_match(StyleSchema::COLOR, $farbe)) {
throw new InvalidPage('Die Farben des Einwilligungshinweises werden als #1a2b3c oder als @schlüssel aus der Palette angegeben.');
}
$values[$feld] = $farbe;
}
foreach (['privacyPath', 'imprintPath'] as $key) {
$path = trim(trim((string) ($input[$key] ?? '')), '/');
if ('' !== $path && 1 !== preg_match('#^[a-z0-9]+(?:[-/][a-z0-9]+)*$#D', $path)) {
throw new InvalidPage('Die Adresse der Datenschutz- beziehungsweise Impressumsseite darf nur Kleinbuchstaben, Ziffern, Bindestriche und Schrägstriche enthalten.');
}
$values[$key] = $path;
}
// Privacy statement and imprint are not a feature that can be switched off: a German
// business website needs both, and both have to be reachable from every page. What the
// CMS cannot do is write them, so it only insists that the pages exist.
foreach (['privacyPath' => 'Datenschutzerklärung', 'imprintPath' => 'Impressum'] as $key => $label) {
if ('' !== $values[$key] && false === $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND path = ? AND deleted_at IS NULL', ['default', $values[$key]])) {
throw new InvalidPage(sprintf('Es gibt keine Seite mit der Adresse /%s für die %s.', $values[$key], $label));
}
}
if ($values['consentBanner']) {
if ([] === $values['consentGroups']) {
throw new InvalidPage('Ohne eine Gruppe gibt es nichts zu fragen. Wähle mindestens eine, oder schalte den Hinweis ab.');
}
// A consent banner without a way to read the details is not consent.
if ('' === $values['privacyPath']) {
throw new InvalidPage('Ein Einwilligungshinweis braucht die Adresse der Datenschutzseite.');
}
}
$this->loaded = null;
$this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'default',
]);
}
/** Change only the 404 assignment, preserving settings saved in other forms. */
public function assignNotFoundPage(string $id, string $expectedId): void
{
$this->db->transactional(function () use ($id, $expectedId): void {
$lock = $this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\SQLitePlatform ? '' : ' FOR UPDATE';
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?'.$lock, ['default']);
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
$stored = is_array($stored) ? $stored : [];
$current = $stored['notFoundPageId'] ?? '';
if ($current !== $expectedId) {
throw new StaleWrite('Die 404-Zuordnung wurde inzwischen geändert. Lade die Einstellungen neu.');
}
if ('' !== $id) {
$page = $this->db->fetchAssociative('SELECT path, type FROM page WHERE id = ? AND website_id = ? AND deleted_at IS NULL', [$id, 'default']);
if (false === $page || 'page' !== $page['type']) {
throw new InvalidPage('Bitte wähle eine vorhandene Seite als 404-Seite.');
}
if ($page['path'] === ($stored['homePath'] ?? self::DEFAULTS['homePath'])) {
throw new InvalidPage('Die Startseite kann nicht gleichzeitig die 404-Seite sein.');
}
}
if ($current === $id) {
return;
}
$stored['notFoundPageId'] = $id;
$changed = $this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
json_encode($stored, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'default',
]);
if (1 !== $changed) {
throw new StaleWrite('Die Einstellungen wurden inzwischen geändert. Lade die Seite neu.');
}
});
$this->loaded = null;
}
/**
* Which of the two mandatory pages are missing, by their settings key.
*
* @return array<string, string> key => label
*/
public function missingLegalPages(): array
{
$settings = $this->load();
$missing = [];
foreach (['privacyPath' => 'Datenschutzerklärung', 'imprintPath' => 'Impressum'] as $key => $label) {
if ('' === $settings[$key]) {
$missing[$key] = $label;
}
}
return $missing;
}
/** 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);
}
// 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;
}
// Tage mit denselben Zeiten werden zu einer Angabe: Mo-Fr 07:30-17:00 ist eine Zeile,
// nicht fünf. Das ist die Form, die Google für openingHoursSpecification dokumentiert.
$gruppen = [];
foreach ($settings['openingHours'] ?? [] as $eintrag) {
$schluessel = $eintrag['opens'].'-'.$eintrag['closes'];
$gruppen[$schluessel] ??= ['@type' => 'OpeningHoursSpecification', 'dayOfWeek' => [], 'opens' => $eintrag['opens'], 'closes' => $eintrag['closes']];
$gruppen[$schluessel]['dayOfWeek'][] = $eintrag['day'];
}
if ([] !== $gruppen) {
$data['openingHoursSpecification'] = array_values($gruppen);
}
if ([] !== ($settings['areaServed'] ?? [])) {
$data['areaServed'] = $settings['areaServed'];
}
return $data;
}
}