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