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 +228 -6 3.0.8 → 3.0.16 View file →
@@ -630,8 +630,46 @@
630 630 return $raw !== '' ? $this->validateUrl($raw) : '';
631 631 }
632 632
633 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 + /**
634 672 * Output basic meta tags
635 673 */
636 674 private function outputBasicMetaTags(): void
637 675 {
@@ -648,9 +686,9 @@
648 686 * Output Open Graph meta tags
649 687 */
650 688 private function outputOpenGraphTags(): void
651 689 {
652 - echo '<meta property="og:locale" content="en_US">' . "\n";
690 + echo '<meta property="og:locale" content="' . esc_attr($this->ogLocale()) . '">' . "\n";
653 691 echo '<meta property="og:site_name" content="' . esc_attr(get_bloginfo('name')) . '">' . "\n";
654 692 echo '<meta property="og:title" content="' . esc_attr($this->seoData['title']) . '">' . "\n";
655 693 echo '<meta property="og:description" content="' . esc_attr($this->truncateText($this->seoData['description'], 160)) . '">' . "\n";
656 694 echo '<meta property="og:type" content="' . esc_attr($this->seoData['type']) . '">' . "\n";
@@ -686,18 +724,81 @@
686 724 }
687 725 }
688 726
689 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 + /**
690 791 * Output advanced meta tags
691 792 */
692 793 private function outputAdvancedMetaTags(): void
693 794 {
694 - 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";
695 796 echo '<link rel="canonical" href="' . esc_url($this->seoData['url']) . '">' . "\n";
696 797 echo '<meta name="author" content="' . esc_attr($this->seoData['author']) . '">' . "\n";
697 798 echo '<meta name="publisher" content="' . esc_attr($this->seoData['publisher']) . '">' . "\n";
698 799 echo '<meta name="lastmod" content="' . esc_attr($this->seoData['modified_time']) . '">' . "\n";
699 - 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";
700 801 echo '<meta name="revisit-after" content="7 days">' . "\n";
701 802 echo '<meta name="distribution" content="global">' . "\n";
702 803 echo '<meta name="rating" content="general">' . "\n";
703 804 }
@@ -794,9 +895,13 @@
794 895 private function outputSchemaMarkup(): void
795 896 {
796 897 $schema = $this->generateSchemaMarkup();
797 898 if (!empty($schema)) {
798 - 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";
799 904 }
800 905 }
801 906
802 907 /**
@@ -815,9 +920,9 @@
815 920 'url' => home_url()
816 921 ],
817 922 'dateModified' => $this->seoData['modified_time'],
818 923 'datePublished' => $this->seoData['published_time'],
819 - 'inLanguage' => 'en-US',
924 + 'inLanguage' => $this->languageTag(),
820 925 'isPartOf' => [
821 926 '@type' => 'WebSite',
822 927 'name' => get_bloginfo('name'),
823 928 'url' => home_url()
@@ -836,11 +941,16 @@
836 941 case self::PAGE_TYPE_CATEGORY_LISTING:
837 942 return $this->generateCollectionPageSchema($baseSchema);
838 943 case self::PAGE_TYPE_DESTINATION:
839 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);
840 951 case self::PAGE_TYPE_ACTIVITY:
841 952 case self::PAGE_TYPE_CATEGORY:
842 - case self::PAGE_TYPE_TRIP:
843 953 return $this->generateArticleSchema($baseSchema);
844 954 default:
845 955 return $baseSchema;
846 956 }
@@ -889,8 +999,120 @@
889 999 '@type' => 'Organization',
890 1000 'name' => get_bloginfo('name')
891 1001 ]
892 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;
893 1115 }
894 1116
895 1117 /**
896 1118 * Truncate text to specified length