enabled( 'enable_sitemap' ) && SeoContext::solo(); } /** * Routing verdict for a request path, promotion-only. Outside promotion * every path passes through untouched: core or a suite owns these * addresses then, and the coexist fallback keeps its legacy serving with * no redirects. * * @param string $path Request path, e.g. '/wp-sitemap.xml'. * @return string 'index' (serve the promoted index here), 'redirect' * (301 to INDEX_PATH), or '' (not ours to answer). */ public static function route_for( $path ) { if ( ! self::promoted() ) { return ''; } if ( self::INDEX_PATH === $path ) { return 'index'; } if ( '/sitemap.xml' === $path || self::PATH === $path ) { return 'redirect'; } return ''; } /** * Resolve the live sitemap, in priority order: * 0. The solo-mode promotion — Agentimus's own sitemap wins outright. * 1. WordPress core sitemaps (on by default since 5.5). * 2. A known SEO plugin (which would have disabled core). * 3. Agentimus's own generator as opt-in fallback — ONLY if neither of the * above provides one, so coexist mode never emits a duplicate. * * When nothing provides one, `url` is an empty string — callers must treat * that as "no sitemap to advertise" rather than guessing a URL that may 404. * * @return array{url:string,source:string,label:string} */ public static function detect() { // 0. Solo-mode promotion (the wp_sitemaps_enabled filter above keeps // core's duplicate off for exactly the requests this branch wins on). // Advertised at the canonical address — robots.txt, llms.txt and the // discovery document all name the URL that never moves. if ( self::promoted() ) { return array( 'url' => home_url( self::INDEX_PATH ), 'source' => 'agentimus', 'label' => __( 'Agentimus', 'agentimus' ), ); } // 1. WordPress core sitemaps. if ( function_exists( 'wp_sitemaps_get_server' ) ) { $server = wp_sitemaps_get_server(); if ( $server && $server->sitemaps_enabled() ) { return array( 'url' => home_url( '/wp-sitemap.xml' ), 'source' => 'core', 'label' => __( 'WordPress core', 'agentimus' ), ); } } // 2. A known SEO plugin owns the sitemap — link its real location. $suite = SeoContext::detected(); if ( null !== $suite ) { return array( 'url' => home_url( $suite['sitemap_path'] ), 'source' => $suite['id'], 'label' => $suite['label'], ); } // 3. Agentimus's opt-in fallback — fills the gap when nobody else does. if ( ( new Settings() )->enabled( 'enable_sitemap' ) ) { return array( 'url' => home_url( self::PATH ), 'source' => 'agentimus', 'label' => __( 'Agentimus', 'agentimus' ), ); } // 4. Nothing detected. $none = array( 'url' => '', 'source' => '', 'label' => '', ); /** * Filter the detected sitemap (lets an add-on declare one Agentimus can't * see, e.g. a less common plugin or a hand-rolled file). * * @param array $none The "no sitemap" result. */ return apply_filters( 'agentimus_sitemap', $none ); } /* ---------------------------------------------------------------------- * * Fallback generator — a real sitemap *index* plus paginated sub-sitemaps, * the same shape core and the SEO plugins ship, so big sites are covered * too. The index lives at PATH; each sub-sitemap is one content type, one * page of up to per_page() URLs, newest-modified first. * ---------------------------------------------------------------------- */ /** * Render the document for a sitemap request path, or '' if the path is not a * valid Agentimus sitemap (so the caller can 404 it). Results are cached for * an hour, namespaced by a generation token that Cache::flush() resets on * any content change. * * @param string $path Request path, e.g. '/agentimus-sitemap.xml' or * '/agentimus-sitemap-post-2.xml'. * @return string */ public static function body( $path ) { if ( self::PATH === $path ) { return self::cached( $path, array( __CLASS__, 'index_xml' ) ); } // /agentimus-sitemap-{type}-{page}.xml — {type} may contain hyphens, so // anchor on the trailing -{digits}.xml and treat the rest as the type. if ( preg_match( '#^/agentimus-sitemap-(.+)-(\d+)\.xml$#', $path, $m ) ) { $type = $m[1]; $page = (int) $m[2]; if ( $page < 1 || ! in_array( $type, Content::post_types(), true ) ) { return ''; } if ( $page > self::page_count( $type ) ) { return ''; } return self::cached( $path, static function () use ( $type, $page ) { return self::sub_xml( $type, $page ); } ); } return ''; } /** * The sitemap index: one entry per content-type page, each with the * newest in that page so crawlers know which sub-sitemaps changed. * * @return string */ private static function index_xml() { $entries = ''; foreach ( Content::post_types() as $type ) { $pages = self::page_count( $type ); for ( $page = 1; $page <= $pages; $page++ ) { $loc = home_url( self::sub_path( $type, $page ) ); $lastmod = self::page_lastmod( $type, $page ); $entries .= "\t\n\t\t" . esc_url( $loc ) . "\n"; if ( '' !== $lastmod ) { $entries .= "\t\t" . esc_html( $lastmod ) . "\n"; } $entries .= "\t\n"; } } $xml = '' . "\n"; $xml .= '' . "\n"; $xml .= $entries; $xml .= '' . "\n"; return $xml; } /** * One sub-sitemap: a urlset for a single content type and page. The very * first sub-sitemap also carries the home page URL (deduped against a static * front page), mirroring how WordPress core seeds the front page. * * @param string $type Post type. * @param int $page 1-based page. * @return string */ private static function sub_xml( $type, $page ) { $query = new \WP_Query( array( 'post_type' => $type, 'post_status' => 'publish', 'has_password' => false, // Never list URLs the HTML site gates behind a password (matches core's sitemap). 'posts_per_page' => self::per_page(), 'paged' => $page, 'orderby' => 'modified', 'order' => 'DESC', 'no_found_rows' => true, 'ignore_sticky_posts' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ) ); $home = untrailingslashit( home_url( '/' ) ); $nodes = ''; if ( self::is_first_page( $type, $page ) ) { $nodes .= self::url_node( home_url( '/' ), self::home_lastmod() ); } foreach ( $query->posts as $post ) { $loc = (string) get_permalink( $post ); // A static front page resolves to the home URL — emitted once, above. if ( untrailingslashit( $loc ) === $home ) { continue; } $nodes .= self::url_node( $loc, (string) get_post_modified_time( 'c', true, $post ) ); } $xml = '' . "\n"; $xml .= '' . "\n"; $xml .= $nodes; $xml .= '' . "\n"; return $xml; } /** * One entry. * * @param string $loc Absolute URL. * @param string $lastmod ISO-8601 timestamp, or '' to omit. * @return string */ private static function url_node( $loc, $lastmod ) { $node = "\t\n\t\t" . esc_url( $loc ) . "\n"; if ( '' !== $lastmod ) { $node .= "\t\t" . esc_html( $lastmod ) . "\n"; } return $node . "\t\n"; } /** Sub-sitemap path for a type + page. */ private static function sub_path( $type, $page ) { return '/agentimus-sitemap-' . $type . '-' . (int) $page . '.xml'; } /** Max URLs per sub-sitemap (protocol ceiling is 50k). */ private static function per_page() { $n = (int) apply_filters( 'agentimus_sitemap_max_urls', 2000 ); return max( 1, min( 50000, $n ) ); } /** * Published, non-password-protected count for a type — must match what * sub_xml() actually lists, so pagination never declares a page that the * password filter leaves short or empty. (wp_count_posts() can't exclude * password-protected posts, so we count via WP_Query.) */ private static function published_count( $type ) { $q = new \WP_Query( array( 'post_type' => $type, 'post_status' => 'publish', 'has_password' => false, 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => false, 'ignore_sticky_posts' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ) ); return (int) $q->found_posts; } /** Number of sub-sitemap pages a type needs (0 if it has no content). */ private static function page_count( $type ) { return (int) ceil( self::published_count( $type ) / self::per_page() ); } /** Whether this is the globally-first sub-sitemap (gets the home page URL). */ private static function is_first_page( $type, $page ) { if ( 1 !== (int) $page ) { return false; } foreach ( Content::post_types() as $candidate ) { if ( self::published_count( $candidate ) > 0 ) { return $candidate === $type; } } return false; } /** Newest within a type's page (page is newest-first). */ private static function page_lastmod( $type, $page ) { $ids = get_posts( array( 'post_type' => $type, 'post_status' => 'publish', 'has_password' => false, 'orderby' => 'modified', 'order' => 'DESC', 'numberposts' => 1, 'offset' => ( (int) $page - 1 ) * self::per_page(), 'fields' => 'ids', 'suppress_filters' => false, ) ); return $ids ? (string) get_post_modified_time( 'c', true, $ids[0] ) : ''; } /** lastmod for the home URL: the front page's, else the newest content's. */ private static function home_lastmod() { $front = (int) get_option( 'page_on_front' ); if ( $front && 'publish' === get_post_status( $front ) ) { return (string) get_post_modified_time( 'c', true, $front ); } $ids = get_posts( array( 'post_type' => Content::post_types(), 'post_status' => 'publish', 'has_password' => false, 'orderby' => 'modified', 'order' => 'DESC', 'numberposts' => 1, 'fields' => 'ids', 'suppress_filters' => false, ) ); return $ids ? (string) get_post_modified_time( 'c', true, $ids[0] ) : ''; } /** * Return a cached render, or build + cache it. Keyed by a generation token * so Cache::flush() (content changes) invalidates every page at once without * having to enumerate them. * * @param string $path Request path (cache identity). * @param callable $builder Produces the XML. * @return string */ private static function cached( $path, $builder ) { $key = self::cache_key( $path ); $hit = get_transient( $key ); if ( is_string( $hit ) && '' !== $hit ) { return $hit; } // Cold cache. Take a short build lock so a crawler burst on the sitemap can't // each run the DB-heavy generation at once. If we don't win the lock, re-read // the transient — the winner may have just filled it — and serve that; only // build ourselves when it is still cold, so a real sitemap URL never 404s // under load. (A true mutex only on object-cache sites; see Cache::acquire_lock.) $locked = Cache::acquire_lock( $key ); if ( ! $locked ) { $hit = get_transient( $key ); if ( is_string( $hit ) && '' !== $hit ) { return $hit; } } try { $xml = (string) call_user_func( $builder ); } finally { if ( $locked ) { Cache::release_lock( $key ); } } if ( '' !== $xml ) { set_transient( $key, $xml, HOUR_IN_SECONDS ); } return $xml; } /** Generation-namespaced transient key for a sitemap path. */ private static function cache_key( $path ) { $gen = get_transient( Cache::SITEMAP_GEN ); if ( ! $gen ) { $gen = (string) time(); set_transient( Cache::SITEMAP_GEN, $gen, DAY_IN_SECONDS ); } return 'agentimus_sm_' . $gen . '_' . md5( $path ); } /** * Best-effort sitemap URL, or '' when none is detectable. * * @return string */ public static function url() { $detected = self::detect(); return isset( $detected['url'] ) ? (string) $detected['url'] : ''; } }