<?php
declare(strict_types=1);
namespace MeinWeimar;
use WP_Post;
final class Seo
{
public static function hooks(): void
{
add_filter('rank_math/frontend/title', [self::class, 'rank_math_title']);
add_filter('rank_math/frontend/description', [self::class, 'rank_math_description']);
add_filter('rank_math/json_ld', [self::class, 'rank_math_schema'], 20, 2);
add_action('wp_head', [self::class, 'fallback_head'], 20);
}
public static function rank_math_title(string $title): string
{
$post = self::queried_content();
if (!$post) {
return $title;
}
$custom = trim((string) get_post_meta($post->ID, 'rank_math_title', true));
return $custom ?: self::generated_title($post);
}
public static function rank_math_description(string $description): string
{
$post = self::queried_content();
if (!$post) {
return $description;
}
$custom = trim((string) get_post_meta($post->ID, 'rank_math_description', true));
return $custom ?: self::generated_description($post);
}
public static function rank_math_schema(array $data, $jsonld): array
{
$post = self::queried_content();
if (!$post) {
return $data;
}
$schema = self::schema_for($post);
if ($schema) {
$data['meinweimar'] = $schema;
}
return $data;
}
public static function fallback_head(): void
{
if (defined('RANK_MATH_VERSION') || !self::queried_content()) {
return;
}
$post = self::queried_content();
$title = self::generated_title($post);
$description = self::generated_description($post);
$url = get_permalink($post);
$image = get_the_post_thumbnail_url($post, 'full');
$locale = self::schema_locale($post);
if ($description) {
echo '<meta name="description" content="' . esc_attr($description) . '">' . "\n";
}
if ($url) {
echo '<link rel="canonical" href="' . esc_url($url) . '">' . "\n";
echo '<meta property="og:url" content="' . esc_url($url) . '">' . "\n";
}
echo '<meta property="og:title" content="' . esc_attr($title) . '">' . "\n";
if ($description) {
echo '<meta property="og:description" content="' . esc_attr($description) . '">' . "\n";
}
echo '<meta property="og:type" content="' . esc_attr(in_array($post->post_type, ['mw_news', 'mw_guide'], true) ? 'article' : 'website') . '">' . "\n";
echo '<meta property="og:locale" content="' . esc_attr($locale) . '">' . "\n";
if ($image) {
echo '<meta property="og:image" content="' . esc_url($image) . '">' . "\n";
}
$schema = self::schema_for($post);
if ($schema) {
echo '<script type="application/ld+json">' . wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . '</script>' . "\n";
}
}
private static function queried_content(): ?WP_Post
{
if (!is_singular(array_values(ContentTypes::public_post_types()))) {
return null;
}
$post = get_queried_object();
return $post instanceof WP_Post ? $post : null;
}
private static function generated_title(WP_Post $post): string
{
$site_name = (string) get_bloginfo('name');
$locality = (string) get_post_meta($post->ID, 'mw_locality', true);
$separator = $locality ? ' in ' . $locality : '';
$suffix = $site_name ? ' | ' . $site_name : '';
return get_the_title($post) . $separator . $suffix;
}
private static function generated_description(WP_Post $post): string
{
$excerpt = wp_trim_words(wp_strip_all_tags($post->post_excerpt ?: $post->post_content), 30);
$locality = (string) get_post_meta($post->ID, 'mw_locality', true);
$address = (string) get_post_meta($post->ID, 'mw_address_line1', true);
$location = implode(', ', array_filter([$address, $locality]));
return wp_trim_words(trim(implode(' — ', array_filter([$excerpt, $location]))), 40);
}
private static function schema_for(WP_Post $post): ?array
{
$item = RestApi::public_item($post);
$type = match ($post->post_type) {
'mw_event' => 'Event',
'mw_job' => 'JobPosting',
'mw_service' => 'LocalBusiness',
'mw_news' => 'Article',
'mw_guide' => 'Article',
default => 'Place',
};
$schema = [
'@context' => 'https://schema.org',
'@type' => $type,
'@id' => get_permalink($post) . '#meinweimar',
'name' => get_the_title($post),
'url' => get_permalink($post),
'description' => self::generated_description($post),
'mainEntityOfPage' => get_permalink($post),
'inLanguage' => self::schema_locale($post),
];
$author = get_userdata((int) $post->post_author);
if ($author && in_array($post->post_type, ['mw_news', 'mw_guide'], true)) {
$schema['author'] = [
'@type' => 'Person',
'name' => (string) ($author->display_name ?: $author->user_login),
];
$schema['datePublished'] = get_the_date(DATE_W3C, $post);
$schema['dateModified'] = get_the_modified_date(DATE_W3C, $post);
}
$image = get_the_post_thumbnail_url($post, 'full');
if ($image) {
$schema['image'] = $image;
}
$address_data = is_array($item['address'] ?? null) ? $item['address'] : [];
$street = implode(', ', array_filter([
(string) ($address_data['line1'] ?? get_post_meta($post->ID, 'mw_address_line1', true)),
(string) ($address_data['line2'] ?? get_post_meta($post->ID, 'mw_address_line2', true)),
]));
$address = array_filter([
'@type' => 'PostalAddress',
'streetAddress' => $street,
'postalCode' => (string) ($address_data['postal_code'] ?? get_post_meta($post->ID, 'mw_postal_code', true)),
'addressLocality' => (string) ($address_data['locality'] ?? get_post_meta($post->ID, 'mw_locality', true)),
'addressRegion' => (string) ($address_data['region'] ?? get_post_meta($post->ID, 'mw_region', true)),
'addressCountry' => (string) ($address_data['country'] ?? get_post_meta($post->ID, 'mw_country', true)),
]);
if (count($address) > 1) {
$schema['address'] = $address;
}
$contact = is_array($item['contact'] ?? null) ? $item['contact'] : [];
if (!empty($contact['phone'])) {
$schema['telephone'] = (string) $contact['phone'];
}
if (!empty($contact['email'])) {
$schema['email'] = (string) $contact['email'];
}
$same_as = array_values(array_filter(array_map(
static fn ($value): string => esc_url_raw((string) $value),
[
$contact['website_url'] ?? '',
$contact['facebook_url'] ?? '',
$contact['twitter_url'] ?? '',
$contact['video_url'] ?? '',
]
)));
if ($same_as) {
$schema['sameAs'] = $same_as;
}
$place_details = is_array($item['place_details'] ?? null) ? $item['place_details'] : [];
$opening_hours = self::opening_hours_schema((array) ($place_details['opening_hours'] ?? []));
if ($opening_hours) {
$schema['openingHoursSpecification'] = $opening_hours;
}
$rating_average = $place_details['rating_average'] ?? null;
$rating_count = $place_details['rating_count'] ?? null;
if (is_numeric($rating_average) && is_numeric($rating_count) && (int) $rating_count > 0) {
$schema['aggregateRating'] = [
'@type' => 'AggregateRating',
'ratingValue' => (float) $rating_average,
'ratingCount' => (int) $rating_count,
'bestRating' => 5,
'worstRating' => 0,
];
}
$geo = RestApi::public_geo_for_schema($post);
if ($geo) {
$schema['geo'] = [
'@type' => 'GeoCoordinates',
'latitude' => $geo['lat'],
'longitude' => $geo['lng'],
];
}
if ('mw_event' === $post->post_type) {
$start = (string) get_post_meta($post->ID, 'mw_event_start', true);
$end = (string) get_post_meta($post->ID, 'mw_event_end', true);
if ($start) {
$schema['startDate'] = $start;
}
if ($end) {
$schema['endDate'] = $end;
}
$status = (string) get_post_meta($post->ID, 'mw_event_status', true);
if ('cancelled' === $status) {
$schema['eventStatus'] = 'https://schema.org/EventCancelled';
} elseif ('postponed' === $status) {
$schema['eventStatus'] = 'https://schema.org/EventPostponed';
} else {
$schema['eventStatus'] = 'https://schema.org/EventScheduled';
}
$occurrences = EventOccurrences::for_post($post->ID);
if ($occurrences) {
$schema['eventSchedule'] = [];
foreach ($occurrences as $occurrence) {
if (empty($occurrence['starts_at'])) {
continue;
}
$schedule = [
'@type' => 'Schedule',
'startDate' => (string) $occurrence['starts_at'],
];
if (!empty($occurrence['ends_at'])) {
$schedule['endDate'] = (string) $occurrence['ends_at'];
}
if (!empty($occurrence['timezone'])) {
$schedule['scheduleTimezone'] = (string) $occurrence['timezone'];
}
if (!empty($occurrence['recurrence']) && 'none' !== $occurrence['recurrence']) {
$schedule['repeatFrequency'] = (string) $occurrence['recurrence'];
}
$schema['eventSchedule'][] = $schedule;
}
if (!$schema['eventSchedule']) {
unset($schema['eventSchedule']);
}
}
$venue_id = absint(get_post_meta($post->ID, 'mw_venue_place_id', true));
$venue = $venue_id ? get_post($venue_id) : null;
$location_post = $venue instanceof WP_Post ? $venue : $post;
$location_address = self::address_for($location_post);
$location = ['@type' => 'Place', 'name' => get_the_title($location_post)];
if (count($location_address) > 1) {
$location['address'] = $location_address;
}
$schema['location'] = $location;
}
if ('mw_job' === $post->post_type) {
$company = (string) get_post_meta($post->ID, 'mw_company_name', true);
if ($company) {
$schema['hiringOrganization'] = ['@type' => 'Organization', 'name' => $company];
}
}
if ('mw_property' === $post->post_type) {
$rooms = get_post_meta($post->ID, 'mw_property_rooms', true);
if (is_numeric($rooms) && (float) $rooms > 0) {
$schema['numberOfRooms'] = (float) $rooms;
}
}
return $schema;
}
private static function schema_locale(WP_Post $post): string
{
$locale = (string) get_post_meta($post->ID, 'mw_content_locale', true);
if (!$locale) {
$locale = (string) (Settings::public_config()['localization']['default_locale'] ?? 'de_DE');
}
return str_replace('_', '-', sanitize_text_field($locale));
}
private static function address_for(WP_Post $post): array
{
return array_filter([
'@type' => 'PostalAddress',
'streetAddress' => (string) get_post_meta($post->ID, 'mw_address_line1', true),
'postalCode' => (string) get_post_meta($post->ID, 'mw_postal_code', true),
'addressLocality' => (string) get_post_meta($post->ID, 'mw_locality', true),
'addressRegion' => (string) get_post_meta($post->ID, 'mw_region', true),
'addressCountry' => (string) get_post_meta($post->ID, 'mw_country', true),
]);
}
private static function opening_hours_schema(array $hours): array
{
$day_names = [
'mon' => 'Monday',
'tue' => 'Tuesday',
'wed' => 'Wednesday',
'thu' => 'Thursday',
'fri' => 'Friday',
'sat' => 'Saturday',
'sun' => 'Sunday',
];
$result = [];
foreach ((array) ($hours['entries'] ?? []) as $entry) {
if (!empty($entry['closed'])) {
continue;
}
$days = array_values(array_filter(array_map(
static fn ($day): string => $day_names[(string) $day] ?? '',
(array) ($entry['days'] ?? [])
)));
if (!$days) {
continue;
}
foreach ((array) ($entry['slots'] ?? []) as $slot) {
$opens = self::schema_time($slot['open'] ?? '');
$closes = self::schema_time($slot['close'] ?? '');
if ('' === $opens || '' === $closes) {
continue;
}
$result[] = [
'@type' => 'OpeningHoursSpecification',
'dayOfWeek' => $days,
'opens' => $opens,
'closes' => $closes,
];
}
}
return $result;
}
private static function schema_time($value): string
{
$value = trim((string) $value);
if (!preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $value)) {
return '24:00' === $value ? '23:59' : '';
}
return $value;
}
}
