| @@ -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 | } |
| @@ -819,9 +920,9 @@ | ||
| 819 | 920 | 'url' => home_url() |
| 820 | 921 | ], |
| 821 | 922 | 'dateModified' => $this->seoData['modified_time'], |
| 822 | 923 | 'datePublished' => $this->seoData['published_time'], |
| 823 | - 'inLanguage' => 'en-US', | |
| 924 | + 'inLanguage' => $this->languageTag(), | |
| 824 | 925 | 'isPartOf' => [ |
| 825 | 926 | '@type' => 'WebSite', |
| 826 | 927 | 'name' => get_bloginfo('name'), |
| 827 | 928 | 'url' => home_url() |