absolute URL, 'lastmod' => W3C datetime|'']. */ class SitemapService { public const TYPE_ARCHIVE = 'archive'; public const TYPE_TRIP = 'trip'; public const TYPE_DESTINATION = 'destination'; public const TYPE_ACTIVITY = 'activity'; public const TYPE_CATEGORY = 'category'; /** Order entries appear in the consolidated sitemap. */ private const TYPES = [ self::TYPE_ARCHIVE, self::TYPE_TRIP, self::TYPE_DESTINATION, self::TYPE_ACTIVITY, self::TYPE_CATEGORY, ]; /** * Per-request memo. Held as a class property rather than a method static so * flushCache() can actually clear it — a flush that only dropped the * transient left this stale for the rest of the request. * * @var array|null */ private static $runtime = null; private const CACHE_KEY = 'yatra_sitemap_entries_all'; /** Setting holding the types the operator wants published. */ private const TYPES_SETTING = 'sitemap_types'; private const CACHE_TTL = HOUR_IN_SECONDS; /** sitemaps.org caps a single sitemap file at 50,000 URLs. */ private const MAX_URLS = 50000; /** * Every public Yatra URL, merged into one de-duplicated, cached list. * * @return array */ public function getAllEntries(): array { if (self::$runtime !== null) { return self::$runtime; } $cached = get_transient(self::CACHE_KEY); if (is_array($cached)) { self::$runtime = $cached; return $cached; } $entries = []; foreach ($this->enabledTypes() as $type) { foreach ($this->entriesForType($type) as $entry) { $entries[] = $entry; } } $entries = $this->dedupe($entries); /** * Final say over the sitemap's entries — lets integrations drop * noindex URLs, add multilingual alternates, etc. * * @param array $entries */ $entries = (array) apply_filters('yatra_sitemap_entries', $entries); // Never emit more than a single sitemap file may legally hold. Realistic // travel catalogues are orders of magnitude below this; the cap is a // safety valve so an extreme catalogue can't produce an invalid file. $max = (int) apply_filters('yatra_sitemap_max_urls', self::MAX_URLS); if ($max > 0 && count($entries) > $max) { $entries = array_slice($entries, 0, $max); } set_transient(self::CACHE_KEY, $entries, self::CACHE_TTL); self::$runtime = $entries; return $entries; } public function getCount(): int { return count($this->getAllEntries()); } /** * Newest lastmod across all entries (for index in SEO plugins). */ public function getLatestLastmod(): string { $latest = ''; foreach ($this->getAllEntries() as $entry) { if ($entry['lastmod'] !== '' && $entry['lastmod'] > $latest) { $latest = $entry['lastmod']; } } return $latest; } /** * Drop the cached list. Call when a trip or classification changes. */ /** * Types the operator has chosen to publish, in the canonical order. * * An unset or empty setting means "everything", which is what every site * had before this was configurable — so an upgrade changes nothing until * the operator actually makes a choice. * * @return string[] */ public function enabledTypes(): array { $selected = null; if (class_exists(SettingsService::class)) { $selected = SettingsService::get(self::TYPES_SETTING, null); } if (!is_array($selected) || $selected === []) { return self::TYPES; } $selected = array_map('strval', $selected); // Intersect rather than trust the stored list, so an unknown or stale // type can never introduce an entry type this class cannot build. $types = array_values(array_intersect(self::TYPES, $selected)); /** * Final say over which Yatra content types the sitemap publishes. * * @param string[] $types Selected types, canonical order. * @param string[] $selected Raw stored selection. */ return (array) apply_filters('yatra_sitemap_types', $types, $selected); } /** * Is this content type published in the sitemap? * * Also answers "should this page be noindexed?" for the SEO layer, which * is why it is public. */ public function isTypeEnabled(string $type): bool { return in_array($type, $this->enabledTypes(), true); } public static function flushCache(): void { self::$runtime = null; delete_transient(self::CACHE_KEY); } /** * @return array */ private function entriesForType(string $type): array { switch ($type) { case self::TYPE_ARCHIVE: return $this->archiveEntries(); case self::TYPE_TRIP: return $this->tripEntries(); case self::TYPE_DESTINATION: case self::TYPE_ACTIVITY: case self::TYPE_CATEGORY: return $this->classificationEntries($type); default: return []; } } /** * The trip listing page plus each taxonomy index page. * * @return array */ private function archiveEntries(): array { $entries = []; $listing = yatra_get_trip_listing_url(); if ($listing !== '') { $entries[] = ['loc' => $listing, 'lastmod' => '']; } if (function_exists('yatra_get_taxonomy_listing_url')) { foreach (['destination', 'activity', 'trip-category'] as $listingType) { $url = yatra_get_taxonomy_listing_url($listingType); if ($url !== '' && $url !== home_url('/')) { $entries[] = ['loc' => $url, 'lastmod' => '']; } } } return $entries; } /** * @return array */ private function tripEntries(): array { global $wpdb; $table = TripsTable::getTableName(); $rows = $wpdb->get_results( "SELECT id, slug, updated_at FROM `{$table}` WHERE status IN ('publish', 'published') AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00') AND slug <> '' ORDER BY updated_at DESC" ); $entries = []; foreach ((array) $rows as $row) { $loc = yatra_get_trip_permalink($row); if ($loc === '') { continue; } $entries[] = ['loc' => $loc, 'lastmod' => $this->toW3c((string) ($row->updated_at ?? ''))]; } return $entries; } /** * @return array */ private function classificationEntries(string $type): array { global $wpdb; $table = ClassificationsTable::getTableName(); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, slug, updated_at FROM `{$table}` WHERE type = %s AND status = 'publish' AND slug <> '' ORDER BY updated_at DESC", $type ) ); $resolver = $this->permalinkResolver($type); $entries = []; foreach ((array) $rows as $row) { $loc = $resolver($row); if ($loc === '') { continue; } $entries[] = ['loc' => $loc, 'lastmod' => $this->toW3c((string) ($row->updated_at ?? ''))]; } return $entries; } /** * @return callable(object): string */ private function permalinkResolver(string $type): callable { switch ($type) { case self::TYPE_DESTINATION: return static fn ($row) => yatra_get_destination_permalink($row); case self::TYPE_ACTIVITY: return static fn ($row) => yatra_get_activity_permalink($row); case self::TYPE_CATEGORY: return static fn ($row) => yatra_get_category_permalink($row); default: return static fn ($row) => ''; } } /** * Convert a MySQL datetime to a W3C / ISO-8601 string for . */ private function toW3c(string $mysqlDate): string { $mysqlDate = trim($mysqlDate); if ($mysqlDate === '' || strpos($mysqlDate, '0000-00-00') === 0) { return ''; } $timestamp = strtotime($mysqlDate); if ($timestamp === false) { return ''; } return gmdate('Y-m-d\TH:i:s+00:00', $timestamp); } /** * @param array $entries * @return array */ private function dedupe(array $entries): array { $seen = []; $out = []; foreach ($entries as $entry) { $loc = $entry['loc']; if ($loc === '' || isset($seen[$loc])) { continue; } $seen[$loc] = true; $out[] = $entry; } return $out; } }