URLs collected this request, deduped, flushed on shutdown. */ private static $queue = array(); /** @var bool Whether the shutdown flush is already scheduled. */ private static $scheduled = false; /** * Whether auto-purge is on. Setting (default true), overridable by code. * * @return bool */ public static function enabled() { $on = (bool) ( new Settings() )->get( 'purge_cache_on_change', true ); /** * Turn the automatic cache purge of the agent files on/off. * * @param bool $on Whether to purge on a content change. */ return (bool) apply_filters( 'agentimus_purge_on_change', $on ); } /** * Register the content-change hooks. No-op when disabled. */ public static function boot() { if ( ! self::enabled() ) { return; } // The site-wide files change on ANY content/identity change — reuse the exact seam // the internal caches fire from, so the two never disagree about "content changed". add_action( 'agentimus_cache_flushed', array( __CLASS__, 'queue_site_files' ) ); // The edited/removed post's own .md twin — a URL generic cache plugins never purge. add_action( 'save_post', array( __CLASS__, 'queue_post' ), 20, 1 ); add_action( 'before_delete_post', array( __CLASS__, 'queue_post' ), 20, 1 ); } /** * Queue the site-wide agent files for purge. */ public static function queue_site_files() { $urls = self::site_urls(); // The front page's listings change on every content change. Caching // plugins purge it themselves — only the edge needs telling, so it // joins the set only when the automatic edge purge will actually run. if ( Cloudflare\Purge::armed() ) { $urls[] = home_url( '/' ); } self::queue( $urls ); } /** * Queue one post's `.md` twin, skipping revisions/autosaves and non-public types. * * @param int $post_id Post ID. */ public static function queue_post( $post_id ) { $post_id = (int) $post_id; if ( ! $post_id || wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return; } if ( ! is_post_type_viewable( get_post_type( $post_id ) ) ) { return; // Nav-menu items, non-public types, … have no public .md. } $permalink = get_permalink( $post_id ); if ( $permalink ) { $urls = array( untrailingslashit( $permalink ) . '.md' ); // The edited page itself: every caching plugin already purges it, // but none of them can reach Cloudflare — an edited post keeps // serving stale from the edge until its TTL runs out. It joins the // set only when the automatic edge purge will actually run. if ( Cloudflare\Purge::armed() ) { $urls[] = (string) $permalink; } self::queue( $urls ); } } /** * The site-wide agent files, as absolute URLs. * * @return string[] */ public static function site_urls() { $urls = array( home_url( '/llms.txt' ), home_url( '/llms-full.txt' ), home_url( '/robots.txt' ), home_url( '/agentimus-changes.json' ), ); foreach ( array( 'discovery.json', 'agent-card.json', 'agent.json', 'mcp.json', 'openapi.json', 'tdmrep.json', 'security.txt' ) as $doc ) { $urls[] = home_url( '/.well-known/' . $doc ); } return $urls; } /** * Add URLs to the request's purge set and arrange a single flush at shutdown, so a bulk * edit of many posts purges `llms.txt` once, not once per post. * * @param string[] $urls URLs to purge. */ private static function queue( array $urls ) { foreach ( $urls as $url ) { if ( '' !== (string) $url ) { self::$queue[ (string) $url ] = true; } } if ( ! self::$scheduled && ! empty( self::$queue ) ) { self::$scheduled = true; add_action( 'shutdown', array( __CLASS__, 'flush_queue' ) ); } } /** * Purge everything queued this request. */ public static function flush_queue() { $urls = array_keys( self::$queue ); self::$queue = array(); // Every adapter here is local except Cloudflare — a real network call. // When that call is about to happen, close the visitor's connection // first where the server allows it (PHP-FPM): the response is already // built at shutdown, so the save returns instantly and the edge purge // finishes in the background of this same process — outcome still read, // still recorded. Elsewhere the short AUTO_TIMEOUT bounds the wait. if ( ! empty( $urls ) && function_exists( 'fastcgi_finish_request' ) && Cloudflare\Purge::armed() ) { fastcgi_finish_request(); } self::purge( $urls ); } /** * The final, deduped URL set to purge — empties dropped, duplicates collapsed, and the * `agentimus_purge_urls` filter applied. Pure (given the filter); unit-tested. * * @param string[] $urls Candidate URLs. * @return string[] */ public static function urls_to_purge( array $urls ) { $urls = array_values( array_unique( array_filter( array_map( 'strval', $urls ) ) ) ); /** * The exact URLs Agentimus asks the page cache to drop on a content change. * * @param string[] $urls Absolute URLs. */ return array_values( array_filter( (array) apply_filters( 'agentimus_purge_urls', $urls ) ) ); } /** * Ask every detected page cache to drop these URLs. Each adapter is guarded so only an * installed cache is called; the `agentimus_purge_url` action lets any other cache in. * * @param string[] $urls URLs to purge. */ public static function purge( array $urls ) { $urls = self::urls_to_purge( $urls ); if ( empty( $urls ) ) { return; } // WP Rocket — takes the whole list at once. if ( function_exists( 'rocket_clean_files' ) ) { rocket_clean_files( $urls ); } // Cloudflare — also the whole list at once (batched by the API's own // limit). Quiet no-op unless the edge source is connected; a refusal // (e.g. a token without the purge permission) is recorded on the // connection and shown in the edge panel's rail, never fatal here. Cloudflare\Purge::purge_urls( $urls ); foreach ( $urls as $url ) { // Nginx Helper — the global purger exposes a per-URL purge. if ( isset( $GLOBALS['nginx_purger'] ) && is_object( $GLOBALS['nginx_purger'] ) && method_exists( $GLOBALS['nginx_purger'], 'purge_url' ) ) { $GLOBALS['nginx_purger']->purge_url( $url, false ); } // W3 Total Cache. if ( function_exists( 'w3tc_flush_url' ) ) { w3tc_flush_url( $url ); } // WP Super Cache (1.7+). if ( function_exists( 'wpsc_delete_url_cache' ) ) { wpsc_delete_url_cache( $url ); } // Cache Enabler. if ( is_callable( array( 'Cache_Enabler', 'clear_page_cache_by_url' ) ) ) { \Cache_Enabler::clear_page_cache_by_url( $url ); } // LiteSpeed Cache, and any other cache / custom integration. The hook name is // LiteSpeed's, not ours — prefixing it with `agentimus_` (as the naming sniff wants) // would mean LiteSpeed never hears it, which is the entire point of firing it. do_action( 'litespeed_purge_url', $url ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- third-party hook we deliberately fire. /** * Purge a single agent-file URL from a cache Agentimus doesn't natively support. * * @param string $url Absolute URL to purge. */ do_action( 'agentimus_purge_url', $url ); } /** * Fires after Agentimus has asked the caches to drop this content change's URLs. * * @param string[] $urls The purged URLs. */ do_action( 'agentimus_purged', $urls ); } }