From afe91ad47d0329c0da15835f9f14c7af9df924db Mon Sep 17 00:00:00 2001 From: Thomas Skerbis Date: Sun, 6 Sep 2026 01:42:04 +0200 Subject: [PATCH] Dynamische Inhalte: SchemaHelper, strukturierte Mappings, FAQ- und Listen-Funktionen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SchemaHelper mit Helfern für Offer, PostalAddress, ContactPoint, GeoCoordinates, OpeningHoursSpecification, AggregateRating, Brand, Organization, Person, Place, Question/FAQPage und ItemList inkl. Normalisierung von Preisen, Verfügbarkeiten, Wochentagen und Uhrzeiten - Organization-/LocalBusiness-Teilobjekte im Generator auf SchemaHelper konsolidiert - Strukturierte Feld-Zuordnung ("nested"/"opening_hours") für dynamische URL-Profile in beiden Auflösungspfaden, Backend-UI mit Unterfeldern, Öffnungszeiten-Zeilen und verschachtelter Vorschau, serverseitige Bereinigung der Mappings beim Speichern - FAQPage aus der Typauswahl entfernt (Bestand bleibt mit Hinweis bearbeitbar); jsonld_render_faq(), jsonld_render_item_list() und jsonld_render_schema() als Template-Funktionen (DynamicContent) - README und CHANGELOG ergänzt Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 18 +- README.md | 71 +++ boot.php | 3 + lib/DynamicContent.php | 328 ++++++++++++++ lib/DynamicJsonLd.php | 20 + lib/JsonLdGenerator.php | 58 +-- lib/Mapping/DynamicFieldMapper.php | 416 +++++++++++++++++ lib/SchemaHelper.php | 538 ++++++++++++++++++++++ lib/template_functions.php | 69 +++ pages/dynamic_urls_edit.php | 687 +++++++++++++++++++++-------- 10 files changed, 1986 insertions(+), 222 deletions(-) create mode 100644 lib/DynamicContent.php create mode 100644 lib/Mapping/DynamicFieldMapper.php create mode 100644 lib/SchemaHelper.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d58863a..adaa1bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,22 @@ - # Changelog +## Unreleased + +### Added +- `SchemaHelper` (`FriendsOfRedaxo\JsonLdManager\SchemaHelper`): wiederverwendbare Helfer für valide Schema.org-Teilobjekte (`offer()`, `openingHoursSpecification()`, `postalAddress()`, `contactPoint()`, `geoCoordinates()`, `aggregateRating()`, `brand()`, `organization()`, `person()`, `place()`, `question()`, `faqPage()`, `itemList()`), inkl. Normalisierung von Preisen (`12,50 €` → `12.50`), Verfügbarkeiten (`InStock`, `ja`/`nein`, `1`/`0` → Schema.org-URL), Wochentagen (`Mo`, `Montag` → `Monday`) und Uhrzeiten. +- Strukturierte Feld-Zuordnung für dynamische URL-Profile: `offers`, `brand`, `aggregateRating`, `address`, `contactPoint`, `location`, `organizer`, `provider`, `author` und `openingHoursSpecification` können in der Backend-UI per „Strukturiert“ aus mehreren YForm-Feldern zu einem gültigen Teilobjekt (`Offer`, `PostalAddress`, `Place`, `OpeningHoursSpecification`, …) zusammengesetzt werden. Neue Mapping-Formate `{"type":"nested","fields":{…}}` und `{"type":"opening_hours","rows":[…]}` (`Mapping\DynamicFieldMapper`); die Vorschau zeigt das verschachtelte Ergebnis. +- Template-Funktionen für mehrzeilige Inhalte: `jsonld_render_faq()` fasst alle passenden YForm-Zeilen zu einem `FAQPage`-Schema mit `mainEntity`-Array zusammen, `jsonld_render_item_list()` erzeugt `ItemList`/`CollectionPage` für Übersichtsseiten, `jsonld_render_schema()` rendert ein beliebiges Schema-Array als Script-Tag inkl. Debug-Overlay (`DynamicContent`). +- Weitere Properties in der dynamischen Zuordnung: `Product.aggregateRating`/`url`, `LocalBusiness.openingHoursSpecification`/`url`/`priceRange`, `Event.offers`/`url`, `Service.offers`. + +### Changed +- `FAQPage` wird für dynamische URL-Profile nicht mehr angeboten, da pro Datensatz nur ein Schema-Objekt entsteht; bestehende Zuordnungen bleiben mit Warnhinweis bearbeitbar. Für FAQ-Seiten ist `jsonld_render_faq()` vorgesehen. +- Feld-Mappings werden beim Speichern serverseitig auf bekannte Formate, gültige Property- und Spaltennamen reduziert. +- `PostalAddress`, `ContactPoint` und `GeoCoordinates` in Organization- und LocalBusiness-Schemas werden über `SchemaHelper` gebaut (Konsolidierung); `GeoCoordinates` werden nur noch ausgegeben, wenn Breiten- und Längengrad numerisch und ungleich 0 sind. +- Beschriftungen im Dynamische-URLs-Editor präzisiert (z. B. „Angebot (Preis, Währung, Verfügbarkeit)“ statt „Preis/Angebot“). + +### Fixed +- Die Live-Vorschau im Dynamische-URLs-Editor zeigt jetzt tatsächlich die Werte des ersten Datensatzes; zuvor blieben gemappte Felder wegen einer falschen Datenstruktur immer leer. + ## v1.0.13 (26. August 2026) ### Fixed diff --git a/README.md b/README.md index 6330606..bf0f287 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,74 @@ Dort lassen sich JSON-LD-Zuordnungen für dynamische Inhalte konfigurieren, zum Das ist optional und nicht nötig, um die normale JSON-LD-Ausgabe für Website und Artikel zu verwenden. +#### Strukturierte Teilobjekte (Angebot, Adresse, Öffnungszeiten, …) + +Einige Schema-Properties verlangen kein einfaches Textfeld, sondern ein eigenes Objekt, zum Beispiel `Product.offers` (`Offer` mit Preis, Währung und Verfügbarkeit) oder `LocalBusiness.address` (`PostalAddress`). Für diese Properties bietet das Feld-Mapping neben „Feld“ und „Statischer Wert“ die Option **Strukturiert**: Die Unterfelder werden einzeln aus YForm-Spalten oder festen Werten befüllt, das AddOn baut daraus ein valides Teilobjekt. + +Unterstützt werden: + +| Property | Objekt | Unterfelder | +| --- | --- | --- | +| `offers` | `Offer` | Preis, Währung, Verfügbarkeit, Preis gültig bis, URL | +| `brand` | `Brand` | Name | +| `aggregateRating` | `AggregateRating` | Bewertung, Anzahl, beste Bewertung | +| `address` | `PostalAddress` | Straße, PLZ, Ort, Region, Land | +| `contactPoint` | `ContactPoint` | Telefon, E-Mail, Kontaktart | +| `location` | `Place` | Name plus Adressfelder | +| `organizer`, `provider` | `Organization` | Name, URL | +| `author` | `Person` | Name, URL | +| `openingHoursSpecification` | `OpeningHoursSpecification[]` | je Zeile Wochentage, Öffnet, Schließt | + +Werte werden dabei normalisiert: Preise wie `12,50 €` werden zu `12.50`, Verfügbarkeiten akzeptieren `InStock`/`OutOfStock` ebenso wie Ja/Nein- oder 1/0-Felder, Wochentage dürfen als `Mo`, `Montag` oder `Monday` vorliegen. + +Gespeichert wird das als `{"type":"nested","fields":{"price":{"type":"field","value":"preis"},"priceCurrency":{"type":"static","value":"EUR"}}}` bzw. `{"type":"opening_hours","rows":[{"days":["Monday","Friday"],"opens":{"type":"field","value":"mo_von"},"closes":{"type":"field","value":"mo_bis"}}]}` neben den bisherigen flachen Mappings. + +#### FAQ-Seiten und Übersichtslisten + +Eine URL-Profil-Zuordnung erzeugt pro Datensatz genau ein Schema-Objekt. `FAQPage` (alle Frage/Antwort-Paare in einem `mainEntity`-Array) und `ItemList`/`CollectionPage` (mehrere Einträge einer Übersichtsseite) lassen sich damit nicht abbilden. Dafür gibt es Template-Funktionen, die mehrere YForm-Zeilen zusammenfassen und direkt im Template oder Modul ausgegeben werden: + +```php +// FAQPage aus einer YForm-Tabelle (alle aktiven Zeilen, sortiert nach prio) +echo jsonld_render_faq('rex_faq', 'frage', 'antwort', ['status' => 1], [ + 'order_by' => 'prio ASC', + 'name' => 'Häufige Fragen', +]); + +// ItemList für eine Produktübersicht, Detail-URLs über das URL-AddOn (Namespace "produkt") +echo jsonld_render_item_list('rex_produkte', 'Product', ['status' => 1, 'kategorie_id' => 3], [ + 'name' => 'titel', + 'image' => 'bild', + 'offers' => ['type' => 'nested', 'fields' => [ + 'price' => ['type' => 'field', 'value' => 'preis'], + 'priceCurrency' => ['type' => 'static', 'value' => 'EUR'], + ]], + 'sku' => static fn (array $row): string => 'P-' . $row['id'], +], [ + 'url_namespace' => 'produkt', + 'order_by' => 'titel ASC', + 'list_type' => 'CollectionPage', +]); +``` + +Filter sind einfache Spalte-Wert-Paare (Arrays ergeben `IN (...)`, `null` ergibt `IS NULL`); Tabellen-, Spalten- und Sortierangaben werden validiert. Feld-Zuordnungen akzeptieren Spaltennamen, Callables (`fn(array $row)`) oder die strukturierten Mapping-Formate von oben. Im Debug-Modus erscheinen die Schemas im Debug-Overlay. + +#### PHP-Helfer für eigene Schemas + +`FriendsOfRedaxo\JsonLdManager\SchemaHelper` stellt statische Methoden für valide Teilobjekte bereit, die sich in eigenen Templates und Modulen verwenden lassen: `offer()`, `openingHoursSpecification()`, `postalAddress()`, `contactPoint()`, `geoCoordinates()`, `aggregateRating()`, `brand()`, `organization()`, `person()`, `place()`, `question()`, `faqPage()`, `itemList()` sowie `withType()` für beliebige Typen. Leere Werte werden entfernt; ein Objekt ohne Inhalt liefert ein leeres Array. Ein fertiges Schema-Array gibt `jsonld_render_schema($schema)` als `' . "\n"; + + if (function_exists('jsonld_is_debug_enabled') && jsonld_is_debug_enabled() && function_exists('jsonld_render_debug_overlay_script')) { + $type = $schema['@type'] ?? 'Schema'; + $meta = array_merge([ + 'article_id' => 0, + 'clang_id' => (int) rex_clang::getCurrentId(), + 'branch_id' => null, + 'types' => [is_string($type) ? $type : 'Schema'], + ], $meta); + $html .= jsonld_render_debug_overlay_script($payload, $meta); + } + + return $html; + } + + /** + * Lädt Zeilen einer YForm-Tabelle mit Prepared-Statement-Filter. + * + * @param array $filter + * @param array $options + * @return array> + */ + public static function fetchRows(string $tableName, array $filter = [], array $options = []): array + { + self::assertIdentifier($tableName, 'Tabellenname'); + + $where = []; + $params = []; + foreach ($filter as $column => $value) { + self::assertIdentifier($column, 'Filter-Spalte'); + $quoted = '`' . $column . '`'; + + if ($value === null) { + $where[] = $quoted . ' IS NULL'; + continue; + } + if (is_array($value)) { + $scalars = array_values(array_filter($value, static fn ($v) => is_scalar($v))); + if (count($scalars) === 0) { + $where[] = '1 = 0'; + continue; + } + $where[] = $quoted . ' IN (' . implode(', ', array_fill(0, count($scalars), '?')) . ')'; + foreach ($scalars as $scalar) { + $params[] = is_bool($scalar) ? (int) $scalar : $scalar; + } + continue; + } + if (!is_scalar($value)) { + throw new InvalidArgumentException('Ungültiger Filterwert für Spalte ' . $column); + } + $where[] = $quoted . ' = ?'; + $params[] = is_bool($value) ? (int) $value : $value; + } + + $query = 'SELECT * FROM `' . $tableName . '`'; + if (count($where) > 0) { + $query .= ' WHERE ' . implode(' AND ', $where); + } + + $orderBy = $options['order_by'] ?? null; + if (is_string($orderBy) && trim($orderBy) !== '') { + $query .= ' ORDER BY ' . self::sanitizeOrderBy($orderBy); + } + + $limit = $options['limit'] ?? null; + if (is_int($limit) && $limit > 0) { + $query .= ' LIMIT ' . $limit; + } + + /** @var array> $rows */ + $rows = rex_sql::factory()->getArray($query, $params); + + return $rows; + } + + /** + * @param array $row + * @param array $fieldMappings + * @return array + */ + private static function buildItem(string $schemaType, array $row, array $fieldMappings): array + { + $resolveLeaf = static function (array $leaf) use ($row): mixed { + if ($leaf['type'] === 'static') { + return $leaf['value']; + } + if ($leaf['type'] === 'field' && is_string($leaf['value']) && isset($row[$leaf['value']])) { + return $row[$leaf['value']]; + } + + return null; + }; + + $fields = []; + foreach ($fieldMappings as $property => $mapping) { + if (preg_match('/^[A-Za-z@][A-Za-z0-9]*$/', $property) !== 1) { + continue; + } + + $value = null; + if (is_string($mapping)) { + $value = $row[$mapping] ?? null; + } elseif (is_callable($mapping)) { + $value = $mapping($row); + } elseif (DynamicFieldMapper::isStructuredMapping($mapping)) { + $value = DynamicFieldMapper::resolveStructured($property, $mapping, $resolveLeaf); + } elseif (is_array($mapping) && isset($mapping['type'], $mapping['value'])) { + $value = $resolveLeaf($mapping); + } + + if ($value === null || (is_string($value) && trim($value) === '') || (is_array($value) && count($value) === 0)) { + continue; + } + + if (in_array($property, ['image', 'photo', 'logo'], true) && is_string($value)) { + $value = self::mediaUrl($value); + } + + $fields[$property] = $value; + } + + return array_merge(['@type' => $schemaType], $fields); + } + + /** + * @param array $row + * @param array $options + */ + private static function resolveItemUrl(array $row, array $options): ?string + { + $callback = $options['url_callback'] ?? null; + if (is_callable($callback)) { + $url = $callback($row); + + return is_string($url) && $url !== '' ? $url : null; + } + + $namespace = $options['url_namespace'] ?? null; + if (is_string($namespace) && $namespace !== '' && isset($row['id']) && rex_addon::get('url')->isAvailable()) { + $url = rex_getUrl('', '', [$namespace => (int) $row['id']]); + if ($url !== '' && !str_starts_with($url, 'http')) { + $url = rtrim(DomainConfig::getBaseUrl(), '/') . '/' . ltrim($url, '/'); + } + + return $url !== '' ? $url : null; + } + + return null; + } + + private static function mediaUrl(string $value): string + { + if (str_starts_with($value, 'http://') || str_starts_with($value, 'https://') || str_starts_with($value, '/')) { + return $value; + } + // Nur den ersten Dateinamen einer Medialiste verwenden + $first = trim(explode(',', $value)[0]); + if ($first === '') { + return ''; + } + if (rex_addon::get('yrewrite')->isAvailable() && class_exists('rex_yrewrite')) { + return rex_yrewrite::getFullPath('/media/' . $first); + } + + return rex_url::frontend('media/' . $first); + } + + /** + * @param array $options + */ + private static function normalizeAnswerText(string $answer, array $options): string + { + if (($options['strip_tags'] ?? true) === true) { + $answer = html_entity_decode(strip_tags($answer), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $answer = preg_replace('/\s+/u', ' ', $answer) ?? $answer; + } + + return trim($answer); + } + + private static function currentLanguageCode(): string + { + $clang = rex_clang::getCurrent(); + + return $clang->getCode(); + } + + private static function sanitizeOrderBy(string $orderBy): string + { + $parts = []; + foreach (explode(',', $orderBy) as $part) { + $part = trim($part); + if (preg_match('/^([A-Za-z0-9_]+)(?:\s+(ASC|DESC))?$/i', $part, $m) !== 1) { + throw new InvalidArgumentException('Ungültige ORDER BY-Angabe: ' . $part); + } + $parts[] = '`' . $m[1] . '`' . (isset($m[2]) ? ' ' . strtoupper($m[2]) : ''); + } + + return implode(', ', $parts); + } + + private static function assertIdentifier(string $value, string $label): void + { + if (preg_match(self::IDENTIFIER_PATTERN, $value) !== 1) { + throw new InvalidArgumentException('Ungültiger ' . $label . ': ' . $value); + } + } +} diff --git a/lib/DynamicJsonLd.php b/lib/DynamicJsonLd.php index 5b51d6e..1d5bde9 100644 --- a/lib/DynamicJsonLd.php +++ b/lib/DynamicJsonLd.php @@ -99,8 +99,28 @@ function generateDynamicJsonLd(int|string $profileId, int|string $dataId): strin $schema['url'] = $schema['@id']; } + // Flache Einzelwerte für strukturierte Mappings (Offer, PostalAddress, Öffnungszeiten, …) auflösen + $resolveLeaf = static function (array $leaf) use ($dataRow): mixed { + if ($leaf['type'] === 'static') { + return $leaf['value']; + } + if ($leaf['type'] === 'field' && is_string($leaf['value']) && isset($dataRow[$leaf['value']])) { + return $dataRow[$leaf['value']]; + } + + return null; + }; + // Felder mappen - KORREKTE AUFLÖSUNG DER MAPPING-OBJEKTE foreach ($fieldMappings as $schemaProperty => $fieldMapping) { + if (Mapping\DynamicFieldMapper::isStructuredMapping($fieldMapping)) { + $structured = Mapping\DynamicFieldMapper::resolveStructured((string) $schemaProperty, $fieldMapping, $resolveLeaf); + if ($structured !== null) { + $schema[$schemaProperty] = $structured; + } + continue; + } + if (is_array($fieldMapping)) { // Neues Mapping-Format: {"type":"field","value":"title"} oder {"type":"static","value":"..."} if (isset($fieldMapping['type']) && isset($fieldMapping['value'])) { diff --git a/lib/JsonLdGenerator.php b/lib/JsonLdGenerator.php index e3dc067..f6b03d1 100644 --- a/lib/JsonLdGenerator.php +++ b/lib/JsonLdGenerator.php @@ -420,21 +420,15 @@ public static function generateForArticle(int $articleId, int|array|string|null } if (!empty($organizationConfig['address']) && is_array($organizationConfig['address'])) { - $address = self::pruneEmptyValues(array_merge( - ['@type' => 'PostalAddress'], - $organizationConfig['address'] - )); - if (count($address) > 1) { + $address = SchemaHelper::postalAddress($organizationConfig['address']); + if (count($address) > 0) { $organizationSchema['address'] = $address; } } - + if (!empty($organizationConfig['contactPoint']) && is_array($organizationConfig['contactPoint'])) { - $contactPoint = self::pruneEmptyValues(array_merge( - ['@type' => 'ContactPoint'], - $organizationConfig['contactPoint'] - )); - if (count($contactPoint) > 1) { + $contactPoint = SchemaHelper::contactPoint($organizationConfig['contactPoint']); + if (count($contactPoint) > 0) { $organizationSchema['contactPoint'] = $contactPoint; } } @@ -1115,29 +1109,21 @@ private static function buildLocalBusinessSchema(array $localBusinessConfig, ?in } if (!empty($localBusinessConfig['contactPoint']) && is_array($localBusinessConfig['contactPoint'])) { - $contactPoint = self::pruneEmptyValues(array_merge( - ['@type' => 'ContactPoint'], - $localBusinessConfig['contactPoint'] - )); + $contactPoint = SchemaHelper::contactPoint($localBusinessConfig['contactPoint']); - if (count($contactPoint) > 1) { + if (count($contactPoint) > 0) { $localBusinessSchema['contactPoint'] = $contactPoint; } } if (!empty($localBusinessConfig['geo']) && is_array($localBusinessConfig['geo'])) { $geo = $localBusinessConfig['geo']; - $latitude = trim($geo['latitude'] ?? ''); - $longitude = trim($geo['longitude'] ?? ''); - if ($latitude !== '' && $longitude !== '' && $latitude !== '0' && $longitude !== '0') { - $localBusinessSchema['geo'] = array_merge( - ['@type' => 'GeoCoordinates'], - $geo, - [ - 'latitude' => $latitude, - 'longitude' => $longitude, - ] - ); + $latitude = trim((string) ($geo['latitude'] ?? '')); + $longitude = trim((string) ($geo['longitude'] ?? '')); + unset($geo['latitude'], $geo['longitude']); + $geoSchema = SchemaHelper::geoCoordinates($latitude, $longitude, $geo); + if (count($geoSchema) > 0) { + $localBusinessSchema['geo'] = $geoSchema; } } @@ -1170,7 +1156,7 @@ private static function buildLocalBusinessSchema(array $localBusinessConfig, ?in if (!empty($localBusinessConfig['address']) && is_array($localBusinessConfig['address'])) { $localBusinessSchema['address'] = $localBusinessConfig['address']; } elseif (!empty($address)) { - $localBusinessSchema['address'] = array_merge(['@type' => 'PostalAddress'], $address); + $localBusinessSchema['address'] = SchemaHelper::postalAddress($address); } if (!empty($localBusinessConfig['custom_jsonld']) && is_array($localBusinessConfig['custom_jsonld'])) { @@ -1404,8 +1390,24 @@ private static function generateDynamicSchema(array $dynamicMapping, int $articl ]); } + $resolveLeaf = static function (array $leaf) use ($dataset, $yformTableName, $articleId, $clangId, $isDebugMode): mixed { + return self::resolveMappingValue($leaf, $dataset, $yformTableName, $articleId, $clangId, $isDebugMode); + }; + // Feld-Mappings auflösen foreach ($fieldMappings as $property => $mapping) { + if (!is_array($mapping)) { + continue; + } + + if (Mapping\DynamicFieldMapper::isStructuredMapping($mapping)) { + $structured = Mapping\DynamicFieldMapper::resolveStructured((string) $property, $mapping, $resolveLeaf); + if ($structured !== null) { + $schema[$property] = $structured; + } + continue; + } + $resolvedValue = self::resolveMappingValue($mapping, $dataset, $yformTableName, $articleId, $clangId, $isDebugMode); if ($resolvedValue !== null && $resolvedValue !== '') { diff --git a/lib/Mapping/DynamicFieldMapper.php b/lib/Mapping/DynamicFieldMapper.php new file mode 100644 index 0000000..6ad4bfb --- /dev/null +++ b/lib/Mapping/DynamicFieldMapper.php @@ -0,0 +1,416 @@ +}> + */ + public static function getNestedPropertyDefinitions(): array + { + $addressFields = [ + 'streetAddress' => 'Straße und Hausnummer', + 'postalCode' => 'PLZ', + 'addressLocality' => 'Ort', + 'addressRegion' => 'Region/Bundesland', + 'addressCountry' => 'Land (ISO-Code, z. B. DE)', + ]; + $organizationFields = [ + 'name' => 'Name', + 'url' => 'Website-URL', + ]; + + return [ + 'offers' => [ + 'type' => 'Offer', + 'label' => 'Angebot', + 'fields' => [ + 'price' => 'Preis (z. B. 12,50)', + 'priceCurrency' => 'Währung (ISO-Code, z. B. EUR)', + 'availability' => 'Verfügbarkeit (InStock/OutOfStock oder Ja/Nein-Feld)', + 'priceValidUntil' => 'Preis gültig bis (YYYY-MM-DD)', + 'url' => 'Angebots-URL', + ], + ], + 'brand' => [ + 'type' => 'Brand', + 'label' => 'Marke', + 'fields' => [ + 'name' => 'Markenname', + ], + ], + 'aggregateRating' => [ + 'type' => 'AggregateRating', + 'label' => 'Bewertung', + 'fields' => [ + 'ratingValue' => 'Durchschnittsbewertung (z. B. 4.5)', + 'reviewCount' => 'Anzahl Bewertungen', + 'bestRating' => 'Beste mögliche Bewertung (Standard 5)', + ], + ], + 'address' => [ + 'type' => 'PostalAddress', + 'label' => 'Adresse', + 'fields' => $addressFields, + ], + 'contactPoint' => [ + 'type' => 'ContactPoint', + 'label' => 'Kontakt', + 'fields' => [ + 'telephone' => 'Telefon', + 'email' => 'E-Mail', + 'contactType' => 'Kontaktart (z. B. customer service)', + ], + ], + 'location' => [ + 'type' => 'Place', + 'label' => 'Veranstaltungsort', + 'fields' => array_merge(['name' => 'Name des Ortes'], $addressFields), + ], + 'organizer' => [ + 'type' => 'Organization', + 'label' => 'Veranstalter', + 'fields' => $organizationFields, + ], + 'provider' => [ + 'type' => 'Organization', + 'label' => 'Anbieter', + 'fields' => $organizationFields, + ], + 'author' => [ + 'type' => 'Person', + 'label' => 'Autor', + 'fields' => [ + 'name' => 'Name', + 'url' => 'Profil-URL', + ], + ], + 'openingHoursSpecification' => [ + 'type' => 'OpeningHoursSpecification', + 'label' => 'Öffnungszeiten', + 'fields' => [ + 'opens' => 'Öffnet (HH:MM)', + 'closes' => 'Schließt (HH:MM)', + ], + ], + ]; + } + + /** + * @param mixed $mapping + */ + public static function isStructuredMapping(mixed $mapping): bool + { + if (!is_array($mapping) || !isset($mapping['type']) || !is_string($mapping['type'])) { + return false; + } + + return in_array($mapping['type'], [self::TYPE_NESTED, self::TYPE_OPENING_HOURS], true); + } + + /** + * Löst ein strukturiertes Mapping zu einem Schema.org-Teilobjekt auf. + * + * @param array $mapping + * @param callable(array): mixed $resolveLeaf Löst ein flaches {"type","value"}-Mapping gegen den Datensatz auf + * @return array|null null, wenn nichts Sinnvolles entsteht + */ + public static function resolveStructured(string $property, array $mapping, callable $resolveLeaf): ?array + { + $type = $mapping['type'] ?? null; + + if ($type === self::TYPE_OPENING_HOURS) { + return self::resolveOpeningHours($mapping, $resolveLeaf); + } + + if ($type !== self::TYPE_NESTED) { + return null; + } + + $definition = self::getNestedPropertyDefinitions()[$property] ?? null; + $objectType = $definition['type'] ?? null; + if ($objectType === null) { + $custom = $mapping['object_type'] ?? null; + if (is_string($custom) && preg_match('/^[A-Za-z][A-Za-z0-9]*$/', $custom) === 1) { + $objectType = $custom; + } + } + if ($objectType === null) { + return null; + } + + $fields = self::resolveLeafFields($mapping['fields'] ?? [], $resolveLeaf); + if (count($fields) === 0) { + return null; + } + + $result = self::buildObject($objectType, $fields); + + return count($result) > 0 ? $result : null; + } + + /** + * @param array $fields Bereits aufgelöste Werte je Schema-Property + * @return array + */ + public static function buildObject(string $objectType, array $fields): array + { + switch ($objectType) { + case 'Offer': + $price = $fields['price'] ?? null; + $currency = $fields['priceCurrency'] ?? 'EUR'; + $availability = $fields['availability'] ?? null; + $url = $fields['url'] ?? null; + unset($fields['price'], $fields['priceCurrency'], $fields['availability'], $fields['url']); + + return SchemaHelper::offer( + is_scalar($price) ? (is_bool($price) ? (int) $price : $price) : null, + is_scalar($currency) ? (string) $currency : 'EUR', + is_scalar($availability) ? (string) $availability : null, + is_scalar($url) ? (string) $url : null, + $fields + ); + + case 'PostalAddress': + return SchemaHelper::postalAddress($fields); + + case 'ContactPoint': + return SchemaHelper::contactPoint($fields); + + case 'Brand': + $name = $fields['name'] ?? null; + + return SchemaHelper::brand(is_scalar($name) ? (string) $name : null); + + case 'AggregateRating': + $ratingValue = $fields['ratingValue'] ?? null; + $reviewCount = $fields['reviewCount'] ?? null; + $bestRating = $fields['bestRating'] ?? 5; + $worstRating = $fields['worstRating'] ?? null; + + return SchemaHelper::aggregateRating( + is_scalar($ratingValue) && !is_bool($ratingValue) ? $ratingValue : null, + is_scalar($reviewCount) && !is_bool($reviewCount) ? (string) $reviewCount : null, + is_scalar($bestRating) && !is_bool($bestRating) && is_numeric($bestRating) ? $bestRating : 5, + is_scalar($worstRating) && !is_bool($worstRating) && is_numeric($worstRating) ? $worstRating : null + ); + + case 'Organization': + case 'Person': + $name = $fields['name'] ?? null; + $url = $fields['url'] ?? null; + unset($fields['name'], $fields['url']); + $nameString = is_scalar($name) ? (string) $name : null; + $urlString = is_scalar($url) ? (string) $url : null; + + return $objectType === 'Person' + ? SchemaHelper::person($nameString, $urlString, $fields) + : SchemaHelper::organization($nameString, $urlString, $fields); + + case 'Place': + $name = $fields['name'] ?? null; + unset($fields['name']); + $addressKeys = ['streetAddress', 'postalCode', 'addressLocality', 'addressRegion', 'addressCountry', 'postOfficeBoxNumber']; + $addressFields = array_intersect_key($fields, array_flip($addressKeys)); + $extra = array_diff_key($fields, array_flip($addressKeys)); + + return SchemaHelper::place(is_scalar($name) ? (string) $name : null, $addressFields, $extra); + + default: + return SchemaHelper::withType($objectType, $fields); + } + } + + /** + * @param array $mapping + * @param callable(array): mixed $resolveLeaf + * @return array>|null + */ + private static function resolveOpeningHours(array $mapping, callable $resolveLeaf): ?array + { + $rows = $mapping['rows'] ?? []; + if (!is_array($rows)) { + return null; + } + + $resolvedRows = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $days = $row['days'] ?? $row['dayOfWeek'] ?? []; + $leafValues = self::resolveLeafFields([ + 'opens' => $row['opens'] ?? null, + 'closes' => $row['closes'] ?? null, + ], $resolveLeaf); + + $resolvedRows[] = [ + 'dayOfWeek' => is_array($days) || is_string($days) ? $days : '', + 'opens' => $leafValues['opens'] ?? null, + 'closes' => $leafValues['closes'] ?? null, + ]; + } + + $spec = SchemaHelper::openingHoursSpecification($resolvedRows); + + return count($spec) > 0 ? $spec : null; + } + + /** + * @param mixed $fieldMappings + * @param callable(array): mixed $resolveLeaf + * @return array Nur nicht-leere, skalare Werte + */ + private static function resolveLeafFields(mixed $fieldMappings, callable $resolveLeaf): array + { + if (!is_array($fieldMappings)) { + return []; + } + + $resolved = []; + foreach ($fieldMappings as $subProperty => $leaf) { + if (!is_string($subProperty) || preg_match('/^[A-Za-z][A-Za-z0-9]*$/', $subProperty) !== 1) { + continue; + } + if (!is_array($leaf) || !isset($leaf['type'], $leaf['value'])) { + continue; + } + $value = $resolveLeaf($leaf); + if ($value === null || (is_string($value) && trim($value) === '')) { + continue; + } + if (!is_scalar($value)) { + continue; + } + $resolved[$subProperty] = is_string($value) ? trim($value) : $value; + } + + return $resolved; + } + + /** + * Bereinigt Mappings aus dem Backend-Formular: nur bekannte Formate, + * gültige Property- und Spaltennamen. Unbekanntes wird verworfen. + * + * @param mixed $mappings + * @return array> + */ + public static function sanitizeMappings(mixed $mappings): array + { + if (!is_array($mappings)) { + return []; + } + + $clean = []; + foreach ($mappings as $property => $mapping) { + if (!is_string($property) || preg_match('/^[A-Za-z][A-Za-z0-9]*$/', $property) !== 1) { + continue; + } + if (!is_array($mapping) || !isset($mapping['type']) || !is_string($mapping['type'])) { + continue; + } + + switch ($mapping['type']) { + case 'field': + case 'static': + $leaf = self::sanitizeLeaf($mapping); + if ($leaf !== null) { + $clean[$property] = $leaf; + } + break; + + case self::TYPE_NESTED: + $fields = []; + foreach (is_array($mapping['fields'] ?? null) ? $mapping['fields'] : [] as $subProperty => $leaf) { + if (!is_string($subProperty) || preg_match('/^[A-Za-z][A-Za-z0-9]*$/', $subProperty) !== 1) { + continue; + } + $cleanLeaf = is_array($leaf) ? self::sanitizeLeaf($leaf) : null; + if ($cleanLeaf !== null) { + $fields[$subProperty] = $cleanLeaf; + } + } + if (count($fields) > 0) { + $nested = ['type' => self::TYPE_NESTED, 'fields' => $fields]; + $objectType = $mapping['object_type'] ?? null; + if (is_string($objectType) && preg_match('/^[A-Za-z][A-Za-z0-9]*$/', $objectType) === 1) { + $nested['object_type'] = $objectType; + } + $clean[$property] = $nested; + } + break; + + case self::TYPE_OPENING_HOURS: + $rows = []; + foreach (is_array($mapping['rows'] ?? null) ? $mapping['rows'] : [] as $row) { + if (!is_array($row)) { + continue; + } + $days = SchemaHelper::normalizeDayOfWeek(is_array($row['days'] ?? null) || is_string($row['days'] ?? null) ? $row['days'] : null); + $opens = is_array($row['opens'] ?? null) ? self::sanitizeLeaf($row['opens']) : null; + $closes = is_array($row['closes'] ?? null) ? self::sanitizeLeaf($row['closes']) : null; + if (count($days) === 0 || ($opens === null && $closes === null)) { + continue; + } + $cleanRow = ['days' => $days]; + if ($opens !== null) { + $cleanRow['opens'] = $opens; + } + if ($closes !== null) { + $cleanRow['closes'] = $closes; + } + $rows[] = $cleanRow; + } + if (count($rows) > 0) { + $clean[$property] = ['type' => self::TYPE_OPENING_HOURS, 'rows' => $rows]; + } + break; + } + } + + return $clean; + } + + /** + * @param array $leaf + * @return array{type: string, value: string}|null + */ + private static function sanitizeLeaf(array $leaf): ?array + { + $type = $leaf['type'] ?? null; + $value = $leaf['value'] ?? null; + if (!is_string($type) || !is_scalar($value)) { + return null; + } + $value = (string) $value; + + if ($type === 'field') { + return preg_match('/^[A-Za-z0-9_]+$/', $value) === 1 ? ['type' => 'field', 'value' => $value] : null; + } + if ($type === 'static') { + return trim($value) !== '' ? ['type' => 'static', 'value' => $value] : null; + } + + return null; + } +} diff --git a/lib/SchemaHelper.php b/lib/SchemaHelper.php new file mode 100644 index 0000000..4f3f636 --- /dev/null +++ b/lib/SchemaHelper.php @@ -0,0 +1,538 @@ + */ + public const AVAILABILITY_VALUES = [ + 'InStock', + 'OutOfStock', + 'PreOrder', + 'PreSale', + 'BackOrder', + 'Discontinued', + 'SoldOut', + 'LimitedAvailability', + 'OnlineOnly', + 'InStoreOnly', + ]; + + /** @var array */ + public const DAYS_OF_WEEK = [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ]; + + /** @var array */ + private const DAY_ALIASES = [ + 'mo' => 'Monday', 'mon' => 'Monday', 'montag' => 'Monday', 'monday' => 'Monday', + 'di' => 'Tuesday', 'tue' => 'Tuesday', 'dienstag' => 'Tuesday', 'tuesday' => 'Tuesday', + 'mi' => 'Wednesday', 'wed' => 'Wednesday', 'mittwoch' => 'Wednesday', 'wednesday' => 'Wednesday', + 'do' => 'Thursday', 'thu' => 'Thursday', 'donnerstag' => 'Thursday', 'thursday' => 'Thursday', + 'fr' => 'Friday', 'fri' => 'Friday', 'freitag' => 'Friday', 'friday' => 'Friday', + 'sa' => 'Saturday', 'sat' => 'Saturday', 'samstag' => 'Saturday', 'sonnabend' => 'Saturday', 'saturday' => 'Saturday', + 'so' => 'Sunday', 'sun' => 'Sunday', 'sonntag' => 'Sunday', 'sunday' => 'Sunday', + 'feiertag' => 'PublicHolidays', 'feiertage' => 'PublicHolidays', 'publicholidays' => 'PublicHolidays', + ]; + + /** @var array */ + private const ADDRESS_ALIASES = [ + 'street' => 'streetAddress', + 'strasse' => 'streetAddress', + 'straße' => 'streetAddress', + 'zip' => 'postalCode', + 'plz' => 'postalCode', + 'postal_code' => 'postalCode', + 'city' => 'addressLocality', + 'ort' => 'addressLocality', + 'stadt' => 'addressLocality', + 'region' => 'addressRegion', + 'bundesland' => 'addressRegion', + 'country' => 'addressCountry', + 'land' => 'addressCountry', + ]; + + /** + * Generischer Aufbau eines typisierten Objekts. Leere Werte werden entfernt. + * + * @param array $fields + * @return array + */ + public static function withType(string $type, array $fields): array + { + $object = JsonLdGenerator::pruneEmptyValues(array_merge(['@type' => $type], $fields)); + if (!is_array($object) || count($object) <= 1) { + return []; + } + + return $object; + } + + /** + * @param array $extra Weitere Offer-Properties (z. B. priceValidUntil, itemCondition) + * @return array + */ + public static function offer(string|float|int|null $price, string $currency = 'EUR', ?string $availability = null, ?string $url = null, array $extra = []): array + { + $normalizedPrice = self::normalizePrice($price); + if ($normalizedPrice === null) { + return []; + } + + $fields = [ + 'price' => $normalizedPrice, + 'priceCurrency' => strtoupper(trim($currency)) !== '' ? strtoupper(trim($currency)) : 'EUR', + 'availability' => self::normalizeAvailability($availability), + 'url' => $url, + ]; + + return self::withType('Offer', array_merge($extra, $fields)); + } + + /** + * Normalisiert Preisangaben wie "12,50", "1.234,56 €" oder 12.5 auf "12.50". + */ + public static function normalizePrice(string|float|int|null $price): ?string + { + if ($price === null) { + return null; + } + if (is_int($price) || is_float($price)) { + return number_format((float) $price, 2, '.', ''); + } + + $raw = trim($price); + if ($raw === '') { + return null; + } + + $cleaned = preg_replace('/[^0-9,.\-]/', '', $raw) ?? ''; + if ($cleaned === '' || $cleaned === '-') { + return null; + } + + $lastComma = strrpos($cleaned, ','); + $lastDot = strrpos($cleaned, '.'); + if ($lastComma !== false && ($lastDot === false || $lastComma > $lastDot)) { + // Dezimaltrennzeichen ist das Komma, Punkte sind Tausendertrenner + $cleaned = str_replace('.', '', $cleaned); + $cleaned = str_replace(',', '.', $cleaned); + } else { + $cleaned = str_replace(',', '', $cleaned); + } + + if (!is_numeric($cleaned)) { + return null; + } + + return number_format((float) $cleaned, 2, '.', ''); + } + + /** + * Akzeptiert "InStock", "https://schema.org/InStock", "in_stock" sowie + * boolesche Werte ("1"/"0", "ja"/"nein", true/false) und liefert die + * vollständige Schema.org-URL oder null bei unbekannten Werten. + */ + public static function normalizeAvailability(string|bool|int|null $value): ?string + { + if ($value === null) { + return null; + } + if (is_bool($value) || is_int($value)) { + return 'https://schema.org/' . ((bool) $value ? 'InStock' : 'OutOfStock'); + } + + $raw = trim($value); + if ($raw === '') { + return null; + } + + if (preg_match('#^https?://schema\.org/(\w+)$#i', $raw, $m)) { + $raw = $m[1]; + } + + $normalized = strtolower(str_replace(['_', '-', ' '], '', $raw)); + foreach (self::AVAILABILITY_VALUES as $candidate) { + if (strtolower($candidate) === $normalized) { + return 'https://schema.org/' . $candidate; + } + } + + if (in_array($normalized, ['1', 'true', 'ja', 'yes', 'y', 'verfuegbar', 'verfügbar', 'lieferbar', 'available'], true)) { + return 'https://schema.org/InStock'; + } + if (in_array($normalized, ['0', 'false', 'nein', 'no', 'n', 'nichtverfuegbar', 'nichtverfügbar', 'ausverkauft', 'unavailable'], true)) { + return 'https://schema.org/OutOfStock'; + } + + return null; + } + + /** + * @param array $rows Liste von ['dayOfWeek' => string|array, 'opens' => 'HH:MM', 'closes' => 'HH:MM'] + * @return array> + */ + public static function openingHoursSpecification(array $rows): array + { + $result = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + + $days = self::normalizeDayOfWeek($row['dayOfWeek'] ?? $row['day_of_week'] ?? ''); + $opens = self::normalizeTime($row['opens'] ?? null); + $closes = self::normalizeTime($row['closes'] ?? null); + + if (count($days) === 0 || ($opens === null && $closes === null)) { + continue; + } + + $entry = [ + '@type' => 'OpeningHoursSpecification', + 'dayOfWeek' => count($days) === 1 ? $days[0] : $days, + 'opens' => $opens, + 'closes' => $closes, + ]; + foreach (['validFrom', 'validThrough'] as $optional) { + if (isset($row[$optional]) && is_string($row[$optional]) && trim($row[$optional]) !== '') { + $entry[$optional] = trim($row[$optional]); + } + } + + $pruned = JsonLdGenerator::pruneEmptyValues($entry); + if (is_array($pruned)) { + $result[] = $pruned; + } + } + + return $result; + } + + /** + * Normalisiert Wochentagsangaben ("Mo", "Montag", "Monday", "Mo,Di" oder Arrays) + * auf die englischen Schema.org-Bezeichner. + * + * @param string|array|null $days + * @return array + */ + public static function normalizeDayOfWeek(string|array|null $days): array + { + if ($days === null) { + return []; + } + if (is_string($days)) { + $days = explode(',', $days); + } + + $result = []; + foreach ($days as $day) { + if (!is_string($day)) { + continue; + } + $day = trim($day); + if ($day === '') { + continue; + } + if (preg_match('#^https?://schema\.org/(\w+)$#i', $day, $m)) { + $day = $m[1]; + } + $key = mb_strtolower(str_replace(['.', ' '], '', $day)); + $normalized = self::DAY_ALIASES[$key] ?? null; + if ($normalized === null) { + continue; + } + if (!in_array($normalized, $result, true)) { + $result[] = $normalized; + } + } + + return $result; + } + + /** + * Liefert "HH:MM" oder null, wenn der Wert keine Uhrzeit ist. + */ + public static function normalizeTime(mixed $time): ?string + { + if (!is_string($time) && !is_int($time)) { + return null; + } + $time = trim((string) $time); + if (preg_match('/^([01]?\d|2[0-4])[:.]([0-5]\d)(?::[0-5]\d)?$/', $time, $m)) { + return sprintf('%02d:%02d', (int) $m[1], (int) $m[2]); + } + if (preg_match('/^([01]?\d|2[0-4])$/', $time, $m)) { + return sprintf('%02d:00', (int) $m[1]); + } + + return null; + } + + /** + * Baut eine PostalAddress. Neben den Schema.org-Namen werden gängige + * Aliase (street, zip, city, country, …) akzeptiert. + * + * @param array $fields + * @return array + */ + public static function postalAddress(array $fields): array + { + $mapped = []; + foreach ($fields as $key => $value) { + $property = self::ADDRESS_ALIASES[strtolower($key)] ?? $key; + if ($property === '@type') { + continue; + } + // Explizite Schema.org-Namen haben Vorrang vor Alias-Werten + if (isset($mapped[$property]) && $property !== $key) { + continue; + } + $mapped[$property] = $value; + } + + return self::withType('PostalAddress', $mapped); + } + + /** + * @param array $fields z. B. telephone, email, contactType, areaServed, availableLanguage + * @return array + */ + public static function contactPoint(array $fields): array + { + unset($fields['@type']); + + return self::withType('ContactPoint', $fields); + } + + /** + * @param array $extra Weitere GeoCoordinates-Properties (z. B. elevation) + * @return array + */ + public static function geoCoordinates(string|float|int|null $latitude, string|float|int|null $longitude, array $extra = []): array + { + $latitude = is_string($latitude) ? trim($latitude) : $latitude; + $longitude = is_string($longitude) ? trim($longitude) : $longitude; + if ($latitude === null || $longitude === null || $latitude === '' || $longitude === '') { + return []; + } + if (!is_numeric($latitude) || !is_numeric($longitude) || (float) $latitude === 0.0 || (float) $longitude === 0.0) { + return []; + } + unset($extra['@type']); + + return array_merge( + ['@type' => 'GeoCoordinates'], + $extra, + [ + 'latitude' => $latitude, + 'longitude' => $longitude, + ] + ); + } + + /** + * @return array + */ + public static function aggregateRating(string|float|int|null $ratingValue, string|int|null $reviewCount, string|float|int $bestRating = 5, string|float|int|null $worstRating = null): array + { + $ratingValue = is_string($ratingValue) ? str_replace(',', '.', trim($ratingValue)) : $ratingValue; + $reviewCount = is_string($reviewCount) ? trim($reviewCount) : $reviewCount; + + if ($ratingValue === null || $ratingValue === '' || !is_numeric($ratingValue)) { + return []; + } + if ($reviewCount === null || $reviewCount === '' || !is_numeric($reviewCount) || (int) $reviewCount <= 0) { + return []; + } + + $fields = [ + 'ratingValue' => (float) $ratingValue, + 'reviewCount' => (int) $reviewCount, + 'bestRating' => is_numeric($bestRating) ? (float) $bestRating : 5.0, + ]; + if ($worstRating !== null && is_numeric($worstRating)) { + $fields['worstRating'] = (float) $worstRating; + } + + return self::withType('AggregateRating', $fields); + } + + /** + * @return array + */ + public static function brand(?string $name): array + { + return self::withType('Brand', ['name' => $name]); + } + + /** + * @param array $extra + * @return array + */ + public static function organization(?string $name, ?string $url = null, array $extra = []): array + { + unset($extra['@type']); + + return self::withType('Organization', array_merge($extra, ['name' => $name, 'url' => $url])); + } + + /** + * @param array $extra + * @return array + */ + public static function person(?string $name, ?string $url = null, array $extra = []): array + { + unset($extra['@type']); + + return self::withType('Person', array_merge($extra, ['name' => $name, 'url' => $url])); + } + + /** + * @param array $addressFields Felder für postalAddress() + * @param array $extra Weitere Place-Properties (z. B. url, telephone, geo) + * @return array + */ + public static function place(?string $name, array $addressFields = [], array $extra = []): array + { + unset($extra['@type']); + $fields = array_merge($extra, ['name' => $name]); + $address = self::postalAddress($addressFields); + if (count($address) > 0) { + $fields['address'] = $address; + } + + return self::withType('Place', $fields); + } + + /** + * @return array + */ + public static function question(?string $question, ?string $answer): array + { + $question = trim((string) $question); + $answer = trim((string) $answer); + if ($question === '' || $answer === '') { + return []; + } + + return [ + '@type' => 'Question', + 'name' => $question, + 'acceptedAnswer' => [ + '@type' => 'Answer', + 'text' => $answer, + ], + ]; + } + + /** + * @param array $pairs Liste von ['question' => ..., 'answer' => ...] + * @param array $extra Weitere FAQPage-Properties (name, description, url, …) + * @return array Leeres Array, wenn keine gültigen Paare vorhanden sind + */ + public static function faqPage(array $pairs, array $extra = []): array + { + $questions = []; + foreach ($pairs as $pair) { + if (!is_array($pair)) { + continue; + } + $questionText = $pair['question'] ?? $pair['name'] ?? null; + $answerText = $pair['answer'] ?? $pair['text'] ?? null; + $question = self::question(is_scalar($questionText) ? (string) $questionText : null, is_scalar($answerText) ? (string) $answerText : null); + if (count($question) > 0) { + $questions[] = $question; + } + } + if (count($questions) === 0) { + return []; + } + + unset($extra['@type'], $extra['@context'], $extra['mainEntity']); + $pruned = JsonLdGenerator::pruneEmptyValues($extra); + + return array_merge( + ['@context' => 'https://schema.org', '@type' => 'FAQPage'], + is_array($pruned) ? $pruned : [], + ['mainEntity' => $questions] + ); + } + + /** + * @param array|string $item Vollständiges Objekt oder URL + * @return array + */ + public static function listItem(int $position, array|string $item): array + { + $listItem = [ + '@type' => 'ListItem', + 'position' => $position, + ]; + if (is_string($item)) { + $listItem['url'] = $item; + } else { + $listItem['item'] = $item; + } + + return $listItem; + } + + /** + * @param array|string> $items Objekte oder URLs in Listenreihenfolge + * @param array $extra Weitere Properties (name, description, url, numberOfItems, itemListOrder) + * @param string $type "ItemList" oder "CollectionPage" + * @return array Leeres Array, wenn keine Einträge vorhanden sind + */ + public static function itemList(array $items, array $extra = [], string $type = 'ItemList'): array + { + $elements = []; + $position = 1; + foreach ($items as $item) { + if (is_array($item) && count($item) === 0) { + continue; + } + if (is_string($item) && trim($item) === '') { + continue; + } + $elements[] = self::listItem($position, $item); + ++$position; + } + if (count($elements) === 0) { + return []; + } + + unset($extra['@type'], $extra['@context'], $extra['itemListElement'], $extra['mainEntity']); + $pruned = JsonLdGenerator::pruneEmptyValues($extra); + $schema = array_merge( + ['@context' => 'https://schema.org', '@type' => $type], + is_array($pruned) ? $pruned : [] + ); + + if ($type === 'CollectionPage') { + $schema['mainEntity'] = [ + '@type' => 'ItemList', + 'numberOfItems' => count($elements), + 'itemListElement' => $elements, + ]; + } else { + $schema['numberOfItems'] = count($elements); + $schema['itemListElement'] = $elements; + } + + return $schema; + } +} diff --git a/lib/template_functions.php b/lib/template_functions.php index 5826618..b5b64bb 100644 --- a/lib/template_functions.php +++ b/lib/template_functions.php @@ -334,3 +334,72 @@ function jsonld_render(?string $schemaType = null, array $additionalData = []): return ''; } } + +if (!function_exists('jsonld_render_schema')) { + /** + * Rendert ein beliebiges Schema-Array (z. B. aus SchemaHelper) als JSON-LD-Script. + * + * @param array $schema + * @param array $meta Zusätzliche Debug-Informationen + */ + function jsonld_render_schema(array $schema, array $meta = []): string + { + return \FriendsOfRedaxo\JsonLdManager\DynamicContent::renderScript($schema, $meta); + } +} + +if (!function_exists('jsonld_render_faq')) { + /** + * Fasst alle passenden Zeilen einer YForm-Tabelle zu einem FAQPage-Schema zusammen. + * + * Beispiel: echo jsonld_render_faq('rex_faq', 'frage', 'antwort', ['status' => 1], ['order_by' => 'prio ASC']); + * + * @param array $filter Spalte => Wert + * @param array $options order_by, limit, name, description, url, strip_tags + */ + function jsonld_render_faq(string $tableName, string $questionField, string $answerField, array $filter = [], array $options = []): string + { + try { + $schema = \FriendsOfRedaxo\JsonLdManager\DynamicContent::faqPage($tableName, $questionField, $answerField, $filter, $options); + + return \FriendsOfRedaxo\JsonLdManager\DynamicContent::renderScript($schema, ['source_table' => $tableName]); + } catch (Throwable $e) { + if (rex::isDebugMode()) { + return '' . "\n"; + } + } + + return ''; + } +} + +if (!function_exists('jsonld_render_item_list')) { + /** + * Fasst YForm-Zeilen (z. B. alle Produkte einer Kategorie) zu einem ItemList-/CollectionPage-Schema zusammen. + * + * Beispiel: + * echo jsonld_render_item_list('rex_produkte', 'Product', ['status' => 1, 'kategorie_id' => 3], [ + * 'name' => 'titel', + * 'image' => 'bild', + * 'offers' => ['type' => 'nested', 'fields' => ['price' => ['type' => 'field', 'value' => 'preis']]], + * ], ['url_namespace' => 'produkt', 'order_by' => 'titel ASC']); + * + * @param array $filter Spalte => Wert + * @param array $fieldMappings Schema-Property => Spaltenname | callable(array $row) | strukturiertes Mapping + * @param array $options list_type, order_by, limit, name, description, url, url_namespace, url_callback + */ + function jsonld_render_item_list(string $tableName, string $schemaType, array $filter = [], array $fieldMappings = [], array $options = []): string + { + try { + $schema = \FriendsOfRedaxo\JsonLdManager\DynamicContent::itemList($tableName, $schemaType, $filter, $fieldMappings, $options); + + return \FriendsOfRedaxo\JsonLdManager\DynamicContent::renderScript($schema, ['source_table' => $tableName]); + } catch (Throwable $e) { + if (rex::isDebugMode()) { + return '' . "\n"; + } + } + + return ''; + } +} diff --git a/pages/dynamic_urls_edit.php b/pages/dynamic_urls_edit.php index ad1e445..97da487 100644 --- a/pages/dynamic_urls_edit.php +++ b/pages/dynamic_urls_edit.php @@ -56,11 +56,19 @@ $active = rex_post('active', 'int', 1); $fieldMappings = rex_post('field_mappings', 'string', '{}'); - // JSON validieren + // JSON validieren und auf bekannte Mapping-Formate reduzieren $mappingsArray = json_decode($fieldMappings, true); - if (json_last_error() !== JSON_ERROR_NONE) { + if (json_last_error() !== JSON_ERROR_NONE || !is_array($mappingsArray)) { $messages[] = rex_view::error('Ungültiges JSON in den Feld-Mappings.'); + } elseif (preg_match('/^[A-Za-z][A-Za-z0-9]*$/', $schemaType) !== 1) { + $messages[] = rex_view::error('Ungültiger Schema-Typ.'); } else { + $mappingsArray = \FriendsOfRedaxo\JsonLdManager\Mapping\DynamicFieldMapper::sanitizeMappings($mappingsArray); + $fieldMappings = json_encode($mappingsArray, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if (!is_string($fieldMappings)) { + $fieldMappings = '{}'; + } + // Mapping speichern/aktualisieren $sql = rex_sql::factory(); if (count($mapping) > 0) { @@ -177,10 +185,12 @@ } // Schema-Properties Definition +// Properties, die in DynamicFieldMapper::getNestedPropertyDefinitions() hinterlegt sind, +// lassen sich zusätzlich "strukturiert" aus mehreren Feldern zusammensetzen. $schemaProperties = [ 'Article' => [ 'headline' => 'Überschrift/Titel', - 'description' => 'Beschreibung', + 'description' => 'Beschreibung', 'author' => 'Autor', 'datePublished' => 'Veröffentlichungsdatum', 'dateModified' => 'Änderungsdatum', @@ -190,7 +200,7 @@ 'BlogPosting' => [ 'headline' => 'Blog-Titel', 'description' => 'Blog-Beschreibung', - 'author' => 'Blog-Autor', + 'author' => 'Blog-Autor', 'datePublished' => 'Veröffentlichungsdatum', 'image' => 'Beitragsbild', 'wordCount' => 'Wortanzahl' @@ -202,11 +212,6 @@ 'datePublished' => 'Publikationsdatum', 'image' => 'News-Bild' ], - 'FAQPage' => [ - 'mainEntity' => 'FAQ-Einträge (Array)', - 'name' => 'FAQ-Titel', - 'description' => 'FAQ-Beschreibung' - ], 'Person' => [ 'name' => 'Vollständiger Name', 'givenName' => 'Vorname', @@ -222,16 +227,19 @@ 'description' => 'Firmenbeschreibung', 'url' => 'Website-URL', 'logo' => 'Firmenlogo', - 'contactPoint' => 'Kontaktinformationen', - 'address' => 'Adresse' + 'contactPoint' => 'Kontaktinformationen (Telefon, E-Mail)', + 'address' => 'Adresse (Straße, PLZ, Ort, Land)' ], 'LocalBusiness' => [ 'name' => 'Geschäftsname', 'description' => 'Geschäftsbeschreibung', - 'address' => 'Geschäftsadresse', + 'address' => 'Geschäftsadresse (Straße, PLZ, Ort, Land)', 'telephone' => 'Telefonnummer', 'email' => 'E-Mail', - 'openingHours' => 'Öffnungszeiten', + 'url' => 'Website-URL', + 'priceRange' => 'Preisspanne (z. B. €€)', + 'openingHoursSpecification' => 'Öffnungszeiten (strukturiert, je Wochentag)', + 'openingHours' => 'Öffnungszeiten (Freitext, z. B. "Mo-Fr 09:00-18:00")', 'image' => 'Geschäftsbild' ], 'Product' => [ @@ -240,29 +248,34 @@ 'image' => 'Produktbild', 'sku' => 'Artikelnummer', 'brand' => 'Marke', - 'offers' => 'Preis/Angebot', - 'category' => 'Produktkategorie' + 'offers' => 'Angebot (Preis, Währung, Verfügbarkeit)', + 'aggregateRating' => 'Bewertung (Durchschnitt, Anzahl)', + 'category' => 'Produktkategorie', + 'url' => 'Produkt-URL' ], 'Service' => [ 'name' => 'Service-Name', 'description' => 'Service-Beschreibung', - 'provider' => 'Anbieter', + 'provider' => 'Anbieter (Name, URL)', 'serviceType' => 'Service-Typ', - 'areaServed' => 'Servicegebiet' + 'areaServed' => 'Servicegebiet', + 'offers' => 'Angebot (Preis, Währung, Verfügbarkeit)' ], 'Event' => [ 'name' => 'Event-Name', 'description' => 'Event-Beschreibung', - 'startDate' => 'Startdatum', - 'endDate' => 'Enddatum', - 'location' => 'Veranstaltungsort', - 'organizer' => 'Veranstalter', - 'image' => 'Event-Bild' + 'startDate' => 'Startdatum (ISO 8601)', + 'endDate' => 'Enddatum (ISO 8601)', + 'location' => 'Veranstaltungsort (Name, Adresse)', + 'organizer' => 'Veranstalter (Name, URL)', + 'offers' => 'Tickets/Angebot (Preis, Währung, Verfügbarkeit)', + 'image' => 'Event-Bild', + 'url' => 'Event-URL' ], 'Course' => [ 'name' => 'Kurs-Name', 'description' => 'Kurs-Beschreibung', - 'provider' => 'Kursanbieter', + 'provider' => 'Kursanbieter (Name, URL)', 'courseMode' => 'Kursart (online/offline)', 'educationalLevel' => 'Bildungsebene' ], @@ -277,6 +290,19 @@ ] ]; +// FAQPage wird bewusst nicht mehr angeboten: Ein URL-Profil-Datensatz ergibt genau ein +// Schema-Objekt, eine FAQ-Seite braucht aber mehrere Frage/Antwort-Paare (siehe jsonld_render_faq()). +// Bestehende Zuordnungen bleiben bearbeitbar. +$legacySchemaProperties = [ + 'FAQPage' => [ + 'mainEntity' => 'FAQ-Einträge (Array)', + 'name' => 'FAQ-Titel', + 'description' => 'FAQ-Beschreibung' + ], +]; + +$nestedDefinitions = \FriendsOfRedaxo\JsonLdManager\Mapping\DynamicFieldMapper::getNestedPropertyDefinitions(); + // Config laden falls vorhanden $config = [ 'schema_type' => '', @@ -295,6 +321,11 @@ ]; } +$isLegacySchemaType = isset($legacySchemaProperties[$config['schema_type']]); +if ($isLegacySchemaType) { + $schemaProperties[$config['schema_type']] = $legacySchemaProperties[$config['schema_type']]; +} + // Sample-Daten für JavaScript (ersten Datensatz verwenden) $sampleData = !empty($sampleData) ? $sampleData[0] : []; @@ -306,6 +337,14 @@ echo $message; } +if ($isLegacySchemaType) { + echo rex_view::warning( + 'Der Schema-Typ ' . rex_escape($config['schema_type']) . ' lässt sich über die URL-Profil-Zuordnung nicht vollständig abbilden: ' + . 'Pro Datensatz entsteht nur ein einzelnes Schema-Objekt, eine FAQ-Seite benötigt aber alle Frage/Antwort-Paare in einem mainEntity-Array. ' + . 'Verwenden Sie dafür im Template jsonld_render_faq(\'' . rex_escape($yformTableName) . '\', \'frage_feld\', \'antwort_feld\').' + ); +} + $backUrl = rex_url::currentBackendPage(['func' => '', 'profile_id' => '']); echo '
'; @@ -322,52 +361,37 @@ echo ' '; echo ' '; +$schemaTypeGroups = [ + 'Artikel & Content' => ['Article', 'BlogPosting', 'NewsArticle'], + 'Personen & Unternehmen' => ['Organization', 'Person'], + 'Geschäfte & Services' => ['LocalBusiness', 'Service'], + 'Produkte & Angebote' => ['Product'], + 'Events & Kurse' => ['Course', 'Event'], + 'Sonstige' => ['Animal'], +]; +if ($isLegacySchemaType) { + $schemaTypeGroups['Nicht empfohlen'] = [$config['schema_type']]; +} + echo '
'; echo ' '; echo ' '; +echo '

FAQ-Seiten und Übersichtslisten werden nicht pro Datensatz zugeordnet, sondern im Template über jsonld_render_faq() bzw. jsonld_render_item_list() erzeugt.

'; echo '
'; echo ' '; @@ -402,231 +426,508 @@ $fragment->setVar('body', $content, false); echo $fragment->parse('core/page/section.php'); -$schemaPropertiesJson = json_encode($schemaProperties, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); -$tableFieldsJson = json_encode(array_column($tableFields, 'Field'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); -$sampleDataJson = json_encode($sampleData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); -$profileNamespaceJson = json_encode((string) ($profile['namespace'] ?? ''), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); -$savedMappingsJson = json_encode($config['field_mappings'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +$jsonFlags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT; +$schemaPropertiesJson = json_encode($schemaProperties, $jsonFlags); +$nestedDefinitionsJson = json_encode($nestedDefinitions, $jsonFlags); +$tableFieldsJson = json_encode(array_column($tableFields, 'Field'), $jsonFlags); +$sampleDataJson = json_encode($sampleData, $jsonFlags); +$profileNamespaceJson = json_encode((string) ($profile['namespace'] ?? ''), $jsonFlags); +$savedMappingsJson = json_encode($config['field_mappings'], $jsonFlags); if (!is_string($schemaPropertiesJson)) { $schemaPropertiesJson = '{}'; } +if (!is_string($nestedDefinitionsJson)) { + $nestedDefinitionsJson = '{}'; +} if (!is_string($tableFieldsJson)) { $tableFieldsJson = '[]'; } -if (!is_string($sampleDataJson)) { +if (!is_string($sampleDataJson) || $sampleDataJson === '[]') { $sampleDataJson = '{}'; } if (!is_string($profileNamespaceJson)) { $profileNamespaceJson = '""'; } -if (!is_string($savedMappingsJson)) { +if (!is_string($savedMappingsJson) || $savedMappingsJson === '[]') { $savedMappingsJson = '{}'; } ?> +