'', '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) . '...'; } }