settings = $settings; $this->llms = new LlmsText( $settings ); } /** * Hook the front-end routes and filters. */ public function register() { add_action( 'template_redirect', array( $this, 'route' ), 0 ); // Late, so link_headers() can see — and avoid duplicating — Link headers a // theme already emitted this request (zero-config de-dupe). add_action( 'send_headers', array( $this, 'link_headers' ), 99 ); add_action( 'send_headers', array( $this, 'ai_signal_headers' ), 99 ); // Mirror the two most useful discovery links into the HTML
too — some // crawlers/scanners read the markup but not the HTTP Link header. add_action( 'wp_head', array( $this, 'head_links' ), 2 ); add_filter( 'robots_txt', array( $this, 'robots_txt' ), 20, 2 ); // Re-warm the heavy full-text edition out-of-band after content changes. add_action( 'agentimus_cache_flushed', array( $this, 'schedule_warm' ) ); add_action( 'agentimus_warm_llms_full', array( $this, 'warm_llms_full' ) ); } /** * On a content change (cache flush), schedule ONE debounced WP-Cron event to * regenerate /llms-full.txt — so a crawler rarely pays cold-cache generation. A * burst of edits coalesces into a single pending warm. WP-Cron is request- * triggered, so this is best-effort (the bounded generation in llms_full_txt() * is the real safety net), not a guarantee. */ public function schedule_warm() { if ( ! $this->settings->enabled( 'enable_llms_full' ) ) { return; } if ( function_exists( 'wp_next_scheduled' ) && function_exists( 'wp_schedule_single_event' ) && ! wp_next_scheduled( 'agentimus_warm_llms_full' ) ) { wp_schedule_single_event( time() + 30, 'agentimus_warm_llms_full' ); } } /** * Cron callback: regenerate the full-text edition into cache (bounded by the * size/time budget). No-op when the feature is off or ceded to another producer. */ public function warm_llms_full() { if ( ! $this->settings->enabled( 'enable_llms_full' ) || $this->yields( 'llms_full' ) ) { return; } $this->llms->llms_full_txt(); } /** * Route the agent-facing endpoints before the normal template loads. * Explicit paths win first, then `Accept` negotiation on the resolved view. */ public function route() { if ( is_admin() || is_feed() || is_embed() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ) { return; } $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET'; if ( 'GET' !== $method && 'HEAD' !== $method ) { return; } $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; $path = '/' . ltrim( (string) wp_parse_url( $uri, PHP_URL_PATH ), '/' ); // Guarantee a 200 robots.txt. WordPress's virtual robots.txt can come back 404 // on some hosts — the body is served but the status is wrong, which a strict // crawler may disregard, taking our crawler policy and sitemap line with it. When // there's no static file and robots rules are on, serve it here on a clean 200, // running the same robots_txt filter chain so core, SEO-plugin and our own // directives all survive. A producer can cede it via agentimus_yield_surface. if ( '/robots.txt' === $path && $this->settings->enabled( 'enable_robots' ) && ! $this->yields( 'robots' ) && ! file_exists( Paths::site_root() . 'robots.txt' ) ) { $this->send_robots( $this->robots_body() ); } if ( '/llms.txt' === $path && $this->settings->enabled( 'enable_llms_txt' ) && ! $this->yields( 'llms_txt' ) ) { $this->send( $this->llms->llms_txt(), 'text/plain', 'llms.txt' ); } if ( '/llms-full.txt' === $path && $this->settings->enabled( 'enable_llms_full' ) && ! $this->yields( 'llms_full' ) ) { $this->send( $this->llms->llms_full_txt(), 'text/plain', 'llms-full.txt' ); } // The change feed: recently added/updated content as JSON, with a `?since=` // delta filter. Freshness is the point, so it gets a short edge max-age. if ( '/agentimus-changes.json' === $path && $this->settings->enabled( 'enable_changes' ) && ! $this->yields( 'changes' ) ) { // Public, read-only endpoint: `since` is a filter value, not a state // change, so no nonce applies. $since = isset( $_GET['since'] ) ? sanitize_text_field( wp_unslash( $_GET['since'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $this->send( Changes::document( $this->settings, $since ), 'application/json', 'changes', 300 ); } // The opt-in fallback sitemap (index + paginated sub-sitemaps) — served // only while Agentimus actually owns the sitemap (core/SEO absent), so we // never shadow another plugin's file. if ( 0 === strpos( $path, '/agentimus-sitemap' ) && '.xml' === substr( $path, -4 ) ) { if ( 'agentimus' === Sitemap::detect()['source'] ) { $body = Sitemap::body( $path ); if ( '' !== $body ) { $this->send( $body, 'application/xml', 'sitemap' ); } } return; // Unknown/inactive sitemap path: let WordPress 404 it normally. } if ( ! $this->settings->enabled( 'enable_markdown' ) || $this->yields( 'markdown' ) ) { return; } // Parallel markdown URL: /slug.md, /index.md (home), etc. if ( '.md' === substr( $path, -3 ) ) { $clean = substr( $path, 0, -3 ); if ( '' === $clean || '/' === $clean || '/index' === $clean ) { $this->send( $this->llms->index_markdown(), 'text/markdown', 'markdown' ); } $post_id = url_to_postid( home_url( trailingslashit( $clean ) ) ); if ( ! $post_id ) { $post_id = url_to_postid( home_url( $clean ) ); } if ( $post_id && $this->post_in_scope( $post_id ) ) { $this->send( MarkdownCache::post( $post_id ), 'text/markdown', 'markdown' ); } return; // Unknown / out-of-scope .md path: let WordPress 404 normally. } // Content negotiation on the resolved view — OFF by default; see // {@see negotiates_markdown()} for why. if ( ! self::negotiates_markdown() ) { return; } $accept = isset( $_SERVER['HTTP_ACCEPT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT'] ) ) : ''; if ( ! self::prefers_markdown( $accept ) ) { return; } if ( is_singular() ) { $id = get_queried_object_id(); if ( $id && $this->post_in_scope( $id ) ) { $this->send( MarkdownCache::post( $id ), 'text/markdown', 'markdown' ); } } if ( is_front_page() || is_home() || is_archive() || is_search() ) { $this->send( $this->llms->index_markdown(), 'text/markdown', 'markdown' ); } } /** * Assemble robots.txt exactly as WordPress core's do_robots() does — the allow-all * group and the public / non-public baseline — then run it through the robots_txt * filter so every contributor (core defaults, SEO plugins, and our own * {@see robots_txt()}) is preserved. Mirroring core means owning the route never * drops a directive another producer expected to add. * * @return string */ private function robots_body() { $public = get_option( 'blog_public' ); $out = "User-agent: *\n"; $out .= ( '0' === (string) $public ) ? "Disallow: /\n" : "Disallow: /wp-admin/\nAllow: /wp-admin/admin-ajax.php\n"; // Failure isolation: robots.txt is hit by every crawler, so a throwing third-party // robots_txt filter must never 500 this route (a crawler's retries would make it a // self-renewing loop). On any error, fall back to the safe baseline. try { $filtered = (string) apply_filters( 'robots_txt', $out, $public ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- invoking WordPress core's own robots_txt filter to reproduce do_robots(), not declaring a new hook. return '' !== trim( $filtered ) ? $filtered : $out; } catch ( \Throwable $e ) { return $out; } } /** * Emit robots.txt on a clean 200 and stop. Unlike {@see send()} it neither logs a * hit (every crawler fetches robots.txt — it would swamp the activity log) nor runs * the access block: robots.txt is the policy file itself, so even a blocked bot * should be able to read the rules telling it to stay out. * * @param string $body robots.txt content. */ private function send_robots( $body ) { if ( ! headers_sent() ) { // WordPress flagged this request a 404 during handle_404() before we ran; // clear that so status_header() emits a clean 200 rather than being // re-affirmed as 404, then set the status both ways to be certain. if ( isset( $GLOBALS['wp_query'] ) && $GLOBALS['wp_query'] instanceof \WP_Query ) { $GLOBALS['wp_query']->is_404 = false; } status_header( 200 ); if ( function_exists( 'http_response_code' ) ) { http_response_code( 200 ); } header( 'Content-Type: text/plain; charset=UTF-8' ); header( 'X-Content-Type-Options: nosniff' ); CacheHeaders::send( HOUR_IN_SECONDS ); } $is_head = isset( $_SERVER['REQUEST_METHOD'] ) && 'HEAD' === strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ); if ( ! $is_head ) { echo $body; // phpcs:ignore WordPress.Security.EscapeOutput -- plain-text robots.txt payload. } exit; } /** * Whether a post's type is in the owner's agent-visible selection. Guards the * direct .md / Accept routes so they expose exactly what /llms.txt lists — not * every public post type. * * @param int $post_id Post ID. * @return bool */ private function post_in_scope( $post_id ) { return in_array( get_post_type( $post_id ), Content::post_types(), true ); } /** * Emit a plain-text/markdown body with sane headers, then stop. * * @param string $body Response body. * @param string $content_type MIME type. * @param string $label Activity-log endpoint label (empty = no log). * @param int $max_age Cache-Control max-age (seconds) for cacheable * (non-markdown) bodies. Defaults to one hour. */ /** * Whether a PAGE URL may answer with its markdown twin for a client that asks * for it (`Accept: text/markdown`). **Off by default since 1.21.2.** * * One URL answering with two different bodies is safe only if every cache in * front of the site honours the "never store this" instruction the markdown * answer carries — `Cache-Control`, `CDN-Cache-Control` and Cloudflare's own * vendor header, all set to `no-store` (see {@see send()}). * * Real CDNs don't. A Cloudflare "Cache Everything" rule with an Edge TTL * overrides *every* cache directive an origin can send, and no mainstream CDN * varies its cache key on `Vary: Accept`. The failure this caused in the wild: * an AI crawler — which finds a new post through the change feed within seconds * of publication, so it is often the FIRST client to fetch it — asked a fresh * post for markdown, the edge stored that answer under the post's own URL, and * every human visitor was served raw markdown until the cache expired. * * There is no header that fixes that from the origin, and the site owner is not * the one who finds out — their readers are. So the convenience is off unless * asked for, and the safe route is the default: the `.md` twin is a DISTINCT URL, * cache-safe by construction, advertised in the page's `Link` header, in llms.txt * and in the discovery documents. Agents lose nothing. * * Turn it back on where you know the caching is sound (no CDN, or one that * honours `no-store`): * * add_filter( 'agentimus_negotiate_markdown', '__return_true' ); * * @return bool */ public static function negotiates_markdown() { /** * Whether a page URL may answer with markdown for a client that asks for it. * * @param bool $enabled Default false — see the method docblock for the CDN * cache-poisoning failure this default prevents. */ return (bool) apply_filters( 'agentimus_negotiate_markdown', false ); } /** * Whether an Accept header asks for markdown IN PREFERENCE TO HTML. * * The old test was `stripos( $accept, 'text/markdown' )`, which ignored quality * values entirely: `Accept: text/html;q=0.9, text/markdown;q=0.8` — a client that * plainly prefers HTML — was answered with markdown. RFC 9110 §12.5.1 says the * client's preference IS the q value, so honour it: markdown must be listed, and * must outrank HTML strictly. A tie goes to HTML, because the page URL's own * media type is HTML and a caller that wants markdown badly enough can say so * (or fetch the `.md` twin). * * A wildcard (`*\/*`) never grants markdown — it says "anything", not "markdown" — * so curl's default Accept, and every browser's, leave the page as HTML. That * matters beyond correctness: the fewer clients that can be answered with markdown * at a page URL, the fewer that can poison a shared cache with it. * * @param string $accept Raw Accept header. * @return bool */ public static function prefers_markdown( $accept ) { $accept = strtolower( trim( (string) $accept ) ); if ( '' === $accept || false === strpos( $accept, 'text/markdown' ) ) { return false; } $q = array(); foreach ( explode( ',', $accept ) as $part ) { $bits = explode( ';', trim( $part ) ); $type = trim( array_shift( $bits ) ); if ( '' === $type ) { continue; } $weight = 1.0; foreach ( $bits as $param ) { $param = trim( $param ); if ( 0 === strpos( $param, 'q=' ) ) { $weight = (float) substr( $param, 2 ); } } // A repeated type keeps its highest weight. if ( ! isset( $q[ $type ] ) || $weight > $q[ $type ] ) { $q[ $type ] = $weight; } } $markdown = isset( $q['text/markdown'] ) ? $q['text/markdown'] : 0.0; $html = max( isset( $q['text/html'] ) ? $q['text/html'] : 0.0, isset( $q['application/xhtml+xml'] ) ? $q['application/xhtml+xml'] : 0.0 ); return $markdown > 0.0 && $markdown > $html; } private function send( $body, $content_type, $label = '', $max_age = 3600 ) { // Optional hard enforcement (opt-in): deny denylisted/spoofed agents before // we serve — and before we record a hit, so a blocked request never appears // in the log as though it were served. // The label rides along so a PROVEN-IMPOSTOR refusal can be recorded as // refused (never as a read) — see Guard::maybe_block(). Guard::maybe_block( $label ); if ( '' !== $label ) { \Agentimus\Activity\Recorder::record( $label ); } if ( ! headers_sent() ) { status_header( 200 ); header( 'Content-Type: ' . $content_type . '; charset=UTF-8' ); header( 'X-Content-Type-Options: nosniff' ); // Public, read-only agent docs — allow cross-origin reads so browser-based // agents can fetch them too (matches the discovery docs in WellKnown). header( 'Access-Control-Allow-Origin: *' ); header( 'Vary: Accept', false ); if ( 'text/markdown' === $content_type ) { // Markdown can share a URL with the HTML page (content negotiation), so a // shared cache that stored it would hand the raw markdown to human // visitors. Say "don't store this" in every dialect a CDN reads, because // `Cache-Control` alone is not enough: a CDN configured to override origin // headers (Cloudflare "Cache Everything" with an Edge TTL, and the // equivalent on other edges) rewrites it and caches the body anyway. The // CDN-targeted headers below take precedence over `Cache-Control` at the // edge, and Cloudflare's own vendor header outranks both. header( 'Cache-Control: no-store, max-age=0' ); header( 'CDN-Cache-Control: no-store' ); header( 'Cloudflare-CDN-Cache-Control: no-store' ); } else { // Stable URLs (llms.txt, the sitemap) are safe to cache; the change // feed passes a shorter max-age since freshness is its whole point. // CacheHeaders sends `no-store` instead when the owner opts to keep the // AI endpoints out of a shared cache (Settings → Visit log). CacheHeaders::send( $max_age ); } } $is_head = isset( $_SERVER['REQUEST_METHOD'] ) && 'HEAD' === strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ); if ( ! $is_head ) { echo $body; // phpcs:ignore WordPress.Security.EscapeOutput -- plain-text/markdown payload. } exit; } /** * Whether Agentimus should STAND DOWN for an agent-readiness surface, so another * producer (a theme or plugin that emits its own llms.txt, markdown, robots * rules or Link headers) owns it instead. This is the documented way to coexist * — a producer cedes a surface in one line, using this public API rather than * sniffing for the plugin: * * add_filter( 'agentimus_yield_surface', function ( $yield, $surface ) { * // My theme already serves these — let it. * return in_array( $surface, array( 'llms_txt', 'markdown' ), true ) ? true : $yield; * }, 10, 2 ); * * @param string $surface One of: llms_txt, llms_full, changes, markdown, link_headers, robots. * @return bool True if Agentimus must not handle this surface. */ private function yields( $surface ) { /** * Cede an agent-readiness surface to another producer. * * @param bool $yield Whether Agentimus should stand down. Default false. * @param string $surface Surface key (llms_txt|llms_full|changes|markdown|link_headers|robots). */ return (bool) apply_filters( 'agentimus_yield_surface', false, $surface ); } /** * Whether a `Link:` header with the given rel was already emitted this request * (e.g. by a theme). link_headers() runs late (priority 99) precisely so this * check sees earlier headers and never duplicates a rel another producer set. * * @param string $rel Relation type, e.g. "api-catalog". * @return bool */ private function link_present( $rel ) { foreach ( headers_list() as $header ) { if ( 0 === stripos( $header, 'link:' ) && false !== stripos( $header, 'rel="' . $rel . '"' ) ) { return true; } } return false; } /** * Advertise discovery endpoints on every front-end response — skipping any rel a * theme/plugin already set (zero-config de-duplication), and standing down * entirely when the `link_headers` surface is ceded. */ public function link_headers() { if ( is_admin() || $this->yields( 'link_headers' ) ) { return; } if ( ! $this->link_present( 'api-catalog' ) ) { header( 'Link: <' . esc_url_raw( rest_url() ) . '>; rel="api-catalog"', false ); } if ( $this->settings->enabled( 'enable_llms_txt' ) && ! $this->link_present( 'describedby' ) ) { header( 'Link: <' . esc_url_raw( home_url( '/llms.txt' ) ) . '>; rel="describedby"; type="text/plain"', false ); } // Advertise the current page's markdown twin (its `.md` URL) so an agent can // discover it from the HTML response instead of guessing the path exists. if ( $this->settings->enabled( 'enable_markdown' ) && ! $this->yields( 'markdown' ) ) { $md = $this->markdown_alternate_url(); if ( '' !== $md && ! $this->markdown_alternate_present() ) { header( 'Link: <' . esc_url_raw( $md ) . '>; rel="alternate"; type="text/markdown"', false ); } } } /** * Echo the two highest-value discovery links into the HTML . These are * already advertised in the HTTP Link header (link_headers above), but some * crawlers and readiness scanners parse the markup and not the headers — so a * belt-and-suspenders makes llms.txt and the OpenAPI contract findable * either way. Cheap, idempotent, and skipped when the surface is ceded. */ public function head_links() { if ( is_admin() || is_feed() || $this->yields( 'link_headers' ) ) { return; } if ( $this->settings->enabled( 'enable_llms_txt' ) ) { printf( '' . "\n", esc_url( home_url( '/llms.txt' ) ) ); } // The OpenAPI 3.1 description of the existing public REST read API is always // served at /.well-known/openapi.json while the plugin is active. printf( '' . "\n", esc_url( home_url( '/.well-known/openapi.json' ) ) ); } /** * The `.md` URL for the page being rendered, or '' when there isn't a faithful * one to advertise. Limited to singular, in-scope content (a post/page that * markdown delivery actually serves) — the front page and archives map only to * the generic site index, so advertising them as a page "alternate" would * mislead, and they're skipped. The URL mirrors route()'s `.md` resolution: * the permalink with `.md` appended. * * @return string */ private function markdown_alternate_url() { if ( ! is_singular() || is_front_page() ) { return ''; } $id = get_queried_object_id(); if ( ! $id || ! $this->post_in_scope( $id ) ) { return ''; } $permalink = get_permalink( $id ); return $permalink ? untrailingslashit( $permalink ) . '.md' : ''; } /** * Whether a markdown `rel="alternate"` Link header was already emitted this * request (e.g. by a theme). Matched on rel AND type, since `alternate` legally * repeats with different media types — so we only de-dupe the markdown one. * * @return bool */ private function markdown_alternate_present() { foreach ( headers_list() as $header ) { if ( 0 === stripos( $header, 'link:' ) && false !== stripos( $header, 'rel="alternate"' ) && false !== stripos( $header, 'text/markdown' ) ) { return true; } } return false; } /* ---------------------------------------------------------------------- * * robots.txt * ---------------------------------------------------------------------- */ /** * Augment WordPress's robots output *without clobbering it* — so we co-exist * with Yoast, WooCommerce and any other plugin filtering robots_txt. We * inject a Content-Signal into the existing `User-agent: *` group, append a * model-training crawler blocklist (skipping agents already present), and * add a Sitemap only if none is declared yet. Read/cite bots stay allowed. * * @param string $output Robots content so far. * @param bool $public Whether the site is indexable. * @return string */ public function robots_txt( $output, $public ) { if ( ! $public || ! $this->settings->enabled( 'enable_robots' ) || $this->yields( 'robots' ) ) { return $output; } $output = (string) $output; // 1. Declare the Content-Signal inside the existing allow-all group. $signal = $this->content_signal_string(); if ( '' !== $signal && false === stripos( $output, 'content-signal:' ) ) { $line = 'Content-Signal: ' . $signal; $injected = preg_replace( '/(^User-agent:\s*\*\s*$)/mi', "$1\n" . $line, $output, 1 ); if ( is_string( $injected ) && $injected !== $output ) { $output = $injected; } else { $output = "User-agent: *\n" . $line . "\n\n" . ltrim( $output ); } } // 2. Hard-block the listed crawlers by name. robots.txt can only enforce a // block per named user-agent (there is no "all AI trainers" directive), so // this list is the *enforcement* arm — independent of the ai-train signal // above and applied whether training is declared Allowed or Blocked. An // empty list blocks no one. $trainers = array_values( array_filter( array_map( 'trim', (array) $this->settings->get( 'blocked_trainers', array() ) ) ) ); $new = array(); foreach ( $trainers as $agent ) { if ( false === stripos( $output, 'User-agent: ' . $agent ) ) { $new[] = 'User-agent: ' . $agent; } } if ( ! empty( $new ) ) { $output = rtrim( $output ) . "\n\n" . implode( "\n", $new ) . "\nDisallow: /\n"; } // 3. Advertise a sitemap only if nobody else has — and never on an SEO // suite's behalf: an active suite owns its own robots advertising (Yoast // hooks this same filter at 99,999, far after us, so the stripos guard // can't see its line yet — appending the suite's URL here would print it // twice). With no suite, the detected sitemap is core's or our own, and // neither advertises itself. $sitemap = $this->sitemap_url(); if ( $sitemap && null === SeoContext::detected() && false === stripos( $output, 'sitemap:' ) ) { $output = rtrim( $output ) . "\n\nSitemap: " . esc_url_raw( $sitemap ) . "\n"; } return $output; } /** * Compose the Content-Signal directive from the stored booleans. The * vocabulary is fixed (search / ai-input / ai-train), so the public * robots.txt can only ever contain valid, expected values. * * @return string e.g. "search=yes, ai-input=yes, ai-train=no". */ private function content_signal_string() { $signal = (array) $this->settings->get( 'content_signal', array() ); $yn = static function ( $v ) { return ! empty( $v ) ? 'yes' : 'no'; }; return sprintf( 'search=%s, ai-input=%s, ai-train=%s', $yn( isset( $signal['search'] ) ? $signal['search'] : false ), $yn( isset( $signal['ai_input'] ) ? $signal['ai_input'] : false ), $yn( isset( $signal['ai_train'] ) ? $signal['ai_train'] : false ) ); } /* ---------------------------------------------------------------------- * * AI-usage signals (TDM Reservation Protocol headers) * ---------------------------------------------------------------------- */ /** * The AI-usage reservation, as a PURE decision so it can be unit-tested * without touching headers. The site reserves its content from AI training * when content_signal.ai_train is off ("ai-train=no"); when training is * allowed there is nothing to reserve, so no header/file is published (the * web default — absence of a signal — already means "allowed"). * * @return array{reserved:bool,policy:string} */ public function tdmrep_state() { $signal = (array) $this->settings->get( 'content_signal', array() ); return array( 'reserved' => empty( $signal['ai_train'] ), // ai-train=no → reserved. 'policy' => trim( (string) $this->settings->get( 'tdm_policy_url', '' ) ), ); } /** * Emit the AI-usage reservation as response headers on normal content pages: * the W3C TDM Reservation Protocol `tdm-reservation` header (plus an optional * `tdm-policy`), and — when opted in — the non-standard-but-widely-honoured * `X-Robots-Tag: noai, noimageai`. This reaches bots that never read robots.txt. * * Scope: skips admin/REST and our own "please read me" surfaces (llms.txt, * markdown, /.well-known, robots.txt, feeds) — marking those reserved would * contradict the invitation to ingest them. Because send_headers fires before * the query is resolved, the surfaces are matched on the request path, not * conditional tags. * * Emitted only when reserved (training blocked): the web default is "not * reserved", so an "allow" site never stamps a header on every page. The same * value is sent to every client, so it stays edge-cacheable (no Vary). */ public function ai_signal_headers() { if ( is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || headers_sent() ) { return; } if ( ! $this->settings->enabled( 'enable_ai_header' ) ) { return; } $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; $path = strtolower( (string) wp_parse_url( $uri, PHP_URL_PATH ) ); if ( '/llms.txt' === $path || '/llms-full.txt' === $path || '/robots.txt' === $path || '.md' === substr( $path, -3 ) || 0 === strpos( $path, '/.well-known/' ) || false !== strpos( $path, '/feed' ) ) { return; } $state = $this->tdmrep_state(); if ( empty( $state['reserved'] ) ) { return; // Training allowed → emit nothing (silence == not reserved). } header( 'tdm-reservation: 1' ); if ( '' !== $state['policy'] ) { header( 'tdm-policy: ' . $state['policy'] ); } if ( $this->settings->enabled( 'ai_noai_header' ) ) { header( 'X-Robots-Tag: noai, noimageai', false ); // Append — never clobber an existing X-Robots-Tag. } } /* ---------------------------------------------------------------------- * * Helpers * ---------------------------------------------------------------------- */ /** * The detected sitemap URL (core or a known SEO plugin), or '' if none. * * @return string */ private function sitemap_url() { return Sitemap::url(); } }