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 );
}
}
}