and as the meta key suffix. * Value = label displayed in the admin meta box (HTML entity–safe). * * AI bots (GPTBot, ClaudeBot, etc.) are deliberately absent — they are * the audience we WANT to attract, not block. */ private const BOT_DEFINITIONS = [ 'googlebot' => 'Googlebot (Google — Web)', 'googlebot-image' => 'Googlebot-Image (Google — Images)', 'googlebot-news' => 'Googlebot-News (Google — News)', 'googlebot-video' => 'Googlebot-Video (Google — Video)', 'adsbot-google' => 'AdsBot-Google (Google — Ads)', 'bingbot' => 'Bingbot (Microsoft / Bing)', 'slurp' => 'Slurp (Yahoo)', 'duckduckbot' => 'DuckDuckBot (DuckDuckGo)', 'baiduspider' => 'Baiduspider (Baidu)', 'yandexbot' => 'YandexBot (Yandex)', ]; /** * Known AI crawler User-Agent substrings. * * Used for fast-path matching in is_ai_bot(). Does not need to be * exhaustive — the heuristic layer catches bots not listed here. * Extend via the 'aionly_ai_crawler_signatures' filter. */ private const AI_CRAWLER_SIGNATURES = [ 'GPTBot', 'ChatGPT-User', 'ClaudeBot', 'Claude-Web', 'PerplexityBot', 'YouBot', 'Meta-ExternalAgent', 'Meta-ExternalFetcher', 'Applebot-Extended', 'Amazonbot', 'Bytespider', 'Diffbot', 'ImagesiftBot', 'cohere-ai', 'anthropic-ai', 'AI2Bot', 'Timpibot', 'OAI-SearchBot', ]; /** * CSS-style selectors for layout chrome removed from AI output. * Supports: element tag, #id, .class (one class only, no combinators). * All values are sanitized via sanitize_selector() before XPath use. * Extend via the 'aionly_strip_selectors' filter. */ private const STRIP_ELEMENTS = [ // Semantic HTML5 structural elements. 'header', 'footer', 'nav', 'aside', // WordPress structural IDs. '#wpadminbar', '#masthead', '#colophon', '#sidebar', '#secondary', '#comments', // WordPress structural classes. '.site-header', '.site-footer', '.site-navigation', '.main-navigation', '.widget-area', '.sidebar', '.comments-area', '.comment-respond', '.post-navigation', '.nav-links', '.social-links', '.social-share', // Elementor / Divi page builder chrome. '.elementor-section-header', '.elementor-section-footer', '.et_pb_header', '.et_pb_footer', // Cookie and GDPR banners. '#cookie-notice', '.cookie-notice', '.cookie-banner', '#gdpr-cookie-notice', ]; /** * HTML attributes preserved after Token Diet attribute stripping. * Everything else — class, style, data-*, aria-*, on* events — is removed. * aria-* is intentionally omitted: accessibility hints are UI metadata, * not semantic content, and carry zero value for LLM ingestion. * Extend via the 'aionly_allowed_attributes' filter. */ private const ALLOWED_ATTRIBUTES = [ // Navigation and media. 'href', 'src', 'alt', 'title', 'srcset', // Structured data and metadata. 'datetime', 'content', 'name', 'property', 'rel', 'lang', 'charset', // Structure. 'id', 'colspan', 'rowspan', 'value', 'type', ]; /** Caching plugins that serve from disk, bypassing our output buffer. */ private const KNOWN_CACHE_PLUGINS = [ 'wp-rocket/wp-rocket.php' => 'WP Rocket', 'w3-total-cache/w3-total-cache.php' => 'W3 Total Cache', 'litespeed-cache/litespeed-cache.php' => 'LiteSpeed Cache', 'wp-super-cache/wp-cache.php' => 'WP Super Cache', 'wp-fastest-cache/wpFastestCache.php' => 'WP Fastest Cache', 'swift-performance-lite/swift-performance-lite.php' => 'Swift Performance', ]; // ------------------------------------------------------------------------- // Singleton // ------------------------------------------------------------------------- private static ?self $instance = null; /** * Whether our output buffer is currently active. * * Tracks the buffer opened in maybe_start_ai_output_buffer() so the * shutdown handler knows whether it needs to flush. Without this flag * we risk closing a buffer we never opened — or leaving ours open if * PHP's implicit flush doesn't fire in the expected order. * * @since 1.3.2 * @var bool */ private bool $ob_active = false; public static function get_instance(): self { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } private function __construct() { // Priority 99 on init: ensures custom post types registered by themes // or plugins exist before we register meta keys against them. add_action( 'init', [ $this, 'register_post_meta' ], 99 ); add_action( 'init', [ $this, 'register_rewrite_rules' ] ); add_action( 'init', static function () { load_plugin_textdomain( 'ai-only-pages', false, dirname( plugin_basename( __DIR__ . '/../ai-only-pages.php' ) ) . '/languages' ); } ); add_filter( 'query_vars', [ $this, 'register_query_vars' ] ); add_action( 'add_meta_boxes', [ $this, 'register_meta_box' ] ); add_action( 'save_post', [ $this, 'save_meta_data' ], 10, 2 ); // JS is enqueued via the proper WordPress hook — no inline