settings = $settings; $this->builder = $builder; $this->global_settings = $global_settings; } public function register(): void { add_action( 'parse_request', array( $this, 'maybe_serve' ) ); } public static function has_physical_file(): bool { return file_exists( ABSPATH . 'llms.txt' ); } public function maybe_serve( \WP $wp ): void { $content = $this->content_for_request( (string) $wp->request ); if ( null === $content ) { return; } header( 'Content-Type: text/plain; charset=utf-8' ); // M57 — the file is meant for LLM crawlers, not search engines: this // prevents Google et al. from indexing the raw text file itself and // showing it as a search result. M61 — explicit opt-in (with an // SEO-risk warning in the tab) lifts this, same "conscious yes" // doctrine as llms_enabled itself. if ( ! (bool) $this->settings->get()['llms_sitemap_opt_in'] ) { header( 'X-Robots-Tag: noindex' ); } echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- this is a text/plain response body (Content-Type set above), not HTML. esc_html() here would corrupt the file it serves — turning & into & inside every URL and breaking the Markdown the llms.txt spec asks for. The content comes from LlmsTxtBuilder, which composes it from settings already sanitized on save and from core post data; no request input reaches it. exit; } /** * Split out from maybe_serve() so tests can exercise the full gate logic * without the exit. Null = not our request / must not be served. */ public function content_for_request( string $request_path ): ?string { if ( 'llms.txt' !== untrailingslashit( $request_path ) ) { return null; } // The web server serves a physical file itself — if the request still // reached WP, do not shadow the file with generated content. if ( self::has_physical_file() ) { return null; } $settings = $this->settings->get(); if ( ! (bool) $settings['llms_enabled'] ) { return null; } // A site hidden from search engines must not feed LLMs either — // consistent with the sitemap being disabled by HideFromSearch. if ( (bool) ( $this->global_settings->get()['hide_from_search'] ?? false ) ) { return null; } // Between-request cache, same pattern as GraphCache for the JSON-LD // graph (M52) — bots crawl this file often. Free since M69 Etap 1: // M57 scoped caching as a Pro extra, but gating PERFORMANCE of purely // local code behind a licence is the least defensible shape of // WP.org's trialware objection, and unlocking it has no downside. // CacheVersion is bumped whenever `acez_seo_ai_bots` (or any // acez_seo_* option) is saved, so editing the intro/footer/toggle // invalidates it automatically. $cache_key = 'llms_txt_v' . CacheVersion::get(); $cached = GraphCache::get( $cache_key ); if ( null !== $cached ) { return $cached; } $content = $this->builder->build( (string) $settings['llms_intro'], (string) $settings['llms_footer'], (string) $settings['llms_title'], (string) $settings['llms_description'] ) . "\n"; GraphCache::set( $cache_key, $content, HOUR_IN_SECONDS ); return $content; } }