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
← All changes | app/Services/SEOService.php +265 -15 3.0.7 → 3.0.16 View file →
@@ -233,8 +233,30 @@
233 233 return wp_strip_all_tags(strip_tags($text));
234 234 }
235 235
236 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 + /**
237 259 * Validate and sanitize URL
238 260 *
239 261 * @param string $url URL to validate
240 262 * @return string Validated URL
@@ -413,13 +435,15 @@
413 435 $metadata = \is_array($maybe) ? $maybe : [];
414 436 }
415 437 }
416 438
439 + $name = (string) ($destination->name ?? '');
417 440 $title = $metadata['seo_title'] ?? $destination->name ?? '';
418 - $this->seoData['title'] = $this->sanitizeText((string) $title);
441 + $this->seoData['title'] = $this->sanitizeText($this->expandTermTokens((string) $title, $name));
419 442 $descRaw = $metadata['seo_description'] ?? $destination->description ?? '';
420 - $this->seoData['description'] = $this->sanitizeText(\wp_trim_words(\wp_strip_all_tags((string) $descRaw), 45, '…'));
421 - $this->seoData['keywords'] = $this->sanitizeText((string) ($metadata['seo_keywords'] ?? ''));
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));
422 446
423 447 // Get featured image or gallery image
424 448 $this->seoData['image'] = $destination->featured_image_url ??
425 449 ($destination->gallery_images[0] ?? '');
@@ -452,12 +476,14 @@
452 476 $metadata = \is_array($maybe) ? $maybe : [];
453 477 }
454 478 }
455 479
456 - $this->seoData['title'] = $this->sanitizeText((string) ($metadata['seo_title'] ?? $activity->name ?? ''));
480 + $name = (string) ($activity->name ?? '');
481 + $this->seoData['title'] = $this->sanitizeText($this->expandTermTokens((string) ($metadata['seo_title'] ?? $activity->name ?? ''), $name));
457 482 $descRaw = $metadata['seo_description'] ?? $activity->description ?? '';
458 - $this->seoData['description'] = $this->sanitizeText(\wp_trim_words(\wp_strip_all_tags((string) $descRaw), 45, '…'));
459 - $this->seoData['keywords'] = $this->sanitizeText((string) ($metadata['seo_keywords'] ?? ''));
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));
460 486
461 487 // Get featured image or gallery image
462 488 $this->seoData['image'] = $activity->featured_image_url ??
463 489 ($activity->gallery_images[0] ?? '');
@@ -490,12 +516,14 @@
490 516 $metadata = \is_array($maybe) ? $maybe : [];
491 517 }
492 518 }
493 519
494 - $this->seoData['title'] = $this->sanitizeText((string) ($metadata['seo_title'] ?? $category->name ?? ''));
520 + $name = (string) ($category->name ?? '');
521 + $this->seoData['title'] = $this->sanitizeText($this->expandTermTokens((string) ($metadata['seo_title'] ?? $category->name ?? ''), $name));
495 522 $descRaw = $metadata['seo_description'] ?? $category->description ?? '';
496 - $this->seoData['description'] = $this->sanitizeText(\wp_trim_words(\wp_strip_all_tags((string) $descRaw), 45, '…'));
497 - $this->seoData['keywords'] = $this->sanitizeText((string) ($metadata['seo_keywords'] ?? ''));
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));
498 526
499 527 // Get featured image or gallery image
500 528 $this->seoData['image'] = $category->featured_image_url ??
501 529 ($category->gallery_images[0] ?? '');
@@ -602,8 +630,46 @@
602 630 return $raw !== '' ? $this->validateUrl($raw) : '';
603 631 }
604 632
605 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 + /**
606 672 * Output basic meta tags
607 673 */
608 674 private function outputBasicMetaTags(): void
609 675 {
@@ -620,9 +686,9 @@
620 686 * Output Open Graph meta tags
621 687 */
622 688 private function outputOpenGraphTags(): void
623 689 {
624 - echo '<meta property="og:locale" content="en_US">' . "\n";
690 + echo '<meta property="og:locale" content="' . esc_attr($this->ogLocale()) . '">' . "\n";
625 691 echo '<meta property="og:site_name" content="' . esc_attr(get_bloginfo('name')) . '">' . "\n";
626 692 echo '<meta property="og:title" content="' . esc_attr($this->seoData['title']) . '">' . "\n";
627 693 echo '<meta property="og:description" content="' . esc_attr($this->truncateText($this->seoData['description'], 160)) . '">' . "\n";
628 694 echo '<meta property="og:type" content="' . esc_attr($this->seoData['type']) . '">' . "\n";
@@ -658,18 +724,81 @@
658 724 }
659 725 }
660 726
661 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 + /**
662 791 * Output advanced meta tags
663 792 */
664 793 private function outputAdvancedMetaTags(): void
665 794 {
666 - echo '<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1">' . "\n";
795 + echo '<meta name="robots" content="' . esc_attr($this->robotsDirective()) . '">' . "\n";
667 796 echo '<link rel="canonical" href="' . esc_url($this->seoData['url']) . '">' . "\n";
668 797 echo '<meta name="author" content="' . esc_attr($this->seoData['author']) . '">' . "\n";
669 798 echo '<meta name="publisher" content="' . esc_attr($this->seoData['publisher']) . '">' . "\n";
670 799 echo '<meta name="lastmod" content="' . esc_attr($this->seoData['modified_time']) . '">' . "\n";
671 - echo '<link rel="alternate" hreflang="en-US" href="' . esc_url($this->seoData['url']) . '">' . "\n";
800 + echo '<link rel="alternate" hreflang="' . esc_attr($this->languageTag()) . '" href="' . esc_url($this->seoData['url']) . '">' . "\n";
672 801 echo '<meta name="revisit-after" content="7 days">' . "\n";
673 802 echo '<meta name="distribution" content="global">' . "\n";
674 803 echo '<meta name="rating" content="general">' . "\n";
675 804 }
@@ -766,9 +895,13 @@
766 895 private function outputSchemaMarkup(): void
767 896 {
768 897 $schema = $this->generateSchemaMarkup();
769 898 if (!empty($schema)) {
770 - echo '<script type="application/ld+json">' . json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . '</script>' . "\n";
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";
771 904 }
772 905 }
773 906
774 907 /**
@@ -787,9 +920,9 @@
787 920 'url' => home_url()
788 921 ],
789 922 'dateModified' => $this->seoData['modified_time'],
790 923 'datePublished' => $this->seoData['published_time'],
791 - 'inLanguage' => 'en-US',
924 + 'inLanguage' => $this->languageTag(),
792 925 'isPartOf' => [
793 926 '@type' => 'WebSite',
794 927 'name' => get_bloginfo('name'),
795 928 'url' => home_url()
@@ -808,11 +941,16 @@
808 941 case self::PAGE_TYPE_CATEGORY_LISTING:
809 942 return $this->generateCollectionPageSchema($baseSchema);
810 943 case self::PAGE_TYPE_DESTINATION:
811 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);
812 951 case self::PAGE_TYPE_ACTIVITY:
813 952 case self::PAGE_TYPE_CATEGORY:
814 - case self::PAGE_TYPE_TRIP:
815 953 return $this->generateArticleSchema($baseSchema);
816 954 default:
817 955 return $baseSchema;
818 956 }
@@ -861,8 +999,120 @@
861 999 '@type' => 'Organization',
862 1000 'name' => get_bloginfo('name')
863 1001 ]
864 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;
865 1115 }
866 1116
867 1117 /**
868 1118 * Truncate text to specified length