| @@ -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 |