289 lines
8.7 KiB
PHP
289 lines
8.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\ContentFeed;
|
|
|
|
/**
|
|
* Builds the searchable Markdown body and the title of a feed record.
|
|
*
|
|
* Every record follows the same pattern: the name as heading, the short introductory
|
|
* fields as paragraphs, then each `text` field as a section under a German heading, in a
|
|
* fixed order. Structured facts that people ask about (WLAN, Sauna, Pistenkilometer) are
|
|
* rendered as a list as well, so the full-text search finds them.
|
|
*/
|
|
class DocumentBodyBuilder
|
|
{
|
|
/**
|
|
* Introductory fields, rendered without a heading. Plain text in the feed.
|
|
*/
|
|
private const LEAD_FIELDS = ['subline', 'headline'];
|
|
|
|
/**
|
|
* `text` fields per type, in output order, with their section heading. A null heading
|
|
* renders the field as an introduction straight under the title.
|
|
*/
|
|
private const TEXT_SECTIONS = [
|
|
'product' => [
|
|
'teaser' => null,
|
|
'teaserLong' => 'Beschreibung',
|
|
'feature' => 'Highlights',
|
|
'conceptDescription' => 'Reisekonzept',
|
|
'boardDescription' => 'Verpflegung',
|
|
'includedServices' => 'Leistungen',
|
|
'additionalServices' => 'Zusatzleistungen',
|
|
'programmeDescription' => 'Programm',
|
|
'skiPassDescription' => 'Skipass',
|
|
'nonBookableInfo' => 'Hinweise zur Buchbarkeit',
|
|
],
|
|
'hotel' => [
|
|
'teaser' => null,
|
|
'description' => 'Beschreibung',
|
|
'features' => 'Ausstattung',
|
|
'roomTypes' => 'Zimmer',
|
|
'additionalInformation' => 'Weitere Informationen',
|
|
],
|
|
'region' => [
|
|
'teaser' => null,
|
|
'description' => 'Beschreibung',
|
|
'feature' => 'Highlights',
|
|
'skiArea' => 'Skigebiet',
|
|
'skiAreaExtended' => 'Skigebiet im Detail',
|
|
'skiPass' => 'Skipass',
|
|
'news' => 'Neuigkeiten',
|
|
],
|
|
'country' => [
|
|
'teaser' => null,
|
|
'description' => 'Beschreibung',
|
|
'descriptionExtended' => 'Mehr erfahren',
|
|
],
|
|
'city' => [
|
|
'teaser' => null,
|
|
'description' => 'Beschreibung',
|
|
'descriptionExtended' => 'Mehr erfahren',
|
|
'party' => 'Party',
|
|
'leisure' => 'Freizeit',
|
|
],
|
|
'concept' => [
|
|
'teaser' => null,
|
|
'description' => 'Beschreibung',
|
|
'descriptionExtended' => 'Mehr erfahren',
|
|
'popupText' => 'Kurz erklärt',
|
|
],
|
|
];
|
|
|
|
/**
|
|
* Facts per type, with their German label.
|
|
*/
|
|
private const FACTS = [
|
|
'hotel' => [
|
|
'bedsCount' => 'Betten',
|
|
'parkingCount' => 'Parkplätze',
|
|
'altitude' => 'Höhenlage',
|
|
'distanceBus' => 'Entfernung zur Bushaltestelle',
|
|
'distanceLift' => 'Entfernung zum Lift',
|
|
'shower' => 'Dusche/WC',
|
|
'partyroom' => 'Partyraum',
|
|
'gamesroom' => 'Spieleraum',
|
|
'sauna' => 'Sauna',
|
|
'wifi' => 'WLAN',
|
|
],
|
|
'region' => [
|
|
'length' => 'Pistenkilometer',
|
|
'height' => 'Höhe',
|
|
'lifts' => 'Lifte',
|
|
],
|
|
];
|
|
|
|
public function __construct(private readonly HtmlToMarkdownConverter $converter)
|
|
{
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $record
|
|
*/
|
|
public function title(string $type, array $record): string
|
|
{
|
|
$title = 'news' === $type ? ($record['title'] ?? null) : ($record['name'] ?? null);
|
|
|
|
return is_string($title) ? trim($title) : '';
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $record
|
|
*/
|
|
public function build(string $type, array $record): string
|
|
{
|
|
$baseUrl = is_string($record['url'] ?? null) ? $record['url'] : null;
|
|
$blocks = [];
|
|
|
|
$title = $this->title($type, $record);
|
|
if ('' !== $title) {
|
|
$blocks[] = '# '.$title;
|
|
}
|
|
|
|
if ('news' === $type) {
|
|
return $this->join(array_merge($blocks, $this->newsBlocks($record, $baseUrl)));
|
|
}
|
|
|
|
foreach (self::LEAD_FIELDS as $field) {
|
|
$value = $this->plain($record[$field] ?? null);
|
|
if ('' !== $value && $value !== $title) {
|
|
$blocks[] = $value;
|
|
}
|
|
}
|
|
|
|
if ('hotel' === $type) {
|
|
$address = $this->plain($record['address'] ?? null);
|
|
if ('' !== $address) {
|
|
$blocks[] = 'Adresse: '.$address;
|
|
}
|
|
}
|
|
|
|
$text = is_array($record['text'] ?? null) ? $record['text'] : [];
|
|
foreach (self::TEXT_SECTIONS[$type] ?? [] as $field => $heading) {
|
|
$markdown = $this->converter->convert(is_string($text[$field] ?? null) ? $text[$field] : null, $baseUrl);
|
|
if ('' === $markdown) {
|
|
continue;
|
|
}
|
|
$blocks[] = null === $heading ? $markdown : "## {$heading}\n\n{$markdown}";
|
|
}
|
|
|
|
$facts = $this->facts($type, is_array($record['facts'] ?? null) ? $record['facts'] : []);
|
|
if ('' !== $facts) {
|
|
$blocks[] = "## Fakten\n\n".$facts;
|
|
}
|
|
|
|
if ('region' === $type && is_string($record['webcam'] ?? null) && '' !== trim($record['webcam'])) {
|
|
$blocks[] = 'Webcam: '.trim($record['webcam']);
|
|
}
|
|
|
|
if ('product' === $type) {
|
|
$faqs = $this->faqs(is_array($record['faqs'] ?? null) ? $record['faqs'] : [], $baseUrl);
|
|
if ('' !== $faqs) {
|
|
$blocks[] = "## Häufige Fragen\n\n".$faqs;
|
|
}
|
|
}
|
|
|
|
return $this->join($blocks);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $record
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
private function newsBlocks(array $record, ?string $baseUrl): array
|
|
{
|
|
$blocks = [];
|
|
|
|
if (is_string($record['datetime'] ?? null) && false !== ($date = date_create_immutable($record['datetime']))) {
|
|
$blocks[] = 'Veröffentlicht am '.$date->format('d.m.Y');
|
|
}
|
|
|
|
foreach (['teaser', 'bodytext'] as $field) {
|
|
$markdown = $this->converter->convert(is_string($record[$field] ?? null) ? $record[$field] : null, $baseUrl);
|
|
if ('' !== $markdown) {
|
|
$blocks[] = $markdown;
|
|
}
|
|
}
|
|
|
|
$tags = $this->labels($record['tags'] ?? null);
|
|
if ([] !== $tags) {
|
|
$blocks[] = 'Themen: '.implode(', ', $tags);
|
|
}
|
|
|
|
return $blocks;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $facts
|
|
*/
|
|
private function facts(string $type, array $facts): string
|
|
{
|
|
$lines = [];
|
|
|
|
foreach (self::FACTS[$type] ?? [] as $field => $label) {
|
|
$value = $facts[$field] ?? null;
|
|
|
|
$rendered = match (true) {
|
|
is_bool($value) => $value ? 'ja' : 'nein',
|
|
is_int($value), is_float($value) => (string) $value,
|
|
is_string($value) => $this->plain($value),
|
|
default => '',
|
|
};
|
|
|
|
if ('' !== $rendered) {
|
|
$lines[] = "- {$label}: {$rendered}";
|
|
}
|
|
}
|
|
|
|
return implode("\n", $lines);
|
|
}
|
|
|
|
/**
|
|
* @param array<mixed> $faqs
|
|
*/
|
|
private function faqs(array $faqs, ?string $baseUrl): string
|
|
{
|
|
$blocks = [];
|
|
|
|
foreach ($faqs as $faq) {
|
|
if (!is_array($faq)) {
|
|
continue;
|
|
}
|
|
|
|
$question = $this->plain($faq['question'] ?? null);
|
|
$answer = $this->converter->convert(is_string($faq['answer'] ?? null) ? $faq['answer'] : null, $baseUrl);
|
|
|
|
if ('' !== $question && '' !== $answer) {
|
|
$blocks[] = "### {$question}\n\n{$answer}";
|
|
}
|
|
}
|
|
|
|
return $this->join($blocks);
|
|
}
|
|
|
|
/**
|
|
* Tags and categories may arrive as strings or as objects with a title or name.
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
private function labels(mixed $values): array
|
|
{
|
|
if (!is_array($values)) {
|
|
return [];
|
|
}
|
|
|
|
$labels = [];
|
|
foreach ($values as $value) {
|
|
$label = is_array($value) ? ($value['title'] ?? $value['name'] ?? null) : $value;
|
|
if (is_string($label) && '' !== trim($label)) {
|
|
$labels[] = trim($label);
|
|
}
|
|
}
|
|
|
|
return $labels;
|
|
}
|
|
|
|
/**
|
|
* A field that is plain text in the feed, defensively stripped of any markup.
|
|
*/
|
|
private function plain(mixed $value): string
|
|
{
|
|
if (!is_string($value)) {
|
|
return '';
|
|
}
|
|
|
|
return trim((string) preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($value), \ENT_QUOTES | \ENT_HTML5, 'UTF-8')));
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $blocks
|
|
*/
|
|
private function join(array $blocks): string
|
|
{
|
|
return implode("\n\n", $blocks);
|
|
}
|
|
}
|