PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / app / Services / SEOService.php

SEOService.php in Yatra – Travel Booking & Tour Operator Software 3.0.16, at app/Services/SEOService.php

1,129 lines 42.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Services;
6
7 use Yatra\Services\SettingsService;
8
9 /**
10 * SEO Service
11 *
12 * Production-ready centralized SEO management for all Yatra pages
13 * Handles meta tags, Open Graph, Twitter Cards, and Schema markup
14 * Follows SOLID principles for extensibility and maintainability
15 *
16 * @package Yatra\Services
17 */
18 class SEOService
19 {
20 /**
21 * Page types supported by SEO service
22 */
23 public const PAGE_TYPE_TRIP = 'trip';
24 public const PAGE_TYPE_TRIP_ARCHIVE = 'trip_archive';
25 public const PAGE_TYPE_DESTINATION = 'destination';
26 public const PAGE_TYPE_ACTIVITY = 'activity';
27 public const PAGE_TYPE_CATEGORY = 'category';
28 /** Browse-all index: /destination/, /activity/, /trip-category/ (and plain ?yatra_page=). */
29 public const PAGE_TYPE_DESTINATION_LISTING = 'destination_listing';
30 public const PAGE_TYPE_ACTIVITY_LISTING = 'activity_listing';
31 public const PAGE_TYPE_CATEGORY_LISTING = 'category_listing';
32
33 /**
34 * Valid page types for validation
35 */
36 private const VALID_PAGE_TYPES = [
37 self::PAGE_TYPE_TRIP,
38 self::PAGE_TYPE_TRIP_ARCHIVE,
39 self::PAGE_TYPE_DESTINATION,
40 self::PAGE_TYPE_ACTIVITY,
41 self::PAGE_TYPE_CATEGORY,
42 self::PAGE_TYPE_DESTINATION_LISTING,
43 self::PAGE_TYPE_ACTIVITY_LISTING,
44 self::PAGE_TYPE_CATEGORY_LISTING,
45 ];
46
47 /**
48 * Cache key prefix
49 */
50 private const CACHE_KEY_PREFIX = 'yatra_seo_';
51
52 /**
53 * Cache expiration time (1 hour)
54 */
55 private const CACHE_EXPIRATION = 3600;
56
57 /**
58 * SEO data structure
59 */
60 private array $seoData = [
61 'title' => '',
62 'description' => '',
63 'keywords' => '',
64 'image' => '',
65 'url' => '',
66 'type' => '',
67 'published_time' => '',
68 'modified_time' => '',
69 'author' => '',
70 'publisher' => '',
71 ];
72
73 /**
74 * Current page context
75 */
76 private string $pageType = '';
77 private $pageObject = null;
78
79 /**
80 * Instance cache for performance
81 */
82 private static array $instanceCache = [];
83
84 /**
85 * Get SEO instance for specific page type with caching and validation
86 *
87 * @param string $pageType The page type
88 * @param mixed $pageObject The page object (trip, destination, etc.)
89 * @return self SEO service instance
90 * @throws \InvalidArgumentException If page type is invalid
91 */
92 public static function forPage(string $pageType, $pageObject = null): self
93 {
94 // Validate page type
95 if (!in_array($pageType, self::VALID_PAGE_TYPES, true)) {
96 throw new \InvalidArgumentException(
97 sprintf('Invalid page type "%s". Valid types: %s',
98 $pageType,
99 implode(', ', self::VALID_PAGE_TYPES)
100 )
101 );
102 }
103
104 // Generate cache key
105 $cacheKey = self::CACHE_KEY_PREFIX . md5($pageType . serialize($pageObject));
106
107 // Check cache first
108 if (isset(self::$instanceCache[$cacheKey])) {
109 return self::$instanceCache[$cacheKey];
110 }
111
112 // Create new instance
113 $instance = new self();
114 $instance->pageType = $pageType;
115 $instance->pageObject = $pageObject;
116
117 // Cache the instance
118 self::$instanceCache[$cacheKey] = $instance;
119
120 return $instance;
121 }
122
123 /**
124 * Generate and output all SEO meta tags with error handling
125 */
126 public function generateMetaTags(): void
127 {
128 try {
129 $this->collectSEOData();
130
131 if (empty($this->seoData['url'])) {
132 $this->seoData['url'] = $this->getCurrentUrl();
133 }
134
135 $this->outputBasicMetaTags();
136 $this->outputOpenGraphTags();
137 $this->outputTwitterCardTags();
138 $this->outputAdvancedMetaTags();
139 $this->outputPaginationRelLinks();
140 $this->outputSchemaMarkup();
141
142 } catch (\Exception $e) {
143 // Log error and fail gracefully
144
145 // Output basic fallback meta tags
146 $this->outputFallbackMetaTags();
147 }
148 }
149
150 /**
151 * Get page title (for document_title filter) with error handling
152 *
153 * @return string The formatted page title
154 */
155 public function getTitle(): string
156 {
157 try {
158 $this->collectSEOData();
159 $title = $this->sanitizeText($this->seoData['title'] ?? '');
160
161 if (!empty($title)) {
162 return $title . ' - ' . $this->sanitizeText(get_bloginfo('name'));
163 }
164
165 return '';
166 } catch (\Exception $e) {
167 return '';
168 }
169 }
170
171 /**
172 * Canonical URL for the current HTTP request (og:url, link rel=canonical).
173 * Must not use the HTTP referer.
174 */
175 private function getCurrentUrl(): string
176 {
177 if (!empty($_SERVER['HTTP_HOST'])) {
178 $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
179 || (!empty($_SERVER['SERVER_PORT']) && (string) $_SERVER['SERVER_PORT'] === '443')
180 || (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])
181 && strtolower((string) $_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https');
182 $scheme = $https ? 'https' : 'http';
183 $host = strtolower(\sanitize_text_field(\wp_unslash((string) $_SERVER['HTTP_HOST'])));
184 $uri = $_SERVER['REQUEST_URI'] ?? '/';
185 $uri = \is_string($uri) && $uri !== '' ? $uri : '/';
186 $url = $scheme . '://' . $host . $uri;
187 $validated = \filter_var($url, \FILTER_VALIDATE_URL);
188 if ($validated) {
189 return \esc_url_raw($validated);
190 }
191 }
192
193 global $wp;
194 if (isset($wp->request) && \is_string($wp->request)) {
195 $base = \home_url(\user_trailingslashit($wp->request));
196 if (!empty($_GET) && \is_array($_GET)) {
197 $base = \add_query_arg(\array_map('sanitize_text_field', \wp_unslash($_GET)), $base);
198 }
199
200 return \esc_url_raw($base);
201 }
202
203 return \home_url('/');
204 }
205
206 /**
207 * Output fallback meta tags in case of errors
208 */
209 private function outputFallbackMetaTags(): void
210 {
211 $title = $this->sanitizeText(get_bloginfo('name'));
212 $description = $this->sanitizeText(get_bloginfo('description'));
213
214 if (!empty($description)) {
215 echo '<meta name="description" content="' . esc_attr($description) . '">' . "\n";
216 }
217
218 echo '<meta property="og:title" content="' . esc_attr($title) . '">' . "\n";
219 echo '<meta property="og:type" content="website">' . "\n";
220 echo '<meta property="og:url" content="' . esc_url(home_url()) . '">' . "\n";
221 echo '<meta name="twitter:card" content="summary">' . "\n";
222 echo '<link rel="canonical" href="' . esc_url(home_url()) . '">' . "\n";
223 }
224
225 /**
226 * Sanitize text for SEO output
227 *
228 * @param string $text Text to sanitize
229 * @return string Sanitized text
230 */
231 private function sanitizeText(string $text): string
232 {
233 return wp_strip_all_tags(strip_tags($text));
234 }
235
236 /**
237 * Expand SEO meta placeholders for taxonomy terms.
238 *
239 * The destination / activity / category meta forms tell the operator to
240 * "Use {name} as placeholder" in the meta title, description and keywords.
241 * Those values are stored verbatim (the term may be renamed later), so the
242 * token must be substituted here, at render time, with the term's name.
243 * No-op when the value contains no token, so plain values are untouched.
244 *
245 * @param string $value Stored meta value, possibly containing {name}
246 * @param string $name Term name to substitute
247 * @return string Value with {name} replaced
248 */
249 private function expandTermTokens(string $value, string $name): string
250 {
251 if ($value === '' || strpos($value, '{') === false) {
252 return $value;
253 }
254
255 return str_replace(['{name}', '{Name}'], $name, $value);
256 }
257
258 /**
259 * Validate and sanitize URL
260 *
261 * @param string $url URL to validate
262 * @return string Validated URL
263 */
264 private function validateUrl(string $url): string
265 {
266 return esc_url(filter_var($url, FILTER_VALIDATE_URL) ?: '');
267 }
268
269 /**
270 * Collect SEO data based on page type with error handling
271 */
272 private function collectSEOData(): void
273 {
274 try {
275 switch ($this->pageType) {
276 case self::PAGE_TYPE_TRIP_ARCHIVE:
277 $this->collectTripArchiveData();
278 break;
279 case self::PAGE_TYPE_DESTINATION:
280 $this->collectDestinationData();
281 break;
282 case self::PAGE_TYPE_ACTIVITY:
283 $this->collectActivityData();
284 break;
285 case self::PAGE_TYPE_CATEGORY:
286 $this->collectCategoryData();
287 break;
288 case self::PAGE_TYPE_DESTINATION_LISTING:
289 $this->collectDestinationListingData();
290 break;
291 case self::PAGE_TYPE_ACTIVITY_LISTING:
292 $this->collectActivityListingData();
293 break;
294 case self::PAGE_TYPE_CATEGORY_LISTING:
295 $this->collectCategoryListingData();
296 break;
297 case self::PAGE_TYPE_TRIP:
298 $this->collectTripData();
299 break;
300 default:
301 throw new \InvalidArgumentException("Unsupported page type: {$this->pageType}");
302 }
303 } catch (\Exception $e) {
304
305 // Set default values on error
306 $this->setDefaultSEOData();
307 }
308 }
309
310 /**
311 * Set default SEO data as fallback
312 */
313 private function setDefaultSEOData(): void
314 {
315 $this->seoData = array_merge($this->seoData, [
316 'title' => get_bloginfo('name'),
317 'description' => get_bloginfo('description'),
318 'author' => get_bloginfo('name'),
319 'publisher' => get_bloginfo('name'),
320 'type' => 'website',
321 'url' => $this->getCurrentUrl(),
322 'published_time' => date('c'),
323 'modified_time' => date('c'),
324 ]);
325 }
326
327 /**
328 * Collect SEO data for trip archive page with validation
329 */
330 private function collectTripArchiveData(): void
331 {
332 try {
333 $this->seoData['title'] = $this->sanitizeText(SettingsService::getString('seo_trip_meta_title', ''));
334 $this->seoData['description'] = $this->sanitizeText(SettingsService::getString('seo_trip_meta_description', ''));
335 $this->seoData['keywords'] = $this->sanitizeText(SettingsService::getString('seo_trip_meta_keywords', ''));
336 if ($this->seoData['title'] === '') {
337 $this->seoData['title'] = $this->sanitizeText(\__('All Trips', 'yatra'));
338 }
339 if ($this->seoData['description'] === '') {
340 $tagline = $this->sanitizeText(\get_bloginfo('description'));
341 $this->seoData['description'] = $tagline !== ''
342 ? $tagline
343 : $this->sanitizeText(
344 \__('Browse and compare trips, then open a trip for full details and booking.', 'yatra')
345 );
346 }
347
348 // Get image from settings (attachment ID) with validation
349 $imageId = SettingsService::getInt('seo_trip_meta_image', 0);
350
351 if ($imageId > 0) {
352 $imageUrl = wp_get_attachment_url($imageId);
353
354 if ($imageUrl && filter_var($imageUrl, FILTER_VALIDATE_URL)) {
355 $this->seoData['image'] = $this->validateUrl($imageUrl);
356 }
357 }
358
359 $this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
360 $this->seoData['type'] = 'website';
361 $this->seoData['published_time'] = date('c');
362 $this->seoData['modified_time'] = date('c');
363 $this->seoData['author'] = $this->sanitizeText(get_bloginfo('name'));
364 $this->seoData['publisher'] = $this->sanitizeText(get_bloginfo('name'));
365
366 } catch (\Exception $e) {
367 $this->setDefaultSEOData();
368 }
369 }
370
371 private function collectDestinationListingData(): void
372 {
373 $this->seoData['title'] = $this->sanitizeText(\__('All Destinations', 'yatra'));
374 $this->seoData['description'] = $this->sanitizeText(
375 \__('Explore destinations we visit and find trips that match where you want to go.', 'yatra')
376 );
377 $this->seoData['keywords'] = '';
378 $this->seoData['image'] = '';
379 $this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
380 $this->seoData['type'] = 'website';
381 $this->seoData['published_time'] = \date('c');
382 $this->seoData['modified_time'] = \date('c');
383 $this->seoData['author'] = $this->sanitizeText(\get_bloginfo('name'));
384 $this->seoData['publisher'] = $this->sanitizeText(\get_bloginfo('name'));
385 }
386
387 private function collectActivityListingData(): void
388 {
389 $this->seoData['title'] = $this->sanitizeText(\__('All Activities', 'yatra'));
390 $this->seoData['description'] = $this->sanitizeText(
391 \__('Browse experiences and find trips built around the activities you love.', 'yatra')
392 );
393 $this->seoData['keywords'] = '';
394 $this->seoData['image'] = '';
395 $this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
396 $this->seoData['type'] = 'website';
397 $this->seoData['published_time'] = \date('c');
398 $this->seoData['modified_time'] = \date('c');
399 $this->seoData['author'] = $this->sanitizeText(\get_bloginfo('name'));
400 $this->seoData['publisher'] = $this->sanitizeText(\get_bloginfo('name'));
401 }
402
403 private function collectCategoryListingData(): void
404 {
405 $this->seoData['title'] = $this->sanitizeText(\__('Trip Categories', 'yatra'));
406 $this->seoData['description'] = $this->sanitizeText(
407 \__('Browse trips by style or theme—adventure, family, luxury, and more.', 'yatra')
408 );
409 $this->seoData['keywords'] = '';
410 $this->seoData['image'] = '';
411 $this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
412 $this->seoData['type'] = 'website';
413 $this->seoData['published_time'] = \date('c');
414 $this->seoData['modified_time'] = \date('c');
415 $this->seoData['author'] = $this->sanitizeText(\get_bloginfo('name'));
416 $this->seoData['publisher'] = $this->sanitizeText(\get_bloginfo('name'));
417 }
418
419 /**
420 * Collect SEO data for destination page
421 */
422 private function collectDestinationData(): void
423 {
424 if (!$this->pageObject) {
425 return;
426 }
427
428 $destination = $this->pageObject;
429 $metadata = [];
430 if (!empty($destination->metadata)) {
431 if (\is_array($destination->metadata)) {
432 $metadata = $destination->metadata;
433 } else {
434 $maybe = \maybe_unserialize($destination->metadata);
435 $metadata = \is_array($maybe) ? $maybe : [];
436 }
437 }
438
439 $name = (string) ($destination->name ?? '');
440 $title = $metadata['seo_title'] ?? $destination->name ?? '';
441 $this->seoData['title'] = $this->sanitizeText($this->expandTermTokens((string) $title, $name));
442 $descRaw = $metadata['seo_description'] ?? $destination->description ?? '';
443 $descRaw = $this->expandTermTokens((string) $descRaw, $name);
444 $this->seoData['description'] = $this->sanitizeText(\wp_trim_words(\wp_strip_all_tags($descRaw), 45, '…'));
445 $this->seoData['keywords'] = $this->sanitizeText($this->expandTermTokens((string) ($metadata['seo_keywords'] ?? ''), $name));
446
447 // Get featured image or gallery image
448 $this->seoData['image'] = $destination->featured_image_url ??
449 ($destination->gallery_images[0] ?? '');
450
451 $this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
452
453 $this->seoData['type'] = 'place';
454 $this->seoData['published_time'] = date('c', strtotime($destination->created_at ?? 'now'));
455 $this->seoData['modified_time'] = date('c', strtotime($destination->updated_at ?? 'now'));
456 $this->seoData['author'] = get_bloginfo('name');
457 $this->seoData['publisher'] = get_bloginfo('name');
458 }
459
460 /**
461 * Collect SEO data for activity page
462 */
463 private function collectActivityData(): void
464 {
465 if (!$this->pageObject) {
466 return;
467 }
468
469 $activity = $this->pageObject;
470 $metadata = [];
471 if (!empty($activity->metadata)) {
472 if (\is_array($activity->metadata)) {
473 $metadata = $activity->metadata;
474 } else {
475 $maybe = \maybe_unserialize($activity->metadata);
476 $metadata = \is_array($maybe) ? $maybe : [];
477 }
478 }
479
480 $name = (string) ($activity->name ?? '');
481 $this->seoData['title'] = $this->sanitizeText($this->expandTermTokens((string) ($metadata['seo_title'] ?? $activity->name ?? ''), $name));
482 $descRaw = $metadata['seo_description'] ?? $activity->description ?? '';
483 $descRaw = $this->expandTermTokens((string) $descRaw, $name);
484 $this->seoData['description'] = $this->sanitizeText(\wp_trim_words(\wp_strip_all_tags($descRaw), 45, '…'));
485 $this->seoData['keywords'] = $this->sanitizeText($this->expandTermTokens((string) ($metadata['seo_keywords'] ?? ''), $name));
486
487 // Get featured image or gallery image
488 $this->seoData['image'] = $activity->featured_image_url ??
489 ($activity->gallery_images[0] ?? '');
490
491 $this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
492
493 $this->seoData['type'] = 'article';
494 $this->seoData['published_time'] = date('c', strtotime($activity->created_at ?? 'now'));
495 $this->seoData['modified_time'] = date('c', strtotime($activity->updated_at ?? 'now'));
496 $this->seoData['author'] = get_bloginfo('name');
497 $this->seoData['publisher'] = get_bloginfo('name');
498 }
499
500 /**
501 * Collect SEO data for category page
502 */
503 private function collectCategoryData(): void
504 {
505 if (!$this->pageObject) {
506 return;
507 }
508
509 $category = $this->pageObject;
510 $metadata = [];
511 if (!empty($category->metadata)) {
512 if (\is_array($category->metadata)) {
513 $metadata = $category->metadata;
514 } else {
515 $maybe = \maybe_unserialize($category->metadata);
516 $metadata = \is_array($maybe) ? $maybe : [];
517 }
518 }
519
520 $name = (string) ($category->name ?? '');
521 $this->seoData['title'] = $this->sanitizeText($this->expandTermTokens((string) ($metadata['seo_title'] ?? $category->name ?? ''), $name));
522 $descRaw = $metadata['seo_description'] ?? $category->description ?? '';
523 $descRaw = $this->expandTermTokens((string) $descRaw, $name);
524 $this->seoData['description'] = $this->sanitizeText(\wp_trim_words(\wp_strip_all_tags($descRaw), 45, '…'));
525 $this->seoData['keywords'] = $this->sanitizeText($this->expandTermTokens((string) ($metadata['seo_keywords'] ?? ''), $name));
526
527 // Get featured image or gallery image
528 $this->seoData['image'] = $category->featured_image_url ??
529 ($category->gallery_images[0] ?? '');
530
531 $this->seoData['url'] = $this->validateUrl($this->getCurrentUrl());
532
533 $this->seoData['type'] = 'article';
534 $this->seoData['published_time'] = date('c', strtotime($category->created_at ?? 'now'));
535 $this->seoData['modified_time'] = date('c', strtotime($category->updated_at ?? 'now'));
536 $this->seoData['author'] = get_bloginfo('name');
537 $this->seoData['publisher'] = get_bloginfo('name');
538 }
539
540 /**
541 * Collect SEO data for single trip page
542 */
543 private function collectTripData(): void
544 {
545 if (!$this->pageObject) {
546 return;
547 }
548
549 $trip = $this->pageObject;
550
551 $customTitle = trim((string) ($trip->meta_title ?? ''));
552 $displayTitle = '';
553 if (\is_object($trip) && \method_exists($trip, 'getTitle')) {
554 $displayTitle = $this->sanitizeText($trip->getTitle());
555 } else {
556 $displayTitle = $this->sanitizeText((string) ($trip->title ?? $trip->name ?? ''));
557 }
558
559 $this->seoData['title'] = $customTitle !== ''
560 ? $this->sanitizeText($customTitle)
561 : $displayTitle;
562
563 $customDesc = trim((string) ($trip->meta_description ?? ''));
564 $fallbackDesc = '';
565 if (\is_object($trip) && \method_exists($trip, 'getShortDescription')) {
566 $short = $trip->getShortDescription();
567 if (!empty($short)) {
568 $fallbackDesc = $this->sanitizeText(\wp_strip_all_tags((string) $short));
569 }
570 }
571 if ($fallbackDesc === '' && !empty($trip->description)) {
572 $fallbackDesc = $this->sanitizeText(
573 \wp_trim_words(\wp_strip_all_tags((string) $trip->description), 40, '…')
574 );
575 }
576 if ($fallbackDesc === '') {
577 $fallbackDesc = $this->sanitizeText(SettingsService::getString('seo_trip_meta_description', ''));
578 }
579 $this->seoData['description'] = $customDesc !== ''
580 ? $this->sanitizeText($customDesc)
581 : $fallbackDesc;
582
583 $customKeywords = trim((string) ($trip->meta_keywords ?? ''));
584 $this->seoData['keywords'] = $customKeywords !== ''
585 ? $this->sanitizeText($customKeywords)
586 : $this->sanitizeText(SettingsService::getString('seo_trip_meta_keywords', ''));
587
588 $this->seoData['image'] = $this->resolveTripSeoImageUrl($trip);
589
590 $this->seoData['url'] = function_exists('yatra_get_trip_permalink')
591 ? yatra_get_trip_permalink($trip)
592 : home_url('/' . SettingsService::getTripBase() . '/' . ($trip->slug ?? ''));
593
594 $this->seoData['type'] = 'article';
595 $this->seoData['published_time'] = date('c', strtotime($trip->created_at ?? 'now'));
596 $this->seoData['modified_time'] = date('c', strtotime($trip->updated_at ?? 'now'));
597 $this->seoData['author'] = get_bloginfo('name');
598 $this->seoData['publisher'] = get_bloginfo('name');
599 }
600
601 /**
602 * Resolve share/SEO image URL for a trip (featured, gallery, then global trip SEO image setting).
603 *
604 * @param object $trip
605 */
606 private function resolveTripSeoImageUrl(object $trip): string
607 {
608 $raw = '';
609 if (!empty($trip->featured_image_url)) {
610 $raw = (string) $trip->featured_image_url;
611 } elseif (!empty($trip->gallery_images) && \is_array($trip->gallery_images)) {
612 $first = $trip->gallery_images[0] ?? null;
613 if (\is_string($first)) {
614 $raw = $first;
615 } elseif (\is_array($first)) {
616 $raw = (string) ($first['url'] ?? $first['src'] ?? '');
617 }
618 }
619
620 if ($raw === '') {
621 $imageId = SettingsService::getInt('seo_trip_meta_image', 0);
622 if ($imageId > 0) {
623 $url = \wp_get_attachment_url($imageId);
624 if ($url && \filter_var($url, FILTER_VALIDATE_URL)) {
625 $raw = $url;
626 }
627 }
628 }
629
630 return $raw !== '' ? $this->validateUrl($raw) : '';
631 }
632
633 /**
634 * The page's language as WordPress reports it, reduced to what hreflang
635 * and Open Graph accept: language + optional region (`de-DE`, `en-US`,
636 * `ca`). Read at render time through get_locale()/the `locale` filter, so
637 * a German install, WPML and Polylang per-page languages all resolve
638 * correctly. WordPress variant locales such as `de_DE_formal` ("Deutsch
639 * (Sie)") or `pt_PT_ao90` carry a third segment that is not a region —
640 * Google ignores an hreflang like `de-DE-formal` and Facebook rejects
641 * `de_DE_formal` — so only the first two segments are kept. Falls back to
642 * `en-US` when WP has no locale, which keeps existing English sites
643 * byte-identical.
644 */
645 private function languageTag(): string
646 {
647 $locale = (string) get_locale();
648 if ($locale === '') {
649 $locale = str_replace('-', '_', (string) get_bloginfo('language'));
650 }
651
652 $parts = preg_split('/[_-]/', $locale) ?: [];
653 $language = strtolower((string) ($parts[0] ?? ''));
654 if (!preg_match('/^[a-z]{2,3}$/', $language)) {
655 return 'en-US';
656 }
657
658 $region = strtoupper((string) ($parts[1] ?? ''));
659
660 return preg_match('/^[A-Z]{2}$/', $region) ? $language . '-' . $region : $language;
661 }
662
663 /**
664 * Same language in Open Graph form (`de_DE`, `en_US`).
665 */
666 private function ogLocale(): string
667 {
668 return str_replace('-', '_', $this->languageTag());
669 }
670
671 /**
672 * Output basic meta tags
673 */
674 private function outputBasicMetaTags(): void
675 {
676 if (!empty($this->seoData['description'])) {
677 echo '<meta name="description" content="' . esc_attr($this->truncateText($this->seoData['description'], 160)) . '">' . "\n";
678 }
679
680 if (!empty($this->seoData['keywords'])) {
681 echo '<meta name="keywords" content="' . esc_attr($this->seoData['keywords']) . '">' . "\n";
682 }
683 }
684
685 /**
686 * Output Open Graph meta tags
687 */
688 private function outputOpenGraphTags(): void
689 {
690 echo '<meta property="og:locale" content="' . esc_attr($this->ogLocale()) . '">' . "\n";
691 echo '<meta property="og:site_name" content="' . esc_attr(get_bloginfo('name')) . '">' . "\n";
692 echo '<meta property="og:title" content="' . esc_attr($this->seoData['title']) . '">' . "\n";
693 echo '<meta property="og:description" content="' . esc_attr($this->truncateText($this->seoData['description'], 160)) . '">' . "\n";
694 echo '<meta property="og:type" content="' . esc_attr($this->seoData['type']) . '">' . "\n";
695 echo '<meta property="og:url" content="' . esc_url($this->seoData['url']) . '">' . "\n";
696
697 if (!empty($this->seoData['image'])) {
698 echo '<meta property="og:image" content="' . esc_url($this->seoData['image']) . '">' . "\n";
699 echo '<meta property="og:image:width" content="1200">' . "\n";
700 echo '<meta property="og:image:height" content="630">' . "\n";
701 echo '<meta property="og:image:alt" content="' . esc_attr($this->seoData['title']) . '">' . "\n";
702 }
703
704 if (!empty($this->seoData['published_time'])) {
705 echo '<meta property="article:published_time" content="' . esc_attr($this->seoData['published_time']) . '">' . "\n";
706 }
707
708 if (!empty($this->seoData['modified_time'])) {
709 echo '<meta property="article:modified_time" content="' . esc_attr($this->seoData['modified_time']) . '">' . "\n";
710 }
711 }
712
713 /**
714 * Output Twitter Card meta tags
715 */
716 private function outputTwitterCardTags(): void
717 {
718 echo '<meta name="twitter:card" content="summary_large_image">' . "\n";
719 echo '<meta name="twitter:title" content="' . esc_attr($this->seoData['title']) . '">' . "\n";
720 echo '<meta name="twitter:description" content="' . esc_attr($this->truncateText($this->seoData['description'], 160)) . '">' . "\n";
721
722 if (!empty($this->seoData['image'])) {
723 echo '<meta name="twitter:image" content="' . esc_url($this->seoData['image']) . '">' . "\n";
724 }
725 }
726
727 /**
728 * The robots directive for this page.
729 *
730 * Normally "index, follow" with the usual snippet hints, exactly as before.
731 * When the operator has both dropped this content type from the sitemap and
732 * opted in to noindexing excluded types, it becomes "noindex, follow" —
733 * because removing a URL from a sitemap does not stop Google indexing it,
734 * and this page was previously asserting the opposite.
735 *
736 * "follow" is kept deliberately: the page should stop being indexed, but
737 * links out of it (to trips that ARE published) should still be crawled.
738 */
739 private function robotsDirective(): string
740 {
741 $indexable = 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1';
742
743 if (!SettingsService::isEnabled('sitemap_noindex_excluded')) {
744 return $indexable;
745 }
746
747 $sitemapType = $this->sitemapTypeForPage();
748 if ($sitemapType === '') {
749 return $indexable;
750 }
751
752 if (!class_exists(\Yatra\Sitemap\SitemapService::class)) {
753 return $indexable;
754 }
755
756 $sitemap = new \Yatra\Sitemap\SitemapService();
757
758 return $sitemap->isTypeEnabled($sitemapType) ? $indexable : 'noindex, follow';
759 }
760
761 /**
762 * Map this page onto the sitemap content type that governs it.
763 *
764 * The taxonomy listing pages are published by the sitemap's `archive` type
765 * (the trip listing plus each taxonomy index), so they follow it.
766 *
767 * @return string '' when no sitemap type owns this page.
768 */
769 private function sitemapTypeForPage(): string
770 {
771 switch ($this->pageType) {
772 case self::PAGE_TYPE_TRIP:
773 return \Yatra\Sitemap\SitemapService::TYPE_TRIP;
774 case self::PAGE_TYPE_DESTINATION:
775 return \Yatra\Sitemap\SitemapService::TYPE_DESTINATION;
776 case self::PAGE_TYPE_ACTIVITY:
777 return \Yatra\Sitemap\SitemapService::TYPE_ACTIVITY;
778 case self::PAGE_TYPE_CATEGORY:
779 return \Yatra\Sitemap\SitemapService::TYPE_CATEGORY;
780 case self::PAGE_TYPE_TRIP_ARCHIVE:
781 case self::PAGE_TYPE_DESTINATION_LISTING:
782 case self::PAGE_TYPE_ACTIVITY_LISTING:
783 case self::PAGE_TYPE_CATEGORY_LISTING:
784 return \Yatra\Sitemap\SitemapService::TYPE_ARCHIVE;
785 default:
786 return '';
787 }
788 }
789
790 /**
791 * Output advanced meta tags
792 */
793 private function outputAdvancedMetaTags(): void
794 {
795 echo '<meta name="robots" content="' . esc_attr($this->robotsDirective()) . '">' . "\n";
796 echo '<link rel="canonical" href="' . esc_url($this->seoData['url']) . '">' . "\n";
797 echo '<meta name="author" content="' . esc_attr($this->seoData['author']) . '">' . "\n";
798 echo '<meta name="publisher" content="' . esc_attr($this->seoData['publisher']) . '">' . "\n";
799 echo '<meta name="lastmod" content="' . esc_attr($this->seoData['modified_time']) . '">' . "\n";
800 echo '<link rel="alternate" hreflang="' . esc_attr($this->languageTag()) . '" href="' . esc_url($this->seoData['url']) . '">' . "\n";
801 echo '<meta name="revisit-after" content="7 days">' . "\n";
802 echo '<meta name="distribution" content="global">' . "\n";
803 echo '<meta name="rating" content="general">' . "\n";
804 }
805
806 /**
807 * rel="prev" / rel="next" for paginated trip archive and taxonomy listings (Google-supported series hints).
808 */
809 private function outputPaginationRelLinks(): void
810 {
811 $prevUrl = '';
812 $nextUrl = '';
813
814 switch ($this->pageType) {
815 case self::PAGE_TYPE_TRIP_ARCHIVE:
816 $ctx = $GLOBALS['yatra_trip_listing_context'] ?? null;
817 if (!\is_array($ctx)) {
818 return;
819 }
820 $pagination = $ctx['pagination'] ?? null;
821 if (!\is_array($pagination)) {
822 return;
823 }
824 if (!empty($pagination['prev_url']) && \is_string($pagination['prev_url'])) {
825 $prevUrl = $pagination['prev_url'];
826 }
827 if (!empty($pagination['next_url']) && \is_string($pagination['next_url'])) {
828 $nextUrl = $pagination['next_url'];
829 }
830 break;
831
832 case self::PAGE_TYPE_DESTINATION:
833 case self::PAGE_TYPE_ACTIVITY:
834 case self::PAGE_TYPE_CATEGORY:
835 $td = $GLOBALS['yatra_taxonomy_data'] ?? null;
836 if (!$td || !\is_object($td)) {
837 return;
838 }
839 $total = \max(1, (int) ($td->trips_pages ?? 1));
840 $current = (int) ($td->trips_current_page ?? 1);
841 if ($current < 1) {
842 $current = 1;
843 }
844 $current = \min($current, $total);
845 if (!\function_exists('yatra_build_current_request_paged_url')) {
846 return;
847 }
848 if ($current > 1) {
849 $prevUrl = (string) yatra_build_current_request_paged_url($current - 1);
850 }
851 if ($current < $total) {
852 $nextUrl = (string) yatra_build_current_request_paged_url($current + 1);
853 }
854 break;
855
856 case self::PAGE_TYPE_DESTINATION_LISTING:
857 case self::PAGE_TYPE_ACTIVITY_LISTING:
858 case self::PAGE_TYPE_CATEGORY_LISTING:
859 $meta = $GLOBALS['yatra_archive_browse_pagination'] ?? null;
860 if (!\is_array($meta)) {
861 return;
862 }
863 $total = \max(1, (int) ($meta['total_pages'] ?? 1));
864 $current = (int) ($meta['current_page'] ?? 1);
865 if ($current < 1) {
866 $current = 1;
867 }
868 $current = \min($current, $total);
869 if (!\function_exists('yatra_build_archive_listing_url')) {
870 return;
871 }
872 if ($current > 1) {
873 $prevUrl = (string) yatra_build_archive_listing_url($current - 1);
874 }
875 if ($current < $total) {
876 $nextUrl = (string) yatra_build_archive_listing_url($current + 1);
877 }
878 break;
879
880 default:
881 return;
882 }
883
884 if ($prevUrl !== '') {
885 echo '<link rel="prev" href="' . esc_url($prevUrl) . '">' . "\n";
886 }
887 if ($nextUrl !== '') {
888 echo '<link rel="next" href="' . esc_url($nextUrl) . '">' . "\n";
889 }
890 }
891
892 /**
893 * Output Schema markup
894 */
895 private function outputSchemaMarkup(): void
896 {
897 $schema = $this->generateSchemaMarkup();
898 if (!empty($schema)) {
899 // JSON_HEX_TAG|JSON_HEX_AMP escape < > & as \u00xx so no string value
900 // (e.g. user-submitted review text/author) can break out of this
901 // <script> block — a </script> in a review would otherwise be XSS.
902 // Google parses the \u-escaped JSON identically.
903 echo '<script type="application/ld+json">' . json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) . '</script>' . "\n";
904 }
905 }
906
907 /**
908 * Generate schema markup based on page type
909 */
910 private function generateSchemaMarkup(): array
911 {
912 $baseSchema = [
913 '@context' => 'https://schema.org',
914 'name' => $this->seoData['title'],
915 'description' => $this->seoData['description'],
916 'url' => $this->seoData['url'],
917 'publisher' => [
918 '@type' => 'Organization',
919 'name' => get_bloginfo('name'),
920 'url' => home_url()
921 ],
922 'dateModified' => $this->seoData['modified_time'],
923 'datePublished' => $this->seoData['published_time'],
924 'inLanguage' => $this->languageTag(),
925 'isPartOf' => [
926 '@type' => 'WebSite',
927 'name' => get_bloginfo('name'),
928 'url' => home_url()
929 ]
930 ];
931
932 // Add image if available
933 if (!empty($this->seoData['image'])) {
934 $baseSchema['image'] = $this->seoData['image'];
935 }
936
937 switch ($this->pageType) {
938 case self::PAGE_TYPE_TRIP_ARCHIVE:
939 case self::PAGE_TYPE_DESTINATION_LISTING:
940 case self::PAGE_TYPE_ACTIVITY_LISTING:
941 case self::PAGE_TYPE_CATEGORY_LISTING:
942 return $this->generateCollectionPageSchema($baseSchema);
943 case self::PAGE_TYPE_DESTINATION:
944 return $this->generatePlaceSchema($baseSchema);
945 case self::PAGE_TYPE_TRIP:
946 // A tour with ratings/reviews must be a Google-supported review
947 // type (Product) with the rating + reviews nested inside it —
948 // NOT a TouristTrip/Article, which Google rejects for review
949 // snippets ("invalid object type for parent_node").
950 return $this->generateTripProductSchema($baseSchema);
951 case self::PAGE_TYPE_ACTIVITY:
952 case self::PAGE_TYPE_CATEGORY:
953 return $this->generateArticleSchema($baseSchema);
954 default:
955 return $baseSchema;
956 }
957 }
958
959 /**
960 * Generate CollectionPage schema for archive pages
961 */
962 private function generateCollectionPageSchema(array $baseSchema): array
963 {
964 return array_merge($baseSchema, [
965 '@type' => 'CollectionPage',
966 'mainEntity' => [
967 '@type' => 'ItemList',
968 'numberOfItems' => 0, // This should be dynamically populated
969 'itemListElement' => []
970 ]
971 ]);
972 }
973
974 /**
975 * Generate Place schema for destination pages
976 */
977 private function generatePlaceSchema(array $baseSchema): array
978 {
979 return array_merge($baseSchema, [
980 '@type' => 'Place',
981 'address' => $this->pageObject->address ?? '',
982 'geo' => [
983 '@type' => 'GeoCoordinates',
984 'latitude' => $this->pageObject->latitude ?? '',
985 'longitude' => $this->pageObject->longitude ?? ''
986 ]
987 ]);
988 }
989
990 /**
991 * Generate Article schema for content pages
992 */
993 private function generateArticleSchema(array $baseSchema): array
994 {
995 return array_merge($baseSchema, [
996 '@type' => 'Article',
997 'headline' => $this->seoData['title'],
998 'author' => [
999 '@type' => 'Organization',
1000 'name' => get_bloginfo('name')
1001 ]
1002 ]);
1003 }
1004
1005 /**
1006 * Generate Product schema for a single trip, with rating + reviews nested
1007 * INSIDE the product (the structure Google requires for review snippets).
1008 *
1009 * `aggregateRating` and `review` are added only when approved reviews exist —
1010 * an empty aggregateRating is itself a structured-data error. Each review
1011 * carries the required author / reviewRating / reviewBody / datePublished so
1012 * Google no longer reports "Missing field author" or "Missing field itemReviewed".
1013 */
1014 private function generateTripProductSchema(array $baseSchema): array
1015 {
1016 $trip = $this->pageObject;
1017
1018 $tripId = (\is_object($trip) && isset($trip->id)) ? (int) $trip->id : 0;
1019 $avg = (\is_object($trip) && \method_exists($trip, 'getAverageRating'))
1020 ? (float) $trip->getAverageRating()
1021 : 0.0;
1022 $count = (\is_object($trip) && \method_exists($trip, 'getReviewCount'))
1023 ? (int) $trip->getReviewCount()
1024 : 0;
1025
1026 // No approved reviews → keep the generic Article (unchanged behaviour).
1027 // A Product is emitted ONLY when there's a real rating to nest, which is
1028 // exactly what fixes the review markup — and it avoids emitting a bare
1029 // Product (offers/review/rating all absent) that Google would flag as
1030 // incomplete on the many tours that have no reviews yet.
1031 if (!($tripId > 0 && $count > 0 && $avg > 0)) {
1032 return $this->generateArticleSchema($baseSchema);
1033 }
1034
1035 $schema = [
1036 '@context' => 'https://schema.org',
1037 '@type' => 'Product',
1038 'name' => $this->seoData['title'],
1039 'url' => $this->seoData['url'],
1040 'brand' => [
1041 '@type' => 'Brand',
1042 'name' => get_bloginfo('name'),
1043 ],
1044 ];
1045 if (!empty($this->seoData['description'])) {
1046 $schema['description'] = $this->seoData['description'];
1047 }
1048 if (!empty($this->seoData['image'])) {
1049 $schema['image'] = $this->seoData['image'];
1050 }
1051
1052 $schema['aggregateRating'] = [
1053 '@type' => 'AggregateRating',
1054 'ratingValue' => (string) round($avg, 1),
1055 'reviewCount' => (string) $count,
1056 'bestRating' => '5',
1057 'worstRating' => '1',
1058 ];
1059
1060 $rows = [];
1061 try {
1062 $rows = (new \Yatra\Repositories\ReviewRepository())->findApprovedByTripId($tripId, 10);
1063 } catch (\Throwable $e) {
1064 $rows = [];
1065 }
1066
1067 $reviews = [];
1068 foreach ($rows as $row) {
1069 $rating = (int) ($row->rating ?? 0);
1070 if ($rating < 1 || $rating > 5) {
1071 continue;
1072 }
1073 // Strip tags on every user-derived value — even though the emitter
1074 // now \u-escapes < > &, keep the data itself clean/plain-text.
1075 $authorName = trim(\wp_strip_all_tags((string) ($row->author_name ?? $row->user_display_name ?? '')));
1076 if ($authorName === '') {
1077 $authorName = __('Anonymous', 'yatra');
1078 }
1079
1080 $review = [
1081 '@type' => 'Review',
1082 'author' => ['@type' => 'Person', 'name' => $authorName],
1083 'reviewRating' => [
1084 '@type' => 'Rating',
1085 'ratingValue' => (string) $rating,
1086 'bestRating' => '5',
1087 'worstRating' => '1',
1088 ],
1089 ];
1090
1091 $title = $this->sanitizeText(trim((string) ($row->title ?? '')));
1092 if ($title !== '') {
1093 $review['name'] = $title;
1094 }
1095 $body = $this->sanitizeText(trim(\wp_strip_all_tags((string) ($row->content ?? ''))));
1096 if ($body !== '') {
1097 $review['reviewBody'] = $body;
1098 }
1099 $created = (string) ($row->created_at ?? '');
1100 if ($created !== '') {
1101 $ts = strtotime($created);
1102 if ($ts) {
1103 $review['datePublished'] = date('Y-m-d', $ts);
1104 }
1105 }
1106
1107 $reviews[] = $review;
1108 }
1109
1110 if (!empty($reviews)) {
1111 $schema['review'] = $reviews;
1112 }
1113
1114 return $schema;
1115 }
1116
1117 /**
1118 * Truncate text to specified length
1119 */
1120 private function truncateText(string $text, int $length): string
1121 {
1122 if (strlen($text) <= $length) {
1123 return $text;
1124 }
1125
1126 return substr($text, 0, $length - 3) . '...';
1127 }
1128 }
1129