'',
'description' => '',
'keywords' => '',
'image' => '',
'url' => '',
'type' => '',
'published_time' => '',
'modified_time' => '',
'author' => '',
'publisher' => '',
];
/**
* Current page context
*/
private string $pageType = '';
private $pageObject = null;
/**
* Instance cache for performance
*/
private static array $instanceCache = [];
/**
* Get SEO instance for specific page type with caching and validation
*
* @param string $pageType The page type
* @param mixed $pageObject The page object (trip, destination, etc.)
* @return self SEO service instance
* @throws \InvalidArgumentException If page type is invalid
*/
public static function forPage(string $pageType, $pageObject = null): self
{
// Validate page type
if (!in_array($pageType, self::VALID_PAGE_TYPES, true)) {
throw new \InvalidArgumentException(
sprintf('Invalid page type "%s". Valid types: %s',
$pageType,
implode(', ', self::VALID_PAGE_TYPES)
)
);
}
// Generate cache key
$cacheKey = self::CACHE_KEY_PREFIX . md5($pageType . serialize($pageObject));
// Check cache first
if (isset(self::$instanceCache[$cacheKey])) {
return self::$instanceCache[$cacheKey];
}
// Create new instance
$instance = new self();
$instance->pageType = $pageType;
$instance->pageObject = $pageObject;
// Cache the instance
self::$instanceCache[$cacheKey] = $instance;
return $instance;
}
/**
* Generate and output all SEO meta tags with error handling
*/
public function generateMetaTags(): void
{
try {
$this->collectSEOData();
if (empty($this->seoData['url'])) {
$this->seoData['url'] = $this->getCurrentUrl();
}
$this->outputBasicMetaTags();
$this->outputOpenGraphTags();
$this->outputTwitterCardTags();
$this->outputAdvancedMetaTags();
$this->outputPaginationRelLinks();
$this->outputSchemaMarkup();
} catch (\Exception $e) {
// Log error and fail gracefully
// Output basic fallback meta tags
$this->outputFallbackMetaTags();
}
}
/**
* Get page title (for document_title filter) with error handling
*
* @return string The formatted page title
*/
public function getTitle(): string
{
try {
$this->collectSEOData();
$title = $this->sanitizeText($this->seoData['title'] ?? '');
if (!empty($title)) {
return $title . ' - ' . $this->sanitizeText(get_bloginfo('name'));
}
return '';
} catch (\Exception $e) {
return '';
}
}
/**
* Canonical URL for the current HTTP request (og:url, link rel=canonical).
* Must not use the HTTP referer.
*/
private function getCurrentUrl(): string
{
if (!empty($_SERVER['HTTP_HOST'])) {
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (!empty($_SERVER['SERVER_PORT']) && (string) $_SERVER['SERVER_PORT'] === '443')
|| (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])
&& strtolower((string) $_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https');
$scheme = $https ? 'https' : 'http';
$host = strtolower(\sanitize_text_field(\wp_unslash((string) $_SERVER['HTTP_HOST'])));
$uri = $_SERVER['REQUEST_URI'] ?? '/';
$uri = \is_string($uri) && $uri !== '' ? $uri : '/';
$url = $scheme . '://' . $host . $uri;
$validated = \filter_var($url, \FILTER_VALIDATE_URL);
if ($validated) {
return \esc_url_raw($validated);
}
}
global $wp;
if (isset($wp->request) && \is_string($wp->request)) {
$base = \home_url(\user_trailingslashit($wp->request));
if (!empty($_GET) && \is_array($_GET)) {
$base = \add_query_arg(\array_map('sanitize_text_field', \wp_unslash($_GET)), $base);
}
return \esc_url_raw($base);
}
return \home_url('/');
}
/**
* Output fallback meta tags in case of errors
*/
private function outputFallbackMetaTags(): void
{
$title = $this->sanitizeText(get_bloginfo('name'));
$description = $this->sanitizeText(get_bloginfo('description'));
if (!empty($description)) {
echo '' . "\n";
}
echo '' . "\n";
echo '' . "\n";
echo '' . "\n";
echo '' . "\n";
echo '' . "\n";
}
/**
* Sanitize text for SEO output
*
* @param string $text Text to sanitize
* @return string Sanitized text
*/
private function sanitizeText(string $text): string
{
return wp_strip_all_tags(strip_tags($text));
}
/**
* Expand SEO meta placeholders for taxonomy terms.
*
* The destination / activity / category meta forms tell the operator to
* "Use {name} as placeholder" in the meta title, description and keywords.
* Those values are stored verbatim (the term may be renamed later), so the
* token must be substituted here, at render time, with the term's name.
* No-op when the value contains no token, so plain values are untouched.
*
* @param string $value Stored meta value, possibly containing {name}
* @param string $name Term name to substitute
* @return string Value with {name} replaced
*/
private function expandTermTokens(string $value, string $name): string
{
if ($value === '' || strpos($value, '{') === false) {
return $value;
}
return str_replace(['{name}', '{Name}'], $name, $value);
}
/**
* Validate and sanitize URL
*
* @param string $url URL to validate
* @return string Validated URL
*/
private function validateUrl(string $url): string
{
return esc_url(filter_var($url, FILTER_VALIDATE_URL) ?: '');
}
/**
* Collect SEO data based on page type with error handling
*/
private function collectSEOData(): void
{
try {
switch ($this->pageType) {
case self::PAGE_TYPE_TRIP_ARCHIVE:
$this->collectTripArchiveData();
break;
case self::PAGE_TYPE_DESTINATION:
$this->collectDestinationData();
break;
case self::PAGE_TYPE_ACTIVITY:
$this->collectActivityData();
break;
case self::PAGE_TYPE_CATEGORY:
$this->collectCategoryData();
break;
case self::PAGE_TYPE_DESTINATION_LISTING:
$this->collectDestinationListingData();
break;
case self::PAGE_TYPE_ACTIVITY_LISTING:
$this->collectActivityListingData();
break;
case self::PAGE_TYPE_CATEGORY_LISTING:
$this->collectCategoryListingData();
break;
case self::PAGE_TYPE_TRIP:
$this->collectTripData();
break;
default:
throw new \InvalidArgumentException("Unsupported page type: {$this->pageType}");
}
} catch (\Exception $e) {
// Set default values on error
$this->setDefaultSEOData();
}
}
/**
* Set default SEO data as fallback
*/
private function setDefaultSEOData(): void
{
$this->seoData = array_merge($this->seoData, [
'title' => get_bloginfo('name'),
'description' => get_bloginfo('description'),
'author' => get_bloginfo('name'),
'publisher' => get_bloginfo('name'),
'type' => 'website',
'url' => $this->getCurrentUrl(),
'published_time' => date('c'),
'modified_time' => date('c'),
]);
}
/**
* Collect SEO data for trip archive page with validation
*/
private function collectTripArchiveData(): void
{
try {
$this->seoData['title'] = $this->sanitizeText(SettingsService::getString('seo_trip_meta_title', ''));
$this->seoData['description'] = $this->sanitizeText(SettingsService::getString('seo_trip_meta_description', ''));
$this->seoData['keywords'] = $this->sanitizeText(SettingsService::getString('seo_trip_meta_keywords', ''));
if ($this->seoData['title'] === '') {
$this->seoData['title'] = $this->sanitizeText(\__('All Trips', 'yatra'));
}
if ($this->seoData['description'] === '') {
$tagline = $this->sanitizeText(\get_bloginfo('description'));
$this->seoData['description'] = $tagline !== ''
? $tagline
: $this->sanitizeText(
\__('Browse and compare trips, then open a trip for full details and booking.', 'yatra')
);
}
// Get image from settings (attachment ID) with validation
$imageId = SettingsService::getInt('seo_trip_meta_image', 0);
if ($imageId > 0) {
$imageUrl = wp_get_attachment_url($imageId);
if ($imageUrl && filter_var($imageUrl, FILTER_VALIDATE_URL)) {
$this->seoData['image'] = $this->validateUrl($imageUrl);
}
}
$this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
$this->seoData['type'] = 'website';
$this->seoData['published_time'] = date('c');
$this->seoData['modified_time'] = date('c');
$this->seoData['author'] = $this->sanitizeText(get_bloginfo('name'));
$this->seoData['publisher'] = $this->sanitizeText(get_bloginfo('name'));
} catch (\Exception $e) {
$this->setDefaultSEOData();
}
}
private function collectDestinationListingData(): void
{
$this->seoData['title'] = $this->sanitizeText(\__('All Destinations', 'yatra'));
$this->seoData['description'] = $this->sanitizeText(
\__('Explore destinations we visit and find trips that match where you want to go.', 'yatra')
);
$this->seoData['keywords'] = '';
$this->seoData['image'] = '';
$this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
$this->seoData['type'] = 'website';
$this->seoData['published_time'] = \date('c');
$this->seoData['modified_time'] = \date('c');
$this->seoData['author'] = $this->sanitizeText(\get_bloginfo('name'));
$this->seoData['publisher'] = $this->sanitizeText(\get_bloginfo('name'));
}
private function collectActivityListingData(): void
{
$this->seoData['title'] = $this->sanitizeText(\__('All Activities', 'yatra'));
$this->seoData['description'] = $this->sanitizeText(
\__('Browse experiences and find trips built around the activities you love.', 'yatra')
);
$this->seoData['keywords'] = '';
$this->seoData['image'] = '';
$this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
$this->seoData['type'] = 'website';
$this->seoData['published_time'] = \date('c');
$this->seoData['modified_time'] = \date('c');
$this->seoData['author'] = $this->sanitizeText(\get_bloginfo('name'));
$this->seoData['publisher'] = $this->sanitizeText(\get_bloginfo('name'));
}
private function collectCategoryListingData(): void
{
$this->seoData['title'] = $this->sanitizeText(\__('Trip Categories', 'yatra'));
$this->seoData['description'] = $this->sanitizeText(
\__('Browse trips by style or theme—adventure, family, luxury, and more.', 'yatra')
);
$this->seoData['keywords'] = '';
$this->seoData['image'] = '';
$this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
$this->seoData['type'] = 'website';
$this->seoData['published_time'] = \date('c');
$this->seoData['modified_time'] = \date('c');
$this->seoData['author'] = $this->sanitizeText(\get_bloginfo('name'));
$this->seoData['publisher'] = $this->sanitizeText(\get_bloginfo('name'));
}
/**
* Collect SEO data for destination page
*/
private function collectDestinationData(): void
{
if (!$this->pageObject) {
return;
}
$destination = $this->pageObject;
$metadata = [];
if (!empty($destination->metadata)) {
if (\is_array($destination->metadata)) {
$metadata = $destination->metadata;
} else {
$maybe = \maybe_unserialize($destination->metadata);
$metadata = \is_array($maybe) ? $maybe : [];
}
}
$name = (string) ($destination->name ?? '');
$title = $metadata['seo_title'] ?? $destination->name ?? '';
$this->seoData['title'] = $this->sanitizeText($this->expandTermTokens((string) $title, $name));
$descRaw = $metadata['seo_description'] ?? $destination->description ?? '';
$descRaw = $this->expandTermTokens((string) $descRaw, $name);
$this->seoData['description'] = $this->sanitizeText(\wp_trim_words(\wp_strip_all_tags($descRaw), 45, '…'));
$this->seoData['keywords'] = $this->sanitizeText($this->expandTermTokens((string) ($metadata['seo_keywords'] ?? ''), $name));
// Get featured image or gallery image
$this->seoData['image'] = $destination->featured_image_url ??
($destination->gallery_images[0] ?? '');
$this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
$this->seoData['type'] = 'place';
$this->seoData['published_time'] = date('c', strtotime($destination->created_at ?? 'now'));
$this->seoData['modified_time'] = date('c', strtotime($destination->updated_at ?? 'now'));
$this->seoData['author'] = get_bloginfo('name');
$this->seoData['publisher'] = get_bloginfo('name');
}
/**
* Collect SEO data for activity page
*/
private function collectActivityData(): void
{
if (!$this->pageObject) {
return;
}
$activity = $this->pageObject;
$metadata = [];
if (!empty($activity->metadata)) {
if (\is_array($activity->metadata)) {
$metadata = $activity->metadata;
} else {
$maybe = \maybe_unserialize($activity->metadata);
$metadata = \is_array($maybe) ? $maybe : [];
}
}
$name = (string) ($activity->name ?? '');
$this->seoData['title'] = $this->sanitizeText($this->expandTermTokens((string) ($metadata['seo_title'] ?? $activity->name ?? ''), $name));
$descRaw = $metadata['seo_description'] ?? $activity->description ?? '';
$descRaw = $this->expandTermTokens((string) $descRaw, $name);
$this->seoData['description'] = $this->sanitizeText(\wp_trim_words(\wp_strip_all_tags($descRaw), 45, '…'));
$this->seoData['keywords'] = $this->sanitizeText($this->expandTermTokens((string) ($metadata['seo_keywords'] ?? ''), $name));
// Get featured image or gallery image
$this->seoData['image'] = $activity->featured_image_url ??
($activity->gallery_images[0] ?? '');
$this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
$this->seoData['type'] = 'article';
$this->seoData['published_time'] = date('c', strtotime($activity->created_at ?? 'now'));
$this->seoData['modified_time'] = date('c', strtotime($activity->updated_at ?? 'now'));
$this->seoData['author'] = get_bloginfo('name');
$this->seoData['publisher'] = get_bloginfo('name');
}
/**
* Collect SEO data for category page
*/
private function collectCategoryData(): void
{
if (!$this->pageObject) {
return;
}
$category = $this->pageObject;
$metadata = [];
if (!empty($category->metadata)) {
if (\is_array($category->metadata)) {
$metadata = $category->metadata;
} else {
$maybe = \maybe_unserialize($category->metadata);
$metadata = \is_array($maybe) ? $maybe : [];
}
}
$name = (string) ($category->name ?? '');
$this->seoData['title'] = $this->sanitizeText($this->expandTermTokens((string) ($metadata['seo_title'] ?? $category->name ?? ''), $name));
$descRaw = $metadata['seo_description'] ?? $category->description ?? '';
$descRaw = $this->expandTermTokens((string) $descRaw, $name);
$this->seoData['description'] = $this->sanitizeText(\wp_trim_words(\wp_strip_all_tags($descRaw), 45, '…'));
$this->seoData['keywords'] = $this->sanitizeText($this->expandTermTokens((string) ($metadata['seo_keywords'] ?? ''), $name));
// Get featured image or gallery image
$this->seoData['image'] = $category->featured_image_url ??
($category->gallery_images[0] ?? '');
$this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
$this->seoData['type'] = 'article';
$this->seoData['published_time'] = date('c', strtotime($category->created_at ?? 'now'));
$this->seoData['modified_time'] = date('c', strtotime($category->updated_at ?? 'now'));
$this->seoData['author'] = get_bloginfo('name');
$this->seoData['publisher'] = get_bloginfo('name');
}
/**
* Collect SEO data for single trip page
*/
private function collectTripData(): void
{
if (!$this->pageObject) {
return;
}
$trip = $this->pageObject;
$customTitle = trim((string) ($trip->meta_title ?? ''));
$displayTitle = '';
if (\is_object($trip) && \method_exists($trip, 'getTitle')) {
$displayTitle = $this->sanitizeText($trip->getTitle());
} else {
$displayTitle = $this->sanitizeText((string) ($trip->title ?? $trip->name ?? ''));
}
$this->seoData['title'] = $customTitle !== ''
? $this->sanitizeText($customTitle)
: $displayTitle;
$customDesc = trim((string) ($trip->meta_description ?? ''));
$fallbackDesc = '';
if (\is_object($trip) && \method_exists($trip, 'getShortDescription')) {
$short = $trip->getShortDescription();
if (!empty($short)) {
$fallbackDesc = $this->sanitizeText(\wp_strip_all_tags((string) $short));
}
}
if ($fallbackDesc === '' && !empty($trip->description)) {
$fallbackDesc = $this->sanitizeText(
\wp_trim_words(\wp_strip_all_tags((string) $trip->description), 40, '…')
);
}
if ($fallbackDesc === '') {
$fallbackDesc = $this->sanitizeText(SettingsService::getString('seo_trip_meta_description', ''));
}
$this->seoData['description'] = $customDesc !== ''
? $this->sanitizeText($customDesc)
: $fallbackDesc;
$customKeywords = trim((string) ($trip->meta_keywords ?? ''));
$this->seoData['keywords'] = $customKeywords !== ''
? $this->sanitizeText($customKeywords)
: $this->sanitizeText(SettingsService::getString('seo_trip_meta_keywords', ''));
$this->seoData['image'] = $this->resolveTripSeoImageUrl($trip);
$this->seoData['url'] = function_exists('yatra_get_trip_permalink')
? yatra_get_trip_permalink($trip)
: home_url('/' . SettingsService::getTripBase() . '/' . ($trip->slug ?? ''));
$this->seoData['type'] = 'article';
$this->seoData['published_time'] = date('c', strtotime($trip->created_at ?? 'now'));
$this->seoData['modified_time'] = date('c', strtotime($trip->updated_at ?? 'now'));
$this->seoData['author'] = get_bloginfo('name');
$this->seoData['publisher'] = get_bloginfo('name');
}
/**
* Resolve share/SEO image URL for a trip (featured, gallery, then global trip SEO image setting).
*
* @param object $trip
*/
private function resolveTripSeoImageUrl(object $trip): string
{
$raw = '';
if (!empty($trip->featured_image_url)) {
$raw = (string) $trip->featured_image_url;
} elseif (!empty($trip->gallery_images) && \is_array($trip->gallery_images)) {
$first = $trip->gallery_images[0] ?? null;
if (\is_string($first)) {
$raw = $first;
} elseif (\is_array($first)) {
$raw = (string) ($first['url'] ?? $first['src'] ?? '');
}
}
if ($raw === '') {
$imageId = SettingsService::getInt('seo_trip_meta_image', 0);
if ($imageId > 0) {
$url = \wp_get_attachment_url($imageId);
if ($url && \filter_var($url, FILTER_VALIDATE_URL)) {
$raw = $url;
}
}
}
return $raw !== '' ? $this->validateUrl($raw) : '';
}
/**
* The page's language as WordPress reports it, reduced to what hreflang
* and Open Graph accept: language + optional region (`de-DE`, `en-US`,
* `ca`). Read at render time through get_locale()/the `locale` filter, so
* a German install, WPML and Polylang per-page languages all resolve
* correctly. WordPress variant locales such as `de_DE_formal` ("Deutsch
* (Sie)") or `pt_PT_ao90` carry a third segment that is not a region —
* Google ignores an hreflang like `de-DE-formal` and Facebook rejects
* `de_DE_formal` — so only the first two segments are kept. Falls back to
* `en-US` when WP has no locale, which keeps existing English sites
* byte-identical.
*/
private function languageTag(): string
{
$locale = (string) get_locale();
if ($locale === '') {
$locale = str_replace('-', '_', (string) get_bloginfo('language'));
}
$parts = preg_split('/[_-]/', $locale) ?: [];
$language = strtolower((string) ($parts[0] ?? ''));
if (!preg_match('/^[a-z]{2,3}$/', $language)) {
return 'en-US';
}
$region = strtoupper((string) ($parts[1] ?? ''));
return preg_match('/^[A-Z]{2}$/', $region) ? $language . '-' . $region : $language;
}
/**
* Same language in Open Graph form (`de_DE`, `en_US`).
*/
private function ogLocale(): string
{
return str_replace('-', '_', $this->languageTag());
}
/**
* Output basic meta tags
*/
private function outputBasicMetaTags(): void
{
if (!empty($this->seoData['description'])) {
echo '' . "\n";
}
if (!empty($this->seoData['keywords'])) {
echo '' . "\n";
}
}
/**
* Output Open Graph meta tags
*/
private function outputOpenGraphTags(): void
{
echo '' . "\n";
echo '' . "\n";
echo '' . "\n";
echo '' . "\n";
echo '' . "\n";
echo '' . "\n";
if (!empty($this->seoData['image'])) {
echo '' . "\n";
echo '' . "\n";
echo '' . "\n";
echo '' . "\n";
}
if (!empty($this->seoData['published_time'])) {
echo '' . "\n";
}
if (!empty($this->seoData['modified_time'])) {
echo '' . "\n";
}
}
/**
* Output Twitter Card meta tags
*/
private function outputTwitterCardTags(): void
{
echo '' . "\n";
echo '' . "\n";
echo '' . "\n";
if (!empty($this->seoData['image'])) {
echo '' . "\n";
}
}
/**
* Output advanced meta tags
*/
private function outputAdvancedMetaTags(): void
{
echo '' . "\n";
echo '' . "\n";
echo '' . "\n";
echo '' . "\n";
echo '' . "\n";
echo '' . "\n";
echo '' . "\n";
echo '' . "\n";
echo '' . "\n";
}
/**
* rel="prev" / rel="next" for paginated trip archive and taxonomy listings (Google-supported series hints).
*/
private function outputPaginationRelLinks(): void
{
$prevUrl = '';
$nextUrl = '';
switch ($this->pageType) {
case self::PAGE_TYPE_TRIP_ARCHIVE:
$ctx = $GLOBALS['yatra_trip_listing_context'] ?? null;
if (!\is_array($ctx)) {
return;
}
$pagination = $ctx['pagination'] ?? null;
if (!\is_array($pagination)) {
return;
}
if (!empty($pagination['prev_url']) && \is_string($pagination['prev_url'])) {
$prevUrl = $pagination['prev_url'];
}
if (!empty($pagination['next_url']) && \is_string($pagination['next_url'])) {
$nextUrl = $pagination['next_url'];
}
break;
case self::PAGE_TYPE_DESTINATION:
case self::PAGE_TYPE_ACTIVITY:
case self::PAGE_TYPE_CATEGORY:
$td = $GLOBALS['yatra_taxonomy_data'] ?? null;
if (!$td || !\is_object($td)) {
return;
}
$total = \max(1, (int) ($td->trips_pages ?? 1));
$current = (int) ($td->trips_current_page ?? 1);
if ($current < 1) {
$current = 1;
}
$current = \min($current, $total);
if (!\function_exists('yatra_build_current_request_paged_url')) {
return;
}
if ($current > 1) {
$prevUrl = (string) yatra_build_current_request_paged_url($current - 1);
}
if ($current < $total) {
$nextUrl = (string) yatra_build_current_request_paged_url($current + 1);
}
break;
case self::PAGE_TYPE_DESTINATION_LISTING:
case self::PAGE_TYPE_ACTIVITY_LISTING:
case self::PAGE_TYPE_CATEGORY_LISTING:
$meta = $GLOBALS['yatra_archive_browse_pagination'] ?? null;
if (!\is_array($meta)) {
return;
}
$total = \max(1, (int) ($meta['total_pages'] ?? 1));
$current = (int) ($meta['current_page'] ?? 1);
if ($current < 1) {
$current = 1;
}
$current = \min($current, $total);
if (!\function_exists('yatra_build_archive_listing_url')) {
return;
}
if ($current > 1) {
$prevUrl = (string) yatra_build_archive_listing_url($current - 1);
}
if ($current < $total) {
$nextUrl = (string) yatra_build_archive_listing_url($current + 1);
}
break;
default:
return;
}
if ($prevUrl !== '') {
echo '' . "\n";
}
if ($nextUrl !== '') {
echo '' . "\n";
}
}
/**
* Output Schema markup
*/
private function outputSchemaMarkup(): void
{
$schema = $this->generateSchemaMarkup();
if (!empty($schema)) {
// JSON_HEX_TAG|JSON_HEX_AMP escape < > & as \u00xx so no string value
// (e.g. user-submitted review text/author) can break out of this
// in a review would otherwise be XSS.
// Google parses the \u-escaped JSON identically.
echo '' . "\n";
}
}
/**
* Generate schema markup based on page type
*/
private function generateSchemaMarkup(): array
{
$baseSchema = [
'@context' => 'https://schema.org',
'name' => $this->seoData['title'],
'description' => $this->seoData['description'],
'url' => $this->seoData['url'],
'publisher' => [
'@type' => 'Organization',
'name' => get_bloginfo('name'),
'url' => home_url()
],
'dateModified' => $this->seoData['modified_time'],
'datePublished' => $this->seoData['published_time'],
'inLanguage' => $this->languageTag(),
'isPartOf' => [
'@type' => 'WebSite',
'name' => get_bloginfo('name'),
'url' => home_url()
]
];
// Add image if available
if (!empty($this->seoData['image'])) {
$baseSchema['image'] = $this->seoData['image'];
}
switch ($this->pageType) {
case self::PAGE_TYPE_TRIP_ARCHIVE:
case self::PAGE_TYPE_DESTINATION_LISTING:
case self::PAGE_TYPE_ACTIVITY_LISTING:
case self::PAGE_TYPE_CATEGORY_LISTING:
return $this->generateCollectionPageSchema($baseSchema);
case self::PAGE_TYPE_DESTINATION:
return $this->generatePlaceSchema($baseSchema);
case self::PAGE_TYPE_TRIP:
// A tour with ratings/reviews must be a Google-supported review
// type (Product) with the rating + reviews nested inside it —
// NOT a TouristTrip/Article, which Google rejects for review
// snippets ("invalid object type for parent_node").
return $this->generateTripProductSchema($baseSchema);
case self::PAGE_TYPE_ACTIVITY:
case self::PAGE_TYPE_CATEGORY:
return $this->generateArticleSchema($baseSchema);
default:
return $baseSchema;
}
}
/**
* Generate CollectionPage schema for archive pages
*/
private function generateCollectionPageSchema(array $baseSchema): array
{
return array_merge($baseSchema, [
'@type' => 'CollectionPage',
'mainEntity' => [
'@type' => 'ItemList',
'numberOfItems' => 0, // This should be dynamically populated
'itemListElement' => []
]
]);
}
/**
* Generate Place schema for destination pages
*/
private function generatePlaceSchema(array $baseSchema): array
{
return array_merge($baseSchema, [
'@type' => 'Place',
'address' => $this->pageObject->address ?? '',
'geo' => [
'@type' => 'GeoCoordinates',
'latitude' => $this->pageObject->latitude ?? '',
'longitude' => $this->pageObject->longitude ?? ''
]
]);
}
/**
* Generate Article schema for content pages
*/
private function generateArticleSchema(array $baseSchema): array
{
return array_merge($baseSchema, [
'@type' => 'Article',
'headline' => $this->seoData['title'],
'author' => [
'@type' => 'Organization',
'name' => get_bloginfo('name')
]
]);
}
/**
* Generate Product schema for a single trip, with rating + reviews nested
* INSIDE the product (the structure Google requires for review snippets).
*
* `aggregateRating` and `review` are added only when approved reviews exist —
* an empty aggregateRating is itself a structured-data error. Each review
* carries the required author / reviewRating / reviewBody / datePublished so
* Google no longer reports "Missing field author" or "Missing field itemReviewed".
*/
private function generateTripProductSchema(array $baseSchema): array
{
$trip = $this->pageObject;
$tripId = (\is_object($trip) && isset($trip->id)) ? (int) $trip->id : 0;
$avg = (\is_object($trip) && \method_exists($trip, 'getAverageRating'))
? (float) $trip->getAverageRating()
: 0.0;
$count = (\is_object($trip) && \method_exists($trip, 'getReviewCount'))
? (int) $trip->getReviewCount()
: 0;
// No approved reviews → keep the generic Article (unchanged behaviour).
// A Product is emitted ONLY when there's a real rating to nest, which is
// exactly what fixes the review markup — and it avoids emitting a bare
// Product (offers/review/rating all absent) that Google would flag as
// incomplete on the many tours that have no reviews yet.
if (!($tripId > 0 && $count > 0 && $avg > 0)) {
return $this->generateArticleSchema($baseSchema);
}
$schema = [
'@context' => 'https://schema.org',
'@type' => 'Product',
'name' => $this->seoData['title'],
'url' => $this->seoData['url'],
'brand' => [
'@type' => 'Brand',
'name' => get_bloginfo('name'),
],
];
if (!empty($this->seoData['description'])) {
$schema['description'] = $this->seoData['description'];
}
if (!empty($this->seoData['image'])) {
$schema['image'] = $this->seoData['image'];
}
$schema['aggregateRating'] = [
'@type' => 'AggregateRating',
'ratingValue' => (string) round($avg, 1),
'reviewCount' => (string) $count,
'bestRating' => '5',
'worstRating' => '1',
];
$rows = [];
try {
$rows = (new \Yatra\Repositories\ReviewRepository())->findApprovedByTripId($tripId, 10);
} catch (\Throwable $e) {
$rows = [];
}
$reviews = [];
foreach ($rows as $row) {
$rating = (int) ($row->rating ?? 0);
if ($rating < 1 || $rating > 5) {
continue;
}
// Strip tags on every user-derived value — even though the emitter
// now \u-escapes < > &, keep the data itself clean/plain-text.
$authorName = trim(\wp_strip_all_tags((string) ($row->author_name ?? $row->user_display_name ?? '')));
if ($authorName === '') {
$authorName = __('Anonymous', 'yatra');
}
$review = [
'@type' => 'Review',
'author' => ['@type' => 'Person', 'name' => $authorName],
'reviewRating' => [
'@type' => 'Rating',
'ratingValue' => (string) $rating,
'bestRating' => '5',
'worstRating' => '1',
],
];
$title = $this->sanitizeText(trim((string) ($row->title ?? '')));
if ($title !== '') {
$review['name'] = $title;
}
$body = $this->sanitizeText(trim(\wp_strip_all_tags((string) ($row->content ?? ''))));
if ($body !== '') {
$review['reviewBody'] = $body;
}
$created = (string) ($row->created_at ?? '');
if ($created !== '') {
$ts = strtotime($created);
if ($ts) {
$review['datePublished'] = date('Y-m-d', $ts);
}
}
$reviews[] = $review;
}
if (!empty($reviews)) {
$schema['review'] = $reviews;
}
return $schema;
}
/**
* Truncate text to specified length
*/
private function truncateText(string $text, int $length): string
{
if (strlen($text) <= $length) {
return $text;
}
return substr($text, 0, $length - 3) . '...';
}
}