apply_robots_txt( $action ); case 'llms_txt': return $this->apply_llms_txt( $action ); case 'faq_schema': return $this->apply_faq_schema( $action ); case 'org_schema': return $this->apply_org_schema( $action ); case 'heading_rewrite': return $this->apply_heading_rewrite( $action ); case 'meta_description': return $this->apply_meta_description( $action ); case 'blog_draft': return $this->apply_blog_draft( $action ); default: return array( 'success' => false, 'message' => "Unknown fix type: $type", ); } } /** * Undo a previously applied fix. * * @param string $type Fix type. * @param array $action Action data (for context). * @return array Result with success boolean and message. */ public function undo( $type, $action = array() ) { switch ( $type ) { case 'robots_txt': return $this->undo_robots_txt(); case 'llms_txt': return $this->undo_llms_txt(); case 'faq_schema': return $this->undo_faq_schema( $action ); case 'org_schema': return $this->undo_org_schema(); case 'heading_rewrite': return $this->undo_heading_rewrite( $action ); case 'meta_description': return $this->undo_meta_description( $action ); case 'blog_draft': return $this->undo_blog_draft( $action ); default: return array( 'success' => false, 'message' => "Unknown fix type: $type", ); } } // ═══ robots.txt ═══ /** * Apply robots.txt rules for AI bots. * Adds rules to allow search crawlers and block training crawlers. */ private function apply_robots_txt( $action ) { // WordPress can only manage robots.txt through the `robots_txt` filter // (see output_robots), and that filter runs only when WordPress actually // serves /robots.txt. It does NOT run when a physical robots.txt sits at // the web root (the web server returns that file directly) or when pretty // permalinks are off (the rewrite that routes /robots.txt into WordPress // isn't active). Detect both and fail honestly rather than report a // success the visitor — and the platform — would never actually see. if ( file_exists( trailingslashit( ABSPATH ) . 'robots.txt' ) ) { return array( 'success' => false, 'message' => 'A physical robots.txt exists at your site root, so WordPress cannot manage it. Remove or edit that file directly, then try again.', ); } if ( '' === (string) get_option( 'permalink_structure', '' ) ) { return array( 'success' => false, 'message' => 'WordPress can serve a dynamic robots.txt only with pretty permalinks enabled. Set Settings → Permalinks to any option other than "Plain", then re-apply.', ); } // Prefer a ruleset supplied by the platform; fall back to the built-in // AI bot rules. sanitize_textarea_field keeps newlines but strips tags. $payload = $action['payload'] ?? array(); $rules = ( ! empty( $payload['rules'] ) && is_string( $payload['rules'] ) ) ? "\n" . sanitize_textarea_field( $payload['rules'] ) . "\n" : $this->build_robots_rules(); // Store rules in an option and serve them through WordPress's // `robots_txt` filter (see output_robots). This avoids writing a // physical file to the web root and reverts cleanly on undo. update_option( 'aiadvisors_robots_rules', $rules, false ); return array( 'success' => true, 'message' => 'AI bot rules added to robots.txt.', ); } /** * Build the AI bot rules block appended to robots.txt output. */ private function build_robots_rules() { // AI search crawlers to ALLOW (they drive traffic back). $allow_bots = array( 'ChatGPT-User', 'OAI-SearchBot', 'Claude-User', 'Claude-SearchBot', 'PerplexityBot' ); // AI training crawlers to BLOCK (they use content without attribution). $block_bots = array( 'GPTBot', 'ClaudeBot', 'anthropic-ai', 'Google-Extended', 'CCBot', 'Meta-ExternalAgent', 'Bytespider', 'Applebot-Extended' ); $rules = "\n# AI-Advisors: AI Bot Rules\n"; $rules .= "# Search/retrieval crawlers - ALLOW (they drive AI citations)\n"; foreach ( $allow_bots as $bot ) { $rules .= "User-agent: $bot\nAllow: /\n\n"; } $rules .= "# Training crawlers - BLOCK (they use content without attribution)\n"; foreach ( $block_bots as $bot ) { $rules .= "User-agent: $bot\nDisallow: /\n\n"; } $rules .= "# End AI-Advisors rules\n"; return $rules; } /** * Undo robots.txt changes by removing the stored rules. */ private function undo_robots_txt() { if ( get_option( 'aiadvisors_robots_rules' ) === false ) { return array( 'success' => false, 'message' => 'No AI bot rules are currently active.', ); } delete_option( 'aiadvisors_robots_rules' ); return array( 'success' => true, 'message' => 'AI bot rules removed from robots.txt.', ); } /** * Append stored AI bot rules to the WordPress virtual robots.txt. * Hooked to the `robots_txt` filter. * * @param string $output Current robots.txt content. * @param bool $is_public Whether the site is public. * @return string */ public static function output_robots( $output, $is_public ) { $rules = get_option( 'aiadvisors_robots_rules' ); if ( $rules && $is_public ) { $output .= $rules; } return $output; } /** * Register the robots_txt filter when AI bot rules are active. * Called on plugin init. */ public static function register_robots_output() { if ( get_option( 'aiadvisors_robots_rules' ) ) { add_filter( 'robots_txt', array( __CLASS__, 'output_robots' ), 10, 2 ); } } // ═══ llms.txt ═══ /** * Generate and write llms.txt to the site root. */ private function apply_llms_txt( $action ) { $payload = $action['payload'] ?? array(); // Platform content (sanitized to plain text) or auto-generated locally. if ( ! empty( $payload['content'] ) ) { $content = sanitize_textarea_field( (string) $payload['content'] ); } else { $content = $this->generate_llms_content(); } // Cap size — this is a small text descriptor, not a content store. $max_bytes = 256 * 1024; // 256 KB if ( strlen( $content ) > $max_bytes ) { $content = substr( $content, 0, $max_bytes ); } $fs = self::filesystem(); if ( ! $fs ) { return array( 'success' => false, 'message' => 'Could not access the filesystem to write llms.txt.', ); } $llms_file = trailingslashit( ABSPATH ) . 'llms.txt'; // Back up existing content ('' marks "did not previously exist"). Only on // the first apply — a second apply must not overwrite the original backup // with our own output, or undo would restore plugin content instead of the // file the user started with. if ( false === get_option( 'aiadvisors_llms_backup', false ) ) { update_option( 'aiadvisors_llms_backup', $fs->exists( $llms_file ) ? $fs->get_contents( $llms_file ) : '', false ); } if ( ! $fs->put_contents( $llms_file, $content, FS_CHMOD_FILE ) ) { return array( 'success' => false, 'message' => 'Failed to write llms.txt. Check file permissions.', ); } return array( 'success' => true, 'message' => 'llms.txt created at site root.', ); } /** * Generate llms.txt content from WordPress site data. */ private function generate_llms_content() { $site_name = get_bloginfo( 'name' ); $site_desc = get_bloginfo( 'description' ); $site_url = home_url( '/' ); $content = "# {$site_name}\n\n"; $content .= "> {$site_desc}\n\n"; $content .= "## About\n\n"; $content .= "{$site_name} ({$site_url})\n"; if ( ! empty( $site_desc ) ) { $content .= "{$site_desc}\n"; } $content .= "\n## Key Pages\n\n"; // Add top-level pages. $pages = get_pages( array( 'sort_column' => 'menu_order', 'number' => 20, 'parent' => 0, ) ); foreach ( $pages as $page ) { $url = get_permalink( $page->ID ); $title = $page->post_title; $content .= "- [{$title}]({$url})\n"; } // Add recent blog posts. $posts = get_posts( array( 'numberposts' => 10, 'post_status' => 'publish', 'orderby' => 'date', 'order' => 'DESC', ) ); if ( ! empty( $posts ) ) { $content .= "\n## Recent Articles\n\n"; foreach ( $posts as $post ) { $url = get_permalink( $post->ID ); $title = $post->post_title; $content .= "- [{$title}]({$url})\n"; } } $content .= "\n---\nGenerated by AI-Advisors WordPress Plugin\n"; return $content; } /** * Undo llms.txt by restoring backup or removing file. */ private function undo_llms_txt() { $fs = self::filesystem(); if ( ! $fs ) { return array( 'success' => false, 'message' => 'Could not access the filesystem.', ); } $backup = get_option( 'aiadvisors_llms_backup' ); $llms_file = trailingslashit( ABSPATH ) . 'llms.txt'; if ( $backup === false ) { // No backup - just remove the file if it carries our marker. if ( $fs->exists( $llms_file ) && strpos( (string) $fs->get_contents( $llms_file ), 'AI-Advisors' ) !== false ) { $fs->delete( $llms_file ); return array( 'success' => true, 'message' => 'llms.txt removed.', ); } return array( 'success' => false, 'message' => 'No llms.txt backup found.', ); } if ( $backup === '' ) { // File didn't exist before - remove it. if ( $fs->exists( $llms_file ) ) { $fs->delete( $llms_file ); } } else { $fs->put_contents( $llms_file, $backup, FS_CHMOD_FILE ); } delete_option( 'aiadvisors_llms_backup' ); return array( 'success' => true, 'message' => 'llms.txt restored to previous state.', ); } /** * Inline-only KSES allowlist for headings / FAQ answers — strips * breakout vector. $json = wp_json_encode( $schema, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_HEX_APOS | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT ); if ( $json ) { echo "\n\n"; echo '' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON_HEX_* encoded above } } } /** * Register the wp_head hook for schema output (called on plugin init). */ public static function register_schema_output() { $has_faq = get_option( 'aiadvisors_faq_schema' ); $has_org = get_option( 'aiadvisors_org_schema' ); $has_page_faq = get_option( 'aiadvisors_has_page_faq' ); if ( $has_faq || $has_org || $has_page_faq ) { add_action( 'wp_head', array( __CLASS__, 'output_schemas' ), 1 ); } } }