detector = $detector; } /** * Register hooks. */ public function hooks() { // Priority 1 — before SEO redirects and cache plugins. add_action( 'template_redirect', array( $this, 'maybe_serve_markdown' ), 1 ); } /** * Entry point: decide whether to serve markdown. */ public function maybe_serve_markdown() { if ( ! ADG_AIM_Plugin::get_setting( 'negotiation_enabled', true ) ) { return; } // Do not interfere with service paths. if ( is_admin() || is_feed() || is_trackback() || is_robots() ) { return; } if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return; } if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) { return; } if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) { return; } // WooCommerce non-content pages (cart/checkout/account/shop/product taxonomy) — never touched. if ( $this->is_excluded_commerce() ) { return; } // Editor preview: an authorized user can view exactly what bots receive. $is_preview = $this->is_preview_request(); if ( is_singular() ) { $post = get_post(); if ( ! $post instanceof WP_Post ) { return; } $allowed_types = (array) ADG_AIM_Plugin::get_setting( 'post_types', array( 'post', 'page' ) ); if ( ! in_array( $post->post_type, $allowed_types, true ) ) { return; } // Status/privacy/password — a bot must not bypass access rights. if ( 'publish' !== $post->post_status ) { return; } if ( post_password_required( $post ) ) { return; } // "Hide from AI bots" checkbox or an excluded category/tag. if ( ADG_AIM_Plugin::is_hidden_from_ai( $post ) ) { return; } if ( $is_preview ) { if ( ! current_user_can( 'edit_post', $post->ID ) ) { return; } } elseif ( $this->is_selftest_probe() ) { // Pro self-test loopback: serve on a UA match, without IP // verification and without polluting analytics. if ( false === $this->detector->match_ua() ) { return; } } else { $bot = $this->detector->detect(); if ( false === $bot || ! $this->policy_allows( $bot ) ) { return; } $this->record_visit( $bot ); } $this->serve( $post ); return; } // Taxonomy archives (categories, tags, custom taxonomies). if ( ADG_AIM_Plugin::get_setting( 'tax_archives', true ) && ( is_category() || is_tag() || is_tax() ) ) { $term = get_queried_object(); if ( ! $term instanceof WP_Term ) { return; } if ( in_array( $term->slug, ADG_AIM_Plugin::excluded_term_slugs(), true ) ) { return; } if ( $is_preview ) { if ( ! current_user_can( 'manage_options' ) ) { return; } } else { $bot = $this->detector->detect(); if ( false === $bot || ! $this->policy_allows( $bot ) ) { return; } $this->record_visit( $bot ); } $this->serve_archive( $term ); } } /** * Whether this is an authorized "view markdown" preview request. * * @return bool */ private function is_preview_request() { // Read-only, capability-gated preview flag; no state change, so no nonce. return isset( $_GET['adg_preview_md'] ) && is_user_logged_in(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended } /** * Pro-only training vs citation policy gate. * * Free tier: every detected bot passes. With Pro active, a bot whose category * the site opted out of does not receive the optimized Markdown (it still gets * the normal HTML page — this decides who gets the AI copy, not site access). * * @param array $bot Detected bot entry. * @return bool */ private function policy_allows( $bot ) { if ( ! ADG_AIM_Plugin::is_pro() ) { return true; } $category = ADG_AIM_Bot_Detector::bot_category( $bot ); return ADG_AIM_Plugin::category_allowed( $category ); } /** * Pro-only: record a served bot visit into the analytics aggregate. * * Free tier / no license: no-op. Without IP verification (a separate Pro * control) served visits are recorded with the 'unverified' status. * * @param array $bot Detected bot entry. */ /** * Pro self-test probe: a request carrying a valid one-time self-test token. * * @return bool */ private function is_selftest_probe() { if ( ! class_exists( 'ADG_AIM_Selftest' ) || ! isset( $_SERVER['HTTP_X_ADG_SELFTEST'] ) ) { return false; } $sent = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_ADG_SELFTEST'] ) ); $stored = get_transient( ADG_AIM_Selftest::TOKEN_TRANSIENT ); return is_string( $stored ) && '' !== $stored && hash_equals( $stored, $sent ); } private function record_visit( $bot ) { if ( ! ADG_AIM_Plugin::is_pro() || ! class_exists( 'ADG_AIM_Analytics' ) ) { return; } $label = isset( $bot['label'] ) ? $bot['label'] : ( isset( $bot['id'] ) ? $bot['id'] : 'unknown' ); $uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; $status = ! empty( $bot['_verified'] ) ? 'verified' : 'unverified'; ADG_AIM_Analytics::record( $label, $status, $uri ); } /** * WooCommerce / e-commerce exclusions. Single products are allowed through * (gated by the post-type setting); only non-content shop pages are excluded. * * @return bool */ private function is_excluded_commerce() { $checks = array( 'is_cart', 'is_checkout', 'is_account_page', 'is_shop', 'is_product_category', 'is_product_tag' ); foreach ( $checks as $fn ) { if ( function_exists( $fn ) && call_user_func( $fn ) ) { return true; } } return false; } /** * Build and send the markdown response for a single post. * * @param WP_Post $post Post. */ private function serve( WP_Post $post ) { // The body comes from cache (the_content conversion runs lazily on the first visit). $body = ADG_AIM_Markdown_Cache::get( $post ); $out = ADG_AIM_Frontmatter::build_for_post( $post ); $out .= '# ' . get_the_title( $post ) . "\n\n"; // Machine-readable JSON-LD (Schema.org) for AI crawlers — if enabled. $schema = ADG_AIM_Schema::build_block( $post ); if ( '' !== $schema ) { $out .= $schema . "\n"; } $out .= $body; $this->send_markdown( $out ); } /** * Build and send a markdown index for a taxonomy-term archive. * * @param WP_Term $term Term. */ private function serve_archive( WP_Term $term ) { $out = ADG_AIM_Frontmatter::build_for_term( $term ); $out .= '# ' . $term->name . "\n\n"; if ( '' !== (string) $term->description ) { $out .= trim( wp_strip_all_tags( $term->description ) ) . "\n\n"; } $allowed_types = (array) ADG_AIM_Plugin::get_setting( 'post_types', array( 'post', 'page' ) ); if ( empty( $allowed_types ) ) { $allowed_types = array( 'post' ); } $posts = get_posts( array( 'post_type' => $allowed_types, 'post_status' => 'publish', 'posts_per_page' => 200, 'has_password' => false, 'orderby' => 'modified', 'order' => 'DESC', 'ignore_sticky_posts' => true, 'update_post_term_cache' => false, 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Archive listing; bounded and cache-fronted. array( 'taxonomy' => $term->taxonomy, 'terms' => $term->term_id, ), ), ) ); foreach ( $posts as $p ) { if ( ADG_AIM_Plugin::is_hidden_from_ai( $p ) ) { continue; } $line = '- [' . get_the_title( $p ) . '](' . get_permalink( $p ) . ')'; $desc = $this->short_excerpt( $p ); if ( '' !== $desc ) { $line .= ': ' . $desc; } $out .= $line . "\n"; } $this->send_markdown( rtrim( $out ) . "\n" ); } /** * A one-line excerpt for archive listings. * * @param WP_Post $post Post. * @return string */ private function short_excerpt( WP_Post $post ) { if ( ! empty( $post->post_excerpt ) ) { $text = $post->post_excerpt; } else { $text = wp_strip_all_tags( strip_shortcodes( $post->post_content ) ); } $text = trim( preg_replace( '/\s+/u', ' ', (string) $text ) ); if ( function_exists( 'mb_strlen' ) && mb_strlen( $text ) > 150 ) { $text = mb_substr( $text, 0, 150 ) . '…'; } return $text; } /** * Send a markdown payload with the correct headers and stop. * * @param string $markdown Markdown. */ private function send_markdown( $markdown ) { // Tell page-cache / minify plugins not to store or alter this response. $this->set_no_cache_constants(); // Headers. nocache_headers() before ours — so it does not overwrite Content-Type. nocache_headers(); status_header( 200 ); header( 'Content-Type: text/markdown; charset=UTF-8' ); header( 'Vary: User-Agent' ); // Critical for cache layers. header( 'X-Robots-Tag: noindex' ); // This response must not be indexed as a separate document. // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Markdown body served as text/markdown; HTML escaping would corrupt it. echo $markdown; exit; } /** * Define the well-known "do not cache" constants honored by WP Rocket, * W3 Total Cache, WP Super Cache, LiteSpeed and others. */ private function set_no_cache_constants() { foreach ( array( 'DONOTCACHEPAGE', 'DONOTCACHEOBJECT', 'DONOTCACHEDB', 'DONOTMINIFY' ) as $const ) { if ( ! defined( $const ) ) { define( $const, true ); } } } }