Put opening hours, service area and FAQ into the structured data
Settings gain opening hours per weekday and a list of places served; LocalBusiness emits openingHoursSpecification (equal times grouped) and areaServed. An accordion can be marked as FAQ (schema v23 property `faq`), which turns its entries into a FAQPage built from the visible title and text blocks. Elektro Bernhard gets the real office hours (Mo–Fr 07:30–17:00), the service area from Karlsruhe to Offenburg and two more questions. ADR 094. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
3cb5b03dd9
commit
5f7ea63b76
12 files changed
+244
-9
No files matched your search
@@ -251,6 +251,11 @@ final class DocumentValidator
|
||||
if ('spacer' === $type) {
|
||||
$this->require(in_array($props['size'] ?? null, self::SPACER_SIZES, true), 'Unknown spacer size.');
|
||||
}
|
||||
if ('accordion' === $type && array_key_exists('faq', $props)) {
|
||||
// Ein Akkordeon ist nicht automatisch eine FAQ; erst die Kennzeichnung macht aus den
|
||||
// Einträgen Frage-Antwort-Paare in den strukturierten Daten.
|
||||
$this->require($schemaVersion >= 23 && is_bool($props['faq']), 'Die FAQ-Kennzeichnung ist an oder aus und braucht Schema v23.');
|
||||
}
|
||||
if ('accordionitem' === $type) {
|
||||
$this->text($props['title'] ?? null, 160);
|
||||
$this->require(is_bool($props['open'] ?? false), 'The open state is a boolean.');
|
||||
|
||||
@@ -27,6 +27,10 @@ final class SiteSettings
|
||||
// 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
|
||||
@@ -75,10 +79,74 @@ final class SiteSettings
|
||||
'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.
|
||||
*
|
||||
@@ -137,6 +205,14 @@ final class SiteSettings
|
||||
}
|
||||
$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.');
|
||||
@@ -363,6 +439,20 @@ final class SiteSettings
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -257,6 +257,29 @@ final class PageController extends AbstractController
|
||||
*
|
||||
* @return list<array{name: string, href: string}>
|
||||
*/
|
||||
/** @return list<array<string, mixed>> */
|
||||
private function faqEntries(array $blocks): array
|
||||
{
|
||||
$entries = [];
|
||||
foreach ($blocks as $block) {
|
||||
if ('accordion' === ($block['type'] ?? null) && true === ($block['props']['faq'] ?? false)) {
|
||||
foreach ($block['children'] ?? [] as $item) {
|
||||
$texts = array_filter($item['children'] ?? [], static fn (array $child): bool => 'text' === ($child['type'] ?? null));
|
||||
$answer = trim(implode("\n\n", array_map(
|
||||
static fn (array $child): string => trim(html_entity_decode(strip_tags(preg_replace('#<br\s*/?>|</p>#i', "\n", (string) ($child['props']['text'] ?? '')) ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8')),
|
||||
$texts,
|
||||
)));
|
||||
if ('' !== $answer && '' !== trim((string) ($item['props']['title'] ?? ''))) {
|
||||
$entries[] = ['@type' => 'Question', 'name' => $item['props']['title'], 'acceptedAnswer' => ['@type' => 'Answer', 'text' => $answer]];
|
||||
}
|
||||
}
|
||||
}
|
||||
$entries = [...$entries, ...$this->faqEntries($block['children'] ?? [])];
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
private function breadcrumbs(array $page): array
|
||||
{
|
||||
if ($page['path'] === $this->settings->homePath()) {
|
||||
@@ -382,6 +405,12 @@ final class PageController extends AbstractController
|
||||
}
|
||||
$blocks[] = $article;
|
||||
}
|
||||
// Ein als FAQ gekennzeichnetes Akkordeon wird zur FAQPage: die Titel sind die Fragen, die
|
||||
// Textbausteine im Eintrag die Antwort. Nur was auf der Seite steht, steht auch hier.
|
||||
$fragen = $this->faqEntries($page['document']['blocks'] ?? []);
|
||||
if ([] !== $fragen) {
|
||||
$blocks[] = ['@context' => 'https://schema.org', '@type' => 'FAQPage', 'mainEntity' => $fragen];
|
||||
}
|
||||
$trail = $this->breadcrumbs($page);
|
||||
if (count($trail) > 1) {
|
||||
$blocks[] = [
|
||||
|
||||
@@ -49,6 +49,7 @@ final class SettingsController extends AbstractController
|
||||
// Fuer die Farbfelder: dieselbe Palette, die auch der Editor anbietet.
|
||||
'palette' => $this->theme->load()['colors'],
|
||||
'socialNetworks' => SocialProfiles::NETWORKS,
|
||||
'weekdays' => SiteSettings::DAYS,
|
||||
'socialPlaceholders' => SocialProfiles::PLACEHOLDERS,
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user