%s', esc_url( admin_url( 'options-general.php?page=' . self::PAGE_SLUG ) ), esc_html__( 'Settings', 'ai-ready-wp' ) ); array_unshift( $links, $settings_link ); return $links; } public function enqueue_assets( string $hook ): void { if ( 'settings_page_' . self::PAGE_SLUG !== $hook ) { return; } wp_enqueue_style( 'airewp-admin', AIREWP_PLUGIN_URL . 'admin/css/admin.css', [], AIREWP_VERSION ); wp_enqueue_media(); // For logo upload } public function register_settings(): void { register_setting( self::PAGE_SLUG, AIREWP_OPTION_KEY, [ 'type' => 'array', 'sanitize_callback' => [ $this, 'sanitize_settings' ], ] ); // Business Information add_settings_section( self::SECTION_BUSINESS, __( 'Business Information', 'ai-ready-wp' ), fn() => printf( '

%s

', esc_html__( 'Basic information about your business. Used for agents.json, JSON-LD schemas, and AI agent discovery.', 'ai-ready-wp' ) ), self::PAGE_SLUG ); $this->add_field( self::SECTION_BUSINESS, 'company_name', __( 'Company Name', 'ai-ready-wp' ) ); $this->add_field( self::SECTION_BUSINESS, 'company_desc', __( 'Description', 'ai-ready-wp' ), 'textarea' ); $this->add_field( self::SECTION_BUSINESS, 'company_email', __( 'Contact Email', 'ai-ready-wp' ), 'email' ); $this->add_field( self::SECTION_BUSINESS, 'company_phone', __( 'Phone', 'ai-ready-wp' ) ); $this->add_field( self::SECTION_BUSINESS, 'company_address', __( 'Street Address', 'ai-ready-wp' ) ); $this->add_field( self::SECTION_BUSINESS, 'company_city', __( 'City', 'ai-ready-wp' ) ); $this->add_field( self::SECTION_BUSINESS, 'company_zip', __( 'ZIP / Postal Code', 'ai-ready-wp' ) ); $this->add_field( self::SECTION_BUSINESS, 'company_country', __( 'Country', 'ai-ready-wp' ) ); $this->add_field( self::SECTION_BUSINESS, 'company_logo', __( 'Logo URL', 'ai-ready-wp' ), 'url' ); $this->add_field( self::SECTION_BUSINESS, 'industry', __( 'Industry', 'ai-ready-wp' ), 'select', [ 'options' => [ 'general' => __( 'General Business', 'ai-ready-wp' ), 'restaurant' => __( 'Restaurant / Gastronomy', 'ai-ready-wp' ), 'real-estate' => __( 'Real Estate', 'ai-ready-wp' ), 'ecommerce' => __( 'E-Commerce / Shop', 'ai-ready-wp' ), 'saas' => __( 'SaaS / Software', 'ai-ready-wp' ), 'agency' => __( 'Agency / Consulting', 'ai-ready-wp' ), 'medical' => __( 'Medical / Health', 'ai-ready-wp' ), 'legal' => __( 'Legal / Law Firm', 'ai-ready-wp' ), 'finance' => __( 'Finance / Insurance', 'ai-ready-wp' ), 'education' => __( 'Education / Training', 'ai-ready-wp' ), 'construction' => __( 'Construction / Trades', 'ai-ready-wp' ), 'fitness' => __( 'Fitness / Sports', 'ai-ready-wp' ), 'beauty' => __( 'Beauty / Wellness', 'ai-ready-wp' ), 'automotive' => __( 'Automotive', 'ai-ready-wp' ), 'hotel' => __( 'Hotel / Hospitality', 'ai-ready-wp' ), 'nonprofit' => __( 'Nonprofit / Association', 'ai-ready-wp' ), ], ] ); $this->add_field( self::SECTION_BUSINESS, 'services', __( 'Services (comma-separated)', 'ai-ready-wp' ), 'textarea', [ 'description' => __( 'e.g.: Web Design, SEO, Consulting, Photography', 'ai-ready-wp' ), ] ); $this->add_field( self::SECTION_BUSINESS, 'schema_type', __( 'Business Type', 'ai-ready-wp' ), 'select', [ 'options' => [ 'Organization' => __( 'Organization (company, brand)', 'ai-ready-wp' ), 'LocalBusiness' => __( 'Local Business (physical location)', 'ai-ready-wp' ), ], ] ); // Social Media add_settings_section( self::SECTION_SOCIAL, __( 'Social Media', 'ai-ready-wp' ), fn() => printf( '

%s

', esc_html__( 'Social profiles for JSON-LD sameAs and agent discovery.', 'ai-ready-wp' ) ), self::PAGE_SLUG ); $this->add_field( self::SECTION_SOCIAL, 'social_x', __( 'X (Twitter) URL', 'ai-ready-wp' ), 'url' ); $this->add_field( self::SECTION_SOCIAL, 'social_linkedin', __( 'LinkedIn URL', 'ai-ready-wp' ), 'url' ); $this->add_field( self::SECTION_SOCIAL, 'social_instagram', __( 'Instagram URL', 'ai-ready-wp' ), 'url' ); $this->add_field( self::SECTION_SOCIAL, 'social_facebook', __( 'Facebook URL', 'ai-ready-wp' ), 'url' ); $this->add_field( self::SECTION_SOCIAL, 'social_github', __( 'GitHub URL', 'ai-ready-wp' ), 'url' ); // Feature Toggles add_settings_section( self::SECTION_FEATURES, __( 'Features', 'ai-ready-wp' ), fn() => printf( '

%s

', esc_html__( 'Enable or disable individual AI-Ready features.', 'ai-ready-wp' ) ), self::PAGE_SLUG ); $this->add_field( self::SECTION_FEATURES, 'enable_agents_json', __( 'agents.json', 'ai-ready-wp' ), 'checkbox', [ 'description' => __( 'AI Agent Discovery — /.well-known/agents.json', 'ai-ready-wp' ), ] ); $this->add_field( self::SECTION_FEATURES, 'enable_agent_card', __( 'agent-card.json', 'ai-ready-wp' ), 'checkbox', [ 'description' => __( 'A2A Protocol — /.well-known/agent-card.json', 'ai-ready-wp' ), ] ); $this->add_field( self::SECTION_FEATURES, 'enable_llms_txt', __( 'llms.txt', 'ai-ready-wp' ), 'checkbox', [ 'description' => __( 'LLM-readable content index — /llms.txt', 'ai-ready-wp' ), ] ); $this->add_field( self::SECTION_FEATURES, 'enable_json_ld', __( 'JSON-LD Schemas', 'ai-ready-wp' ), 'checkbox', [ 'description' => __( 'Enhanced structured data in page head', 'ai-ready-wp' ), ] ); $this->add_field( self::SECTION_FEATURES, 'enable_indexnow', __( 'IndexNow', 'ai-ready-wp' ), 'checkbox', [ 'description' => __( 'Instant indexing notification to Bing/Yandex on content changes', 'ai-ready-wp' ), ] ); $this->add_field( self::SECTION_FEATURES, 'enable_rest_api', __( 'REST API', 'ai-ready-wp' ), 'checkbox', [ 'description' => __( 'API endpoints for AI agents (/wp-json/airewp/v1/)', 'ai-ready-wp' ), ] ); $this->add_field( self::SECTION_FEATURES, 'enable_robots_ai', __( 'AI Bot Management', 'ai-ready-wp' ), 'checkbox', [ 'description' => __( 'Manage AI training and retrieval bots in robots.txt', 'ai-ready-wp' ), ] ); $this->add_field( self::SECTION_FEATURES, 'enable_crawler_analytics', __( 'AI Crawler Analytics', 'ai-ready-wp' ), 'checkbox', [ 'description' => __( 'Track which AI bots visit your site and how often', 'ai-ready-wp' ), ] ); $this->add_field( self::SECTION_FEATURES, 'enable_mcp_json', __( 'mcp.json', 'ai-ready-wp' ), 'checkbox', [ 'description' => __( 'MCP Server Discovery — /.well-known/mcp.json', 'ai-ready-wp' ), ] ); // AI Bot Settings (shown if robots.txt enabled) $settings = AI_Ready_WP::get_settings(); if ( ! empty( $settings['enable_robots_ai'] ) ) { add_settings_section( 'airewp_robots', __( 'AI Bot Policy', 'ai-ready-wp' ), fn() => printf( '

%s

', esc_html__( 'Control which AI bots can access your content. Best practice: allow retrieval bots (search/answers), block training bots.', 'ai-ready-wp' ) ), self::PAGE_SLUG ); $this->add_field( 'airewp_robots', 'robots_block_training', __( 'Block Training Bots', 'ai-ready-wp' ), 'checkbox', [ 'description' => __( 'Block bots that scrape for AI model training (GPTBot, ClaudeBot, Google-Extended, Meta-ExternalAgent, etc.)', 'ai-ready-wp' ), ] ); $this->add_field( 'airewp_robots', 'robots_block_retrieval', __( 'Block Retrieval Bots', 'ai-ready-wp' ), 'checkbox', [ 'description' => __( 'Block bots used for AI search answers (OAI-SearchBot, Claude-SearchBot, PerplexityBot). NOT recommended.', 'ai-ready-wp' ), ] ); } // Crawler Analytics Stats (shown if enabled) if ( ! empty( $settings['enable_crawler_analytics'] ) ) { add_settings_section( 'airewp_crawler_stats', __( 'AI Crawler Statistics', 'ai-ready-wp' ), [ $this, 'render_crawler_section' ], self::PAGE_SLUG ); } // ─── v4.0 SEO Module ───────────────────────────────── add_settings_section( 'airewp_seo', __( 'SEO', 'ai-ready-wp' ), fn() => printf( '

%s

', esc_html__( 'Search engine optimization. Meta tags, OpenGraph, XML Sitemap.', 'ai-ready-wp' ) ), self::PAGE_SLUG ); $this->add_field( 'airewp_seo', 'enable_seo', __( 'Enable SEO Module', 'ai-ready-wp' ), 'checkbox', [ 'description' => __( 'Meta title/description, OpenGraph tags, Twitter Cards per page', 'ai-ready-wp' ), ] ); // Advanced add_settings_section( self::SECTION_ADVANCED, __( 'Advanced', 'ai-ready-wp' ), fn() => printf( '

%s

', esc_html__( 'Advanced settings for developers and power users.', 'ai-ready-wp' ) ), self::PAGE_SLUG ); $this->add_field( self::SECTION_ADVANCED, 'indexnow_key', __( 'IndexNow Key', 'ai-ready-wp' ), 'text', [ 'description' => __( 'Auto-generated on activation. Only change if you have a custom key.', 'ai-ready-wp' ), 'class' => 'large-text code', ] ); $this->add_field( self::SECTION_ADVANCED, 'mcp_servers', __( 'MCP Servers (JSON)', 'ai-ready-wp' ), 'textarea', [ 'description' => __( 'JSON array of MCP servers. Example: [{"name":"my-server","description":"My MCP Server","transport":"streamable-http","url":"https://example.com/mcp"}]', 'ai-ready-wp' ), ] ); } private function add_field( string $section, string $id, string $label, string $type = 'text', array $args = [] ): void { add_settings_field( $id, $label, [ $this, 'render_field' ], self::PAGE_SLUG, $section, array_merge( $args, [ 'id' => $id, 'type' => $type, ] ) ); } public function render_field( array $args ): void { $settings = AI_Ready_WP::get_settings(); $id = $args['id']; $type = $args['type'] ?? 'text'; $value = $settings[ $id ] ?? ''; $name = AIREWP_OPTION_KEY . '[' . $id . ']'; $class = $args['class'] ?? ''; $desc = $args['description'] ?? ''; switch ( $type ) { case 'textarea': printf( '', esc_attr( $name ), esc_attr( $id ), esc_textarea( $value ) ); break; case 'select': printf( ''; break; case 'checkbox': printf( '', esc_attr( $name ), esc_attr( $id ), checked( $value, true, false ), esc_html( $desc ) ); return; // Don't print description again case 'email': case 'url': case 'text': default: $field_class = $class ?: 'regular-text'; printf( '', esc_attr( $type ), esc_attr( $name ), esc_attr( $id ), esc_attr( $value ), esc_attr( $field_class ) ); break; } if ( $desc && 'checkbox' !== $type ) { printf( '

%s

', esc_html( $desc ) ); } } /** * Render AI Crawler Analytics section. */ public function render_crawler_section(): void { $stats = AIREWP_Crawler_Analytics::get_stats(); $total = AIREWP_Crawler_Analytics::get_total_visits(); $log = AIREWP_Crawler_Analytics::get_log( 10 ); if ( empty( $stats ) ) { printf( '

%s

', esc_html__( 'No AI crawler visits recorded yet. Data appears after bots visit your site.', 'ai-ready-wp' ) ); return; } echo '
'; echo '
'; // Total visits card printf( '
%d%s
', absint( $total ), esc_html__( 'Total AI Visits', 'ai-ready-wp' ) ); // Unique bots card printf( '
%d%s
', count( $stats ), esc_html__( 'Unique Bots', 'ai-ready-wp' ) ); echo '
'; // Per-bot breakdown echo ''; echo ''; echo ''; echo ''; // Sort by visits descending uasort( $stats, fn( $a, $b ) => ( $b['visits'] ?? 0 ) - ( $a['visits'] ?? 0 ) ); foreach ( $stats as $bot => $data ) { printf( '', esc_html( $bot ), absint( $data['visits'] ?? 0 ), esc_html( $data['last_seen'] ?? '—' ) ); } echo '
' . esc_html__( 'Bot', 'ai-ready-wp' ) . '' . esc_html__( 'Visits', 'ai-ready-wp' ) . '' . esc_html__( 'Last Seen', 'ai-ready-wp' ) . '
%s%d%s
'; // Recent log if ( ! empty( $log ) ) { echo '

' . esc_html__( 'Recent Visits', 'ai-ready-wp' ) . '

'; echo ''; echo ''; echo ''; echo ''; echo ''; foreach ( $log as $entry ) { printf( '', esc_html( $entry['bot'] ), esc_html( $entry['url'] ), esc_html( $entry['time'] ) ); } echo '
' . esc_html__( 'Bot', 'ai-ready-wp' ) . '' . esc_html__( 'URL', 'ai-ready-wp' ) . '' . esc_html__( 'Time', 'ai-ready-wp' ) . '
%s%s%s
'; } echo '
'; } public function sanitize_settings( $input ): array { if ( ! is_array( $input ) ) { return []; } $old = AI_Ready_WP::get_settings(); $sanitized = []; // Text fields $text_fields = [ 'company_name', 'company_desc', 'company_phone', 'company_address', 'company_city', 'company_zip', 'company_country', 'services', 'indexnow_key', 'custom_tools', 'custom_skills', ]; foreach ( $text_fields as $field ) { $sanitized[ $field ] = sanitize_text_field( $input[ $field ] ?? $old[ $field ] ?? '' ); } // JSON fields — validate structure, keep raw JSON. // NOTE: no wp_unslash() here — options.php already unslashes the // input before the sanitize callback runs; a second unslash would // strip legitimate escapes inside JSON strings (e.g. \" sequences) // and silently blank out valid input. $mcp_raw = $input['mcp_servers'] ?? $old['mcp_servers'] ?? ''; if ( ! empty( $mcp_raw ) ) { $mcp_parsed = json_decode( $mcp_raw, true ); $sanitized['mcp_servers'] = is_array( $mcp_parsed ) ? wp_json_encode( $mcp_parsed ) : ''; } else { $sanitized['mcp_servers'] = ''; } // Email $sanitized['company_email'] = sanitize_email( $input['company_email'] ?? $old['company_email'] ?? '' ); // URLs $url_fields = [ 'company_url', 'company_logo', 'social_x', 'social_linkedin', 'social_instagram', 'social_facebook', 'social_github', ]; foreach ( $url_fields as $field ) { $sanitized[ $field ] = esc_url_raw( $input[ $field ] ?? $old[ $field ] ?? '' ); } // Select fields $sanitized['industry'] = sanitize_text_field( $input['industry'] ?? $old['industry'] ?? 'general' ); $sanitized['schema_type'] = in_array( $input['schema_type'] ?? '', [ 'Organization', 'LocalBusiness' ], true ) ? $input['schema_type'] : 'Organization'; // Checkboxes (unchecked = not in POST) $checkboxes = [ 'enable_agents_json', 'enable_agent_card', 'enable_llms_txt', 'enable_json_ld', 'enable_indexnow', 'enable_rest_api', 'enable_robots_ai', 'enable_crawler_analytics', 'enable_mcp_json', 'robots_block_training', 'robots_block_retrieval', // v4.0 'enable_seo', ]; foreach ( $checkboxes as $cb ) { $sanitized[ $cb ] = ! empty( $input[ $cb ] ); } // Languages $sanitized['languages'] = $old['languages'] ?? [ get_locale() ]; // NOTE: regeneration is triggered from the update_option hooks below, // NOT here — this callback runs BEFORE the option is written, so a // do_action here would regenerate the files from the OLD values. return $sanitized; } /** * Fire the regeneration hook once the option value is actually persisted. * Covers both a changed value (update_option_*) and the first save * (add_option_*). */ public static function trigger_regeneration(): void { do_action( 'airewp_settings_updated' ); } public function render_page(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } $this->maybe_handle_recheck(); $settings = AI_Ready_WP::get_settings(); // This is the screen where a fresh measurement is worth its latency. $score = AIREWP_Dashboard::calculate_score( $settings, true ); ?>

broken_endpoints( $settings ); ?> render_write_warning( ! empty( $broken ) ); ?> render_reachability_warning( $broken ); ?>
/10

render_file_status( 'agents.json', '.well-known/agents.json', $settings['enable_agents_json'] ); ?> render_file_status( 'agent-card.json', '.well-known/agent-card.json', $settings['enable_agent_card'] ); ?> render_file_status( 'agent.json', '.well-known/agent.json', $settings['enable_agent_card'] ); ?> render_file_status( 'mcp.json', '.well-known/mcp.json', $settings['enable_mcp_json'] ); ?> render_file_status( 'llms.txt', 'llms.txt', $settings['enable_llms_txt'] ); ?> render_file_status( 'llms-full.txt', 'llms-full.txt', $settings['enable_llms_txt'] ); ?> render_file_status( 'IndexNow Key', $settings['indexnow_key'] . '.txt', true ); ?>

render_module_status( __( 'SEO', 'ai-ready-wp' ), $settings['enable_seo'], __( 'Meta tags, OpenGraph, Twitter Cards', 'ai-ready-wp' ) ); ?> render_module_status( __( 'AI Discovery', 'ai-ready-wp' ), $settings['enable_agents_json'], __( 'agents.json, llms.txt, agent-card.json', 'ai-ready-wp' ) ); ?>

.

[ 'class' => [] ] ]; $status = $enabled ? '' . esc_html__( 'Active', 'ai-ready-wp' ) . '' : '' . esc_html__( 'Disabled', 'ai-ready-wp' ) . ''; printf( '%s%s%s', esc_html( $label ), wp_kses( $status, $allowed ), esc_html( $details ) ); } /** * One row of the discovery table. * * The status column answers "does the URL work", because that is the whole * point of the document. Whether a file was written is shown next to it as * context: it is the difference between the web server answering on its own * and WordPress building the answer, and both are fine. * * Paths without a reachability check (llms.txt, the IndexNow key) still * report file existence, which for those really is the deciding fact. */ private function render_file_status( string $label, string $path, bool $enabled ): void { $fs = AI_Ready_WP::filesystem(); $file_exists = null !== $fs && $fs->exists( AIREWP_ROOT_PATH . $path ); $url = home_url( '/' . $path ); $checked = array_key_exists( $path, AIREWP_Endpoint_Check::paths() ); $allowed_html = [ 'span' => [ 'class' => [] ], 'a' => [ 'href' => [], 'target' => [] ], 'code' => [], ]; if ( ! $enabled ) { $status = '' . esc_html__( 'Disabled', 'ai-ready-wp' ) . ''; } elseif ( ! $checked ) { $status = $file_exists ? '✓ ' . esc_html__( 'Generated', 'ai-ready-wp' ) . '' : '✗ ' . esc_html__( 'Missing', 'ai-ready-wp' ) . ''; } else { $settings = AI_Ready_WP::get_settings(); $state = AIREWP_Endpoint_Check::state( $path, $settings ); $note = $file_exists ? esc_html__( 'from file', 'ai-ready-wp' ) : esc_html__( 'built on the fly, no file written', 'ai-ready-wp' ); if ( AIREWP_Endpoint_Check::OK === $state ) { $status = '✓ ' . esc_html__( 'Answering', 'ai-ready-wp' ) . '' . '' . $note . ''; } elseif ( AIREWP_Endpoint_Check::BROKEN === $state ) { $status = '✗ ' . esc_html__( 'Not answering', 'ai-ready-wp' ) . '' . '' . esc_html__( 'the web server does not pass this URL to WordPress', 'ai-ready-wp' ) . ''; } else { $status = '' . esc_html__( 'Not verified', 'ai-ready-wp' ) . '' . '' . $note . ''; } } // Always linked when the feature is on. The URL is the deliverable, and // the old rule (link only when a file exists) hid the one link a site // owner needs to click in exactly the case where something is wrong. $url_col = $enabled ? '' . esc_html( $path ) . '' : '' . esc_html( $path ) . ''; printf( '%s%s%s', esc_html( $label ), wp_kses( $status, $allowed_html ), wp_kses( $url_col, $allowed_html ) ); } /** * URL that drops the cached measurement and reloads this page. */ private function recheck_url(): string { return wp_nonce_url( add_query_arg( [ 'page' => self::PAGE_SLUG, 'airewp_recheck' => '1' ], admin_url( 'options-general.php' ) ), 'airewp_recheck' ); } /** * Handle the "check again" link. */ private function maybe_handle_recheck(): void { if ( empty( $_GET['airewp_recheck'] ) ) { return; } if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'airewp_recheck' ) ) { return; } // Rebuild first, then re-measure. The usual reason for clicking this is // that permissions were just fixed, and measuring before rebuilding // would report the state the site was in a minute ago. AI_Ready_WP::instance()->regenerate_all(); AIREWP_Endpoint_Check::invalidate(); } /** * Discovery paths the last measurement found unreachable. * * @return string[] */ private function broken_endpoints( array $settings ): array { $status = AIREWP_Endpoint_Check::get_status( $settings ); $broken = []; foreach ( $status['paths'] as $path => $result ) { if ( AIREWP_Endpoint_Check::BROKEN === ( $result['state'] ?? '' ) ) { $broken[] = $path; } } return $broken; } /** * Explain a failed write, with the facts needed to fix it. * * Before this the plugin knew perfectly well that a write had failed and * said nothing at all. The status table showed "Missing" and left the site * owner to guess whether that was a bug, a setting, or their server. * * The wording follows the measurement rather than the common case. Naming * .well-known/ unconditionally would be wrong on a site where that * directory is fine and something else is not, and a warning that points at * the wrong directory costs more time than no warning at all. */ private function render_write_warning( bool $any_broken ): void { if ( AI_Ready_WP::writes_ok() ) { return; } $info = AI_Ready_WP::well_known_diagnostics(); // Strictly false, not falsy: null means the transport could not say, // and an unknown is not evidence against this directory. $well_known = $info['exists'] && false === $info['writable']; $target = $well_known ? $info['path'] : AIREWP_ROOT_PATH; // Only worth printing when the two differ. Equal names mean the cause // is something else (the mode, an immutable flag, SELinux, open_basedir) // and pointing at ownership would send the reader down a dead end. $show_users = $well_known && '' !== $info['owner'] && '' !== $info['php_user'] && $info['owner'] !== $info['php_user']; ?>

' . esc_html( $target ) . '' ); ?> ' . esc_html( $info['owner'] ) . '', '' . esc_html( $info['php_user'] ) . '' ); ?>

chown -R