post meta key. */ private const SEO_FIELDS = [ 'title' => self::META_TITLE, 'description' => self::META_DESC, 'keyword' => self::META_KEYWORD, 'canonical' => self::META_CANONICAL, 'noindex' => self::META_NOINDEX, 'nofollow' => self::META_NOFOLLOW, ]; // The settings vocabulary, grouped the way // AIREWP_Settings::sanitize_settings() treats each group. private const TEXT_FIELDS = [ 'company_name', 'company_desc', 'company_phone', 'company_address', 'company_city', 'company_zip', 'company_country', 'services', 'indexnow_key', 'custom_tools', 'custom_skills', ]; private const URL_FIELDS = [ 'company_url', 'company_logo', 'social_x', 'social_linkedin', 'social_instagram', 'social_facebook', 'social_github', ]; private const CHECKBOX_FIELDS = [ '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', 'enable_seo', 'seo_force_with_plugin', ]; /** The industry presets the settings page offers, by slug. */ private const INDUSTRIES = [ 'general', 'restaurant', 'real-estate', 'ecommerce', 'saas', 'agency', 'medical', 'legal', 'finance', 'education', 'construction', 'fitness', 'beauty', 'automotive', 'hotel', 'nonprofit', ]; private const SCHEMA_TYPES = [ 'Organization', 'LocalBusiness' ]; /** * Stable keys for the readiness checks, in the order * AIREWP_Dashboard::get_checks() returns them. The MCP check is appended * by get_checks() only when mcp.json is switched on, and score_block() * appends its key under the same condition. */ private const CHECK_KEYS = [ 'agents_json', 'agent_card', 'llms_txt', 'json_ld', 'robots_ai', 'company_name', 'description', 'email', 'services', 'logo', 'indexnow', ]; /** Settings the readiness score needs filled in. */ private const REQUIRED_SETTINGS = [ 'company_name', 'company_desc', 'company_email', 'services', 'company_logo' ]; private const MAX_PER_PAGE = 100; private const MAX_INDEXNOW_URLS = 100; private const TEXT_LIMIT = 3000; private const RECENT_VISITS = 50; private const MAX_HEADINGS = 100; public function __construct() { add_action( 'rest_api_init', [ $this, 'register_routes' ] ); } public function register_routes(): void { $manage = [ $this, 'can_manage' ]; register_rest_route( self::API_NAMESPACE, '/admin/status', [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_status' ], 'permission_callback' => $manage, 'args' => [ 'measure' => [ 'type' => 'boolean', 'default' => false, 'description' => 'Take a fresh measurement of the discovery URLs when nothing is cached.', ], ], ] ); // EDITABLE (POST, PUT, PATCH) rather than PUT alone: some hosts and // proxies drop PUT, and a POST with the same body is no less safe. register_rest_route( self::API_NAMESPACE, '/admin/settings', [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [ $this, 'update_settings' ], 'permission_callback' => $manage, ] ); register_rest_route( self::API_NAMESPACE, '/admin/regenerate', [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'regenerate' ], 'permission_callback' => $manage, ] ); register_rest_route( self::API_NAMESPACE, '/admin/robots', [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_robots' ], 'permission_callback' => $manage, ] ); register_rest_route( self::API_NAMESPACE, '/admin/pages', [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'list_pages' ], 'permission_callback' => $manage, 'args' => [ 'type' => [ 'type' => 'string', 'default' => 'any', 'enum' => [ 'page', 'post', 'any' ] ], 'status' => [ 'type' => 'string', 'default' => 'publish', 'enum' => [ 'publish', 'draft', 'any' ] ], 'per_page' => [ 'type' => 'integer', 'default' => 50, 'minimum' => 1, 'maximum' => self::MAX_PER_PAGE ], 'page' => [ 'type' => 'integer', 'default' => 1, 'minimum' => 1 ], 'missing' => [ 'type' => 'string', 'default' => '', 'enum' => [ '', 'title', 'description', 'keyword' ] ], 'search' => [ 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field' ], ], ] ); register_rest_route( self::API_NAMESPACE, '/admin/pages/(?P\d+)', [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_page' ], 'permission_callback' => $manage, 'args' => [ 'id' => [ 'type' => 'integer', 'minimum' => 1 ] ], ], [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [ $this, 'update_page' ], 'permission_callback' => [ $this, 'can_edit_page' ], 'args' => [ 'id' => [ 'type' => 'integer', 'minimum' => 1 ] ], ], ] ); register_rest_route( self::API_NAMESPACE, '/admin/indexnow', [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'submit_indexnow' ], 'permission_callback' => $manage, ] ); register_rest_route( self::API_NAMESPACE, '/admin/crawlers', [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_crawlers' ], 'permission_callback' => $manage, ] ); register_rest_route( self::API_NAMESPACE, '/admin/diagnostics', [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_diagnostics' ], 'permission_callback' => $manage, ] ); } // ─── Permissions ──────────────────────────────────────── public function can_manage(): bool { return current_user_can( 'manage_options' ); } /** * Writing SEO fields needs the site-wide right AND the right to edit * this particular post. A missing post is answered with 404 by the * handler; the capability check needs a post to check against. */ public function can_edit_page( \WP_REST_Request $request ): bool { if ( ! current_user_can( 'manage_options' ) ) { return false; } $post = get_post( (int) $request['id'] ); return ! $post instanceof \WP_Post || current_user_can( 'edit_post', $post->ID ); } // ─── Routes ───────────────────────────────────────────── /** * GET /admin/status: the whole picture in one answer. */ public function get_status( \WP_REST_Request $request ): \WP_REST_Response { $settings = AI_Ready_WP::get_settings(); $may_measure = (bool) $request->get_param( 'measure' ); return $this->respond( [ 'plugin' => $this->plugin_block(), 'site' => $this->site_block(), 'settings' => $settings, 'score' => $this->score_block( $settings, $may_measure ), 'endpoints' => $this->endpoints_block( $settings, $may_measure ), 'write_status' => $this->write_status_block(), 'seo' => $this->seo_block( $settings ), 'robots' => $this->robots_block( $settings ), ] ); } /** * PUT /admin/settings with {"changes": {...}}. * * Only known keys, each checked against the rules of the settings page, * then merged into the stored values and passed through the very same * sanitize_settings() the settings form uses. The rebuild is not started * here: update_option() fires the update_option_airewp_settings hook, * and that hook regenerates the files and drops the cached measurement. * When nothing changed, update_option() does not write and nothing is * rebuilt, which is what regenerated:false says. * * @return \WP_REST_Response|\WP_Error */ public function update_settings( \WP_REST_Request $request ) { $body = $this->body( $request ); $changes = $body['changes'] ?? null; if ( ! is_array( $changes ) || [] === $changes ) { return $this->error( 'missing_changes', __( 'Send a JSON body of the form {"changes": {"setting": value}}.', 'ai-ready-wp' ), 400 ); } $known = self::settable_keys(); $unknown = []; foreach ( array_keys( $changes ) as $key ) { if ( ! in_array( (string) $key, $known, true ) ) { $unknown[] = (string) $key; } } if ( [] !== $unknown ) { return $this->error( 'unknown_keys', sprintf( /* translators: %s: comma-separated list of setting names */ __( 'Unknown settings: %s', 'ai-ready-wp' ), implode( ', ', $unknown ) ), 400, [ 'unknown_keys' => $unknown, 'known_keys' => $known ] ); } $old = AI_Ready_WP::get_settings(); $input = $old; $errors = []; foreach ( $changes as $key => $value ) { [ $clean, $message ] = $this->normalize_setting( (string) $key, $value ); if ( '' !== $message ) { $errors[ $key ] = $message; continue; } $input[ $key ] = $clean; } if ( [] !== $errors ) { return $this->error( 'invalid_value', __( 'Some values are invalid.', 'ai-ready-wp' ), 400, [ 'errors' => $errors ] ); } // Checkboxes are passed explicitly, true and false alike: for the // settings form "not in the array" means unchecked, and a merge that // left one out would switch that feature off. $sanitized = self::bare( AIREWP_Settings::class )->sanitize_settings( $input ); $regenerated = (bool) update_option( AIREWP_OPTION_KEY, $sanitized ); $new = AI_Ready_WP::get_settings(); $changed = []; foreach ( array_keys( $changes ) as $key ) { if ( ( $old[ $key ] ?? null ) !== ( $new[ $key ] ?? null ) ) { $changed[] = (string) $key; } } return $this->respond( [ 'settings' => $new, 'changed' => $changed, 'regenerated' => $regenerated, 'write_status' => $this->write_status_block(), 'score' => $this->score_block( $new, false ), ] ); } /** * POST /admin/regenerate: rebuild the files, then measure the URLs again. */ public function regenerate(): \WP_REST_Response { $ok = AI_Ready_WP::instance()->regenerate_all(); $settings = AI_Ready_WP::get_settings(); AIREWP_Endpoint_Check::invalidate(); AIREWP_Endpoint_Check::run( $settings ); return $this->respond( [ 'ok' => $ok, 'write_status' => $this->write_status_block(), 'endpoints' => $this->endpoints_block( $settings, false ), ] ); } /** * GET /admin/robots: robots.txt as it is served, plus the policy behind it. * * The text is built the way do_robots() builds it, then handed through * the same filter, so every plugin that speaks up there (core's sitemap * line, this plugin's AI groups, a security plugin) is in the answer. * do_robots() itself is not called: it sends a text/plain header that * would replace the JSON one. */ public function get_robots(): \WP_REST_Response { $settings = AI_Ready_WP::get_settings(); $default = "User-agent: *\n"; $default .= 'Disallow: ' . wp_parse_url( admin_url(), PHP_URL_PATH ) . "\n"; $default .= 'Allow: ' . wp_parse_url( admin_url( 'admin-ajax.php' ), PHP_URL_PATH ) . "\n"; $public = (bool) get_option( 'blog_public' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- the core robots.txt filter, applied on purpose so the answer is the served file. $content = (string) apply_filters( 'robots_txt', $default, $public ); $managed = ! empty( $settings['enable_robots_ai'] ); $block_training = ! empty( $settings['robots_block_training'] ); $block_retrieval = ! empty( $settings['robots_block_retrieval'] ); $bots = []; foreach ( AIREWP_Robots_Txt::TRAINING_BOTS as $name => $purpose ) { $bots[] = [ 'name' => $name, 'group' => 'training', 'allowed' => ! ( $managed && $block_training ), 'purpose' => $purpose, ]; } foreach ( AIREWP_Robots_Txt::RETRIEVAL_BOTS as $name => $purpose ) { $bots[] = [ 'name' => $name, 'group' => 'retrieval', 'allowed' => ! ( $managed && $block_retrieval ), 'purpose' => $purpose, ]; } return $this->respond( [ 'content' => $content, 'public' => $public, 'policy' => [ 'managed' => $managed, 'block_training' => $block_training, 'block_retrieval' => $block_retrieval, ], 'bots' => $bots, 'signal' => AIREWP_Robots_Txt::content_signal( $block_training, $block_retrieval ), ] ); } /** * GET /admin/pages: the pages and posts with their SEO fields. */ public function list_pages( \WP_REST_Request $request ): \WP_REST_Response { $settings = AI_Ready_WP::get_settings(); $per_page = (int) $request->get_param( 'per_page' ); $page = (int) $request->get_param( 'page' ); $missing = (string) $request->get_param( 'missing' ); $search = (string) $request->get_param( 'search' ); $args = [ 'post_type' => $this->post_types( (string) $request->get_param( 'type' ) ), 'post_status' => (string) $request->get_param( 'status' ), 'posts_per_page' => $per_page, 'paged' => $page, 'orderby' => 'ID', 'order' => 'ASC', 'ignore_sticky_posts' => true, ]; if ( '' !== $missing ) { $meta_key = self::SEO_FIELDS[ $missing ]; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- the filter is the point of the request; it is an administrator's own call and paginated. $args['meta_query'] = [ 'relation' => 'OR', [ 'key' => $meta_key, 'compare' => 'NOT EXISTS' ], [ 'key' => $meta_key, 'value' => '', 'compare' => '=' ], ]; } if ( '' !== $search ) { $args['s'] = $search; } $query = new \WP_Query( $args ); $pages = []; foreach ( $query->posts as $post ) { if ( $post instanceof \WP_Post ) { $pages[] = $this->page_entry( $post, $settings ); } } return $this->respond( [ 'total' => (int) $query->found_posts, 'page' => $page, 'per_page' => $per_page, 'total_pages' => (int) $query->max_num_pages, 'pages' => $pages, ] ); } /** * GET /admin/pages/{id}: one page in full. * * @return \WP_REST_Response|\WP_Error */ public function get_page( \WP_REST_Request $request ) { $post = $this->find_post( (int) $request['id'] ); if ( null === $post ) { return $this->error( 'not_found', __( 'No page or post with this ID.', 'ai-ready-wp' ), 404 ); } return $this->respond( $this->page_detail( $post, AI_Ready_WP::get_settings() ) ); } /** * PUT /admin/pages/{id} with {"seo": {...}}: set SEO fields. * * Same sanitizing as the meta box: text fields through * sanitize_text_field(), the canonical URL through esc_url_raw(), the two * checkboxes stored as '1' or ''. Only the fields that were sent change. * * @return \WP_REST_Response|\WP_Error */ public function update_page( \WP_REST_Request $request ) { $post = $this->find_post( (int) $request['id'] ); if ( null === $post ) { return $this->error( 'not_found', __( 'No page or post with this ID.', 'ai-ready-wp' ), 404 ); } $body = $this->body( $request ); $seo = $body['seo'] ?? null; if ( ! is_array( $seo ) || [] === $seo ) { return $this->error( 'missing_seo', __( 'Send a JSON body of the form {"seo": {"title": "...", "description": "..."}}.', 'ai-ready-wp' ), 400 ); } $unknown = array_values( array_diff( array_map( 'strval', array_keys( $seo ) ), array_keys( self::SEO_FIELDS ) ) ); if ( [] !== $unknown ) { return $this->error( 'unknown_keys', sprintf( /* translators: %s: comma-separated list of field names */ __( 'Unknown SEO fields: %s', 'ai-ready-wp' ), implode( ', ', $unknown ) ), 400, [ 'unknown_keys' => $unknown, 'known_keys' => array_keys( self::SEO_FIELDS ) ] ); } $errors = []; $values = []; foreach ( $seo as $field => $value ) { if ( 'noindex' === $field || 'nofollow' === $field ) { $bool = self::to_bool( $value ); if ( null === $bool ) { $errors[ $field ] = __( 'must be true or false', 'ai-ready-wp' ); continue; } $values[ $field ] = $bool ? '1' : ''; continue; } if ( ! is_scalar( $value ) && null !== $value ) { $errors[ $field ] = __( 'must be a string', 'ai-ready-wp' ); continue; } $clean = sanitize_text_field( (string) $value ); if ( 'canonical' === $field && '' !== $clean ) { $clean = esc_url_raw( $clean ); if ( '' === $clean || ! filter_var( $clean, FILTER_VALIDATE_URL ) ) { $errors[ $field ] = __( 'must be a URL', 'ai-ready-wp' ); continue; } } $values[ $field ] = $clean; } if ( [] !== $errors ) { return $this->error( 'invalid_value', __( 'Some values are invalid.', 'ai-ready-wp' ), 400, [ 'errors' => $errors ] ); } foreach ( $values as $field => $value ) { update_post_meta( $post->ID, self::SEO_FIELDS[ $field ], $value ); } $settings = AI_Ready_WP::get_settings(); $fresh = get_post( $post->ID ); $detail = $this->page_detail( $fresh instanceof \WP_Post ? $fresh : $post, $settings ); // Stored is not the same as printed. Saying so here spares the AI a // claim the page source would contradict. if ( empty( $settings['enable_seo'] ) || AIREWP_SEO::yields() ) { $detail['notice'] = __( 'The SEO module is switched off or stands down for another SEO plugin, so these fields are stored but not printed.', 'ai-ready-wp' ); } return $this->respond( $detail ); } /** * POST /admin/indexnow with {"urls": [...]}. * * Only this site's own URLs go out; everything else is listed under * skipped. The ping itself is fire-and-forget, the same call the plugin * makes after a rebuild, so submitted counts what was handed over. * * @return \WP_REST_Response|\WP_Error */ public function submit_indexnow( \WP_REST_Request $request ) { $body = $this->body( $request ); $urls = $body['urls'] ?? null; if ( ! is_array( $urls ) || [] === $urls ) { return $this->error( 'missing_urls', __( 'Send a JSON body of the form {"urls": ["https://..."]}.', 'ai-ready-wp' ), 400 ); } if ( count( $urls ) > self::MAX_INDEXNOW_URLS ) { return $this->error( 'too_many_urls', sprintf( /* translators: %d: maximum number of URLs per request */ __( 'At most %d URLs per request.', 'ai-ready-wp' ), self::MAX_INDEXNOW_URLS ), 400 ); } $settings = AI_Ready_WP::get_settings(); $enabled = ! empty( $settings['enable_indexnow'] ); $key_present = ! empty( $settings['indexnow_key'] ); if ( ! $enabled || ! $key_present ) { return $this->error( 'indexnow_unavailable', __( 'IndexNow is switched off or has no key, so nothing can be submitted.', 'ai-ready-wp' ), 409, [ 'enabled' => $enabled, 'key_present' => $key_present ] ); } $home = strtolower( untrailingslashit( home_url() ) ); $accepted = []; $skipped = []; foreach ( $urls as $raw ) { if ( ! is_scalar( $raw ) ) { continue; } $url = esc_url_raw( trim( (string) $raw ) ); $lc = strtolower( $url ); if ( '' === $url || ( $lc !== $home && ! str_starts_with( $lc, $home . '/' ) ) ) { $skipped[] = (string) $raw; continue; } $accepted[] = $url; } $accepted = array_values( array_unique( $accepted ) ); if ( [] !== $accepted ) { AIREWP_IndexNow::ping_batch( $accepted, $settings ); } return $this->respond( [ 'submitted' => count( $accepted ), 'skipped' => $skipped, 'key_present' => true, ] ); } /** * GET /admin/crawlers: what the crawler analytics recorded. */ public function get_crawlers(): \WP_REST_Response { $settings = AI_Ready_WP::get_settings(); $stats = AIREWP_Crawler_Analytics::get_stats(); $counts = []; $last = []; foreach ( $stats as $bot => $row ) { $counts[ $bot ] = (int) ( $row['visits'] ?? 0 ); $last[ $bot ] = (string) ( $row['last_seen'] ?? '' ); } arsort( $counts ); $recent = []; foreach ( AIREWP_Crawler_Analytics::get_log( self::RECENT_VISITS ) as $entry ) { $recent[] = [ 'bot' => (string) ( $entry['bot'] ?? '' ), 'url' => (string) ( $entry['url'] ?? '' ), 'time' => (string) ( $entry['time'] ?? '' ), ]; } return $this->respond( [ 'enabled' => ! empty( $settings['enable_crawler_analytics'] ), 'total_visits' => AIREWP_Crawler_Analytics::get_total_visits(), 'stats' => (object) $counts, 'last_seen' => (object) $last, 'recent' => $recent, ] ); } /** * GET /admin/diagnostics: measure now, then name what is wrong and how * to fix it. */ public function get_diagnostics(): \WP_REST_Response { $settings = AI_Ready_WP::get_settings(); AIREWP_Endpoint_Check::invalidate(); AIREWP_Endpoint_Check::run( $settings ); $endpoints = $this->endpoints_block( $settings, false ); $write = $this->write_status_block(); $software = isset( $_SERVER['SERVER_SOFTWARE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) : ''; return $this->respond( [ 'write_status' => $write, 'endpoints' => $endpoints, 'server' => [ 'software' => $software, 'php' => PHP_VERSION, 'wp' => get_bloginfo( 'version' ), 'https' => str_starts_with( home_url(), 'https://' ), 'rewrite_ok' => $this->rewrite_ok(), ], 'seo' => $this->seo_block( $settings ), 'problems' => $this->problems( $settings, $endpoints, $write ), ] ); } // ─── Blocks shared between routes ─────────────────────── private function plugin_block(): array { $pro = [ 'installed' => defined( 'AIREWP_PRO_VERSION' ) ]; if ( $pro['installed'] ) { $pro['version'] = AIREWP_PRO_VERSION; // The key is the site owner's own, handed to the site owner's // own AI over a manage_options route. It is what lets the MCP // server tell Free from Pro without asking anyone to type it. if ( class_exists( 'AIREWP_Pro_License' ) ) { $pro['license_status'] = AIREWP_Pro_License::get_status(); $key = (string) get_option( 'airewp_pro_license_key', '' ); if ( '' !== $key ) { $pro['license_key'] = $key; } $expires = (string) get_option( 'airewp_pro_license_expiry', '' ); if ( '' !== $expires ) { $pro['expires'] = $expires; } } } return [ 'version' => AIREWP_VERSION, 'generated_version' => (string) get_option( 'airewp_generated_version', '' ), 'pro' => $pro, ]; } private function site_block(): array { $pages = wp_count_posts( 'page' ); $posts = wp_count_posts( 'post' ); return [ 'name' => get_bloginfo( 'name' ), 'tagline' => get_bloginfo( 'description' ), 'home_url' => home_url(), 'wp_version' => get_bloginfo( 'version' ), 'locale' => get_locale(), 'https' => str_starts_with( home_url(), 'https://' ), 'permalink_structure' => (string) get_option( 'permalink_structure' ), 'front_page' => 'page' === get_option( 'show_on_front' ) ? 'static' : 'posts', 'theme' => (string) wp_get_theme()->get( 'Name' ), 'counts' => [ 'pages' => (int) ( $pages->publish ?? 0 ), 'posts' => (int) ( $posts->publish ?? 0 ), ], ]; } private function score_block( array $settings, bool $may_measure ): array { $keys = self::CHECK_KEYS; if ( ! empty( $settings['enable_mcp_json'] ) ) { $keys[] = 'mcp_json'; } $checks = []; foreach ( array_values( AIREWP_Dashboard::get_checks( $settings, $may_measure ) ) as $i => $check ) { $checks[] = [ 'key' => $keys[ $i ] ?? sanitize_key( str_replace( ' ', '_', (string) $check['label'] ) ), 'label' => (string) $check['label'], 'pass' => (bool) $check['pass'], ]; } $score = AIREWP_Dashboard::calculate_score( $settings, $may_measure ); return [ 'score' => $score, 'max' => 10, 'message' => AIREWP_Dashboard::get_score_message( $score ), 'checks' => $checks, ]; } /** * State of every discovery URL the settings expect to answer. * * @return array */ private function endpoints_block( array $settings, bool $may_measure ): array { $status = AIREWP_Endpoint_Check::get_status( $settings, $may_measure ); $out = []; foreach ( array_keys( AIREWP_Endpoint_Check::paths() ) as $path ) { if ( ! AIREWP_Endpoint_Check::expected( $path, $settings ) ) { continue; } $out[ $path ] = (string) ( $status['paths'][ $path ]['state'] ?? AIREWP_Endpoint_Check::UNKNOWN ); } return $out; } /** * The same facts the settings page puts into its write warning. */ private function write_status_block(): array { $ok = AI_Ready_WP::writes_ok(); $info = AI_Ready_WP::well_known_diagnostics(); // Strictly false: 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; $block = [ 'ok' => $ok, 'dir' => $target ]; if ( '' !== $info['owner'] ) { $block['owner'] = $info['owner']; } if ( '' !== $info['php_user'] ) { $block['php_user'] = $info['php_user']; } if ( ! $ok ) { $user = '' !== $info['php_user'] ? $info['php_user'] . ':' . $info['php_user'] : 'USER:GROUP'; $block['chown_command'] = 'chown -R ' . $user . ' ' . $target; } return $block; } private function seo_block( array $settings ): array { $block = [ 'enabled' => ! empty( $settings['enable_seo'] ), 'competing_plugin' => AIREWP_SEO::competing_plugin(), 'yields' => AIREWP_SEO::yields(), 'force_with_plugin' => ! empty( $settings['seo_force_with_plugin'] ), ]; if ( AIREWP_SEO::serves_sitemap( $settings ) ) { $block['sitemap_url'] = home_url( '/sitemap.xml' ); } return $block; } private function robots_block( array $settings ): array { return [ 'block_training' => ! empty( $settings['robots_block_training'] ), 'block_retrieval' => ! empty( $settings['robots_block_retrieval'] ), 'bot_count' => count( AIREWP_Robots_Txt::TRAINING_BOTS ) + count( AIREWP_Robots_Txt::RETRIEVAL_BOTS ), ]; } /** * The problems the plugin can see itself, each with its fix. * * @return array */ private function problems( array $settings, array $endpoints, array $write ): array { $problems = []; $add = static function ( string $key, string $severity, string $message, string $fix, array $extra = [] ) use ( &$problems ): void { $problems[] = array_merge( compact( 'key', 'severity', 'message', 'fix' ), $extra ); }; if ( empty( $write['ok'] ) ) { $add( 'write_failed', 'error', sprintf( /* translators: %s: absolute path to a directory */ __( 'WordPress could not write the generated files into %s.', 'ai-ready-wp' ), $write['dir'] ), sprintf( /* translators: %s: a chown command */ __( 'The directory has to belong to the user PHP runs as, with mode 755. Your host can set it with: %s', 'ai-ready-wp' ), $write['chown_command'] ?? 'chown' ) ); } $unverified = false; foreach ( $endpoints as $path => $state ) { if ( AIREWP_Endpoint_Check::BROKEN === $state ) { $add( 'endpoint_broken', 'error', sprintf( /* translators: %s: path of a discovery URL */ __( '/%s does not answer: something in front of WordPress takes the request.', 'ai-ready-wp' ), $path ), __( 'On nginx this is usually a location block for /.well-known/ left behind by the SSL setup. Ask your host to restrict it to /.well-known/acme-challenge/ so the rest reaches WordPress.', 'ai-ready-wp' ), [ 'path' => $path ] ); } elseif ( AIREWP_Endpoint_Check::UNKNOWN === $state ) { $unverified = true; } } if ( $unverified ) { $add( 'endpoints_unverified', 'info', __( 'The site could not reach its own discovery URLs, so their state is unknown. Hosts that block loopback requests cause this; it says nothing about the URLs.', 'ai-ready-wp' ), __( 'Open one of the URLs in a browser. If it answers with the document, nothing is wrong.', 'ai-ready-wp' ) ); } foreach ( self::REQUIRED_SETTINGS as $key ) { if ( empty( $settings[ $key ] ) ) { $add( 'setting_missing', 'warning', sprintf( /* translators: %s: name of a setting */ __( 'The setting %s is empty, and the readiness score needs it.', 'ai-ready-wp' ), $key ), sprintf( /* translators: %s: name of a setting */ __( 'Fill it in under Settings > AI-Ready, or send {"changes": {"%s": "..."}} to PUT /admin/settings.', 'ai-ready-wp' ), $key ), [ 'setting' => $key ] ); } } if ( ! empty( $settings['enable_mcp_json'] ) && ! AIREWP_MCP_JSON::is_active( $settings ) ) { $add( 'mcp_json_without_servers', 'warning', __( 'mcp.json is switched on but no server is listed, so the URL answers 404 and the readiness score loses a point.', 'ai-ready-wp' ), __( 'List at least one server in mcp_servers, or set enable_mcp_json to false.', 'ai-ready-wp' ) ); } if ( ! empty( $settings['enable_robots_ai'] ) && AIREWP_Endpoint_Check::BROKEN === AIREWP_Endpoint_Check::state( 'robots.txt', $settings, true ) ) { $add( 'robots_block_not_served', 'warning', __( 'The robots.txt your site serves does not contain this plugin\'s crawler block, so the AI crawler groups, the content signals and the discovery pointers never reach a crawler. Every setting still says the feature is on, because robots.txt is written through a WordPress filter rather than to a file.', 'ai-ready-wp' ), __( 'Look for a static robots.txt in your site root and delete or move it, or for an SEO plugin that answers /robots.txt itself and let this plugin\'s block through. Your site is not blocked either way; it simply gets none of what this feature adds.', 'ai-ready-wp' ) ); } if ( ! empty( $settings['enable_robots_ai'] ) && ( ! empty( $settings['robots_block_training'] ) || ! empty( $settings['robots_block_retrieval'] ) ) ) { $add( 'ai_crawlers_blocked', 'warning', __( 'robots.txt turns AI crawlers away, so the readiness score cannot reach 10 and your content stays out of the answers those crawlers feed.', 'ai-ready-wp' ), __( 'Set robots_block_training and robots_block_retrieval to false if you want AI visibility. Keeping the block is a legitimate choice; then this is not a problem.', 'ai-ready-wp' ) ); } if ( empty( $settings['enable_robots_ai'] ) ) { $add( 'robots_ai_disabled', 'info', __( 'AI Bot Management is switched off, so robots.txt does not name the AI crawlers and carries no Content-Signal line.', 'ai-ready-wp' ), __( 'Set enable_robots_ai to true.', 'ai-ready-wp' ) ); } $competing = AIREWP_SEO::competing_plugin(); if ( '' !== $competing && ! empty( $settings['enable_seo'] ) ) { if ( AIREWP_SEO::yields() ) { $add( 'seo_plugin_yields', 'info', sprintf( /* translators: %s: name of the other SEO plugin */ __( '%s handles titles, descriptions, social tags and the sitemap on this site. SEO fields written through this API are stored but not printed.', 'ai-ready-wp' ), $competing ), sprintf( /* translators: %s: name of the other SEO plugin */ __( 'Edit titles and descriptions in %s, or set seo_force_with_plugin to true to have this plugin print them as well (expect duplicate tags).', 'ai-ready-wp' ), $competing ), [ 'plugin' => $competing ] ); } else { $add( 'seo_plugin_duplicate', 'warning', sprintf( /* translators: %s: name of the other SEO plugin */ __( '%s is active and this plugin prints its meta tags anyway, so every page carries two sets.', 'ai-ready-wp' ), $competing ), __( 'Set seo_force_with_plugin to false so the SEO module stands down.', 'ai-ready-wp' ), [ 'plugin' => $competing ] ); } } if ( empty( $settings['enable_seo'] ) ) { $add( 'seo_module_off', 'info', __( 'The SEO module is switched off, so this plugin prints no titles, descriptions, social tags or sitemap.', 'ai-ready-wp' ), __( 'Set enable_seo to true unless another plugin provides them.', 'ai-ready-wp' ) ); } if ( ! empty( $settings['enable_indexnow'] ) && empty( $settings['indexnow_key'] ) ) { $add( 'indexnow_without_key', 'warning', __( 'IndexNow is switched on but no key is stored, so no ping is ever sent.', 'ai-ready-wp' ), __( 'Save a key under Advanced (64 lowercase hex characters; the plugin generates one on activation) or through PUT /admin/settings.', 'ai-ready-wp' ) ); } if ( '' === (string) get_option( 'permalink_structure' ) ) { $add( 'plain_permalinks', 'warning', __( 'Permalinks are set to Plain, so WordPress cannot answer the discovery URLs itself; they only work as files on disk.', 'ai-ready-wp' ), __( 'Choose any permalink structure other than Plain under Settings > Permalinks.', 'ai-ready-wp' ) ); } $rank = [ 'error' => 0, 'warning' => 1, 'info' => 2 ]; usort( $problems, static fn( array $a, array $b ): int => ( $rank[ $a['severity'] ] ?? 3 ) <=> ( $rank[ $b['severity'] ] ?? 3 ) ); return $problems; } /** * Whether WordPress routes the discovery URLs at all: pretty permalinks * on, and this plugin's rule in the flushed rewrite rules. */ private function rewrite_ok(): bool { if ( '' === (string) get_option( 'permalink_structure' ) ) { return false; } $rules = get_option( 'rewrite_rules' ); return is_array( $rules ) && isset( $rules['^\.well-known/agents\.json$'] ); } // ─── Pages ────────────────────────────────────────────── /** * Post types the list covers: 'page', 'post', or every public type * except attachments for 'any'. * * @return string[] */ private function post_types( string $type ): array { if ( 'any' !== $type ) { return [ $type ]; } $types = array_values( get_post_types( [ 'public' => true ], 'names' ) ); return array_values( array_diff( $types, [ 'attachment' ] ) ); } private function find_post( int $id ): ?\WP_Post { $post = $id > 0 ? get_post( $id ) : null; if ( ! $post instanceof \WP_Post ) { return null; } if ( ! in_array( $post->post_type, $this->post_types( 'any' ), true ) ) { return null; } if ( in_array( $post->post_status, [ 'auto-draft', 'trash' ], true ) ) { return null; } return $post; } private function seo_fields( int $post_id ): array { $meta = static fn( string $key ): string => (string) get_post_meta( $post_id, $key, true ); return [ 'title' => $meta( self::META_TITLE ), 'description' => $meta( self::META_DESC ), 'keyword' => $meta( self::META_KEYWORD ), 'canonical' => $meta( self::META_CANONICAL ), 'noindex' => '1' === $meta( self::META_NOINDEX ), 'nofollow' => '1' === $meta( self::META_NOFOLLOW ), ]; } private function page_entry( \WP_Post $post, array $settings ): array { $seo = $this->seo_fields( $post->ID ); $text = AI_Ready_WP::to_plain_text( (string) $post->post_content ); return [ 'id' => (int) $post->ID, 'type' => $post->post_type, 'title' => $post->post_title, 'url' => (string) get_permalink( $post ), 'slug' => $post->post_name, 'status' => $post->post_status, 'modified' => (string) get_the_modified_date( 'c', $post ), 'word_count' => self::word_count( $text ), 'images_without_alt' => self::images_without_alt( (string) $post->post_content ), 'seo' => $seo, 'effective' => $this->effective_meta( $post, $seo, $text, $settings ), ]; } private function page_detail( \WP_Post $post, array $settings ): array { $entry = $this->page_entry( $post, $settings ); $text = AI_Ready_WP::to_plain_text( (string) $post->post_content ); $keyword = $entry['seo']['keyword']; $analysis = null; if ( '' !== $keyword ) { $analysis = []; foreach ( AIREWP_SEO::analyze_keyword( $keyword, $post ) as $check ) { $analysis[] = [ 'label' => (string) $check['label'], 'pass' => (bool) $check['pass'] ]; } } $entry['analysis'] = $analysis; $entry['headings'] = self::headings( (string) $post->post_content ); $entry['text'] = mb_substr( $text, 0, self::TEXT_LIMIT ); $entry['excerpt'] = AI_Ready_WP::to_plain_text( (string) $post->post_excerpt ); return $entry; } /** * What the page really prints when the SEO fields are empty: the same * fallbacks as AIREWP_SEO::output_meta_tags(). A password-protected post * prints no description at all, whoever asks. */ private function effective_meta( \WP_Post $post, array $seo, string $text, array $settings ): array { $is_front = 'page' === get_option( 'show_on_front' ) && (int) get_option( 'page_on_front' ) === (int) $post->ID; $title = $seo['title']; if ( '' === $title ) { $title = $is_front ? get_bloginfo( 'name' ) : $post->post_title; } $description = ''; if ( '' === (string) $post->post_password ) { $description = $seo['description']; if ( '' === $description && $is_front ) { $description = (string) ( $settings['company_desc'] ?: get_bloginfo( 'description' ) ); } if ( '' === $description ) { $description = wp_trim_words( $text, 25, '...' ); } } return [ 'title' => $title, 'description' => $description ]; } /** * Words as a reader counts them: runs of non-whitespace. str_word_count() * would split "Mallorca" from its umlauts on a German site. */ private static function word_count( string $text ): int { if ( '' === $text ) { return 0; } $parts = preg_split( '/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY ); return is_array( $parts ) ? count( $parts ) : str_word_count( $text ); } /** * Images without an alt attribute, or with an empty one, in the stored * content. A regex, on purpose: rendering the page for this would cost * more than the answer is worth. */ private static function images_without_alt( string $html ): int { if ( '' === $html || ! preg_match_all( '/]*>/i', $html, $tags ) ) { return 0; } $count = 0; foreach ( $tags[0] as $tag ) { if ( preg_match( '/\balt\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))/i', $tag, $m ) ) { $alt = ''; for ( $i = 1; $i <= 3; $i++ ) { if ( isset( $m[ $i ] ) && '' !== $m[ $i ] ) { $alt = $m[ $i ]; break; } } if ( '' !== trim( $alt ) ) { continue; } } $count++; } return $count; } /** * @return array */ private static function headings( string $html ): array { if ( '' === $html || ! preg_match_all( '/]*>(.*?)<\/h\1\s*>/is', $html, $matches, PREG_SET_ORDER ) ) { return []; } $headings = []; foreach ( $matches as $match ) { $text = trim( html_entity_decode( wp_strip_all_tags( $match[2] ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ); if ( '' === $text ) { continue; } $headings[] = [ 'level' => (int) $match[1], 'text' => $text ]; if ( count( $headings ) >= self::MAX_HEADINGS ) { break; } } return $headings; } // ─── Settings validation ──────────────────────────────── /** * Every key PUT /admin/settings accepts. 'languages' is not among them: * the settings form never writes it either. * * @return string[] */ private static function settable_keys(): array { return array_merge( self::TEXT_FIELDS, self::URL_FIELDS, self::CHECKBOX_FIELDS, [ 'company_email', 'mcp_servers', 'industry', 'schema_type' ] ); } /** * Check one incoming value against the rule of its field. * * sanitize_settings() cleans quietly: an unknown industry is kept as * text, a broken JSON becomes '', a bad email becomes ''. Quiet is fine * for a form with a human looking at the result and wrong for an API * whose caller would report the change as done. So the checks happen * here first, and only clean input reaches sanitize_settings(). * * @return array{0: mixed, 1: string} The value to store and an error message, '' when valid. */ private function normalize_setting( string $key, $value ): array { if ( in_array( $key, self::CHECKBOX_FIELDS, true ) ) { $bool = self::to_bool( $value ); return null === $bool ? [ null, __( 'must be true or false', 'ai-ready-wp' ) ] : [ $bool, '' ]; } if ( 'mcp_servers' === $key ) { if ( is_array( $value ) ) { $value = wp_json_encode( $value ); } if ( ! is_string( $value ) ) { return [ null, __( 'must be a JSON array of servers', 'ai-ready-wp' ) ]; } $value = trim( $value ); if ( '' !== $value && ! is_array( json_decode( $value, true ) ) ) { return [ null, __( 'must be a JSON array of servers', 'ai-ready-wp' ) ]; } return [ $value, '' ]; } if ( ! is_scalar( $value ) && null !== $value ) { return [ null, __( 'must be a string', 'ai-ready-wp' ) ]; } $value = trim( (string) $value ); if ( 'industry' === $key ) { if ( ! in_array( $value, self::INDUSTRIES, true ) ) { return [ null, sprintf( /* translators: %s: comma-separated list of allowed values */ __( 'must be one of: %s', 'ai-ready-wp' ), implode( ', ', self::INDUSTRIES ) ) ]; } return [ $value, '' ]; } if ( 'schema_type' === $key ) { if ( ! in_array( $value, self::SCHEMA_TYPES, true ) ) { return [ null, sprintf( /* translators: %s: comma-separated list of allowed values */ __( 'must be one of: %s', 'ai-ready-wp' ), implode( ', ', self::SCHEMA_TYPES ) ) ]; } return [ $value, '' ]; } if ( 'company_email' === $key ) { if ( '' !== $value && ! is_email( $value ) ) { return [ null, __( 'must be an email address', 'ai-ready-wp' ) ]; } return [ sanitize_email( $value ), '' ]; } if ( in_array( $key, self::URL_FIELDS, true ) ) { if ( '' === $value ) { return [ '', '' ]; } $url = esc_url_raw( $value ); if ( '' === $url || ! filter_var( $url, FILTER_VALIDATE_URL ) ) { return [ null, __( 'must be a URL', 'ai-ready-wp' ) ]; } return [ $url, '' ]; } // The rewrite rule that serves the key file only matches this form, // so any other key would be accepted and never work. if ( 'indexnow_key' === $key && '' !== $value && ! preg_match( '/^[a-f0-9]{64}$/', $value ) ) { return [ null, __( 'must be 64 lowercase hex characters', 'ai-ready-wp' ) ]; } return [ sanitize_text_field( $value ), '' ]; } // ─── Helpers ──────────────────────────────────────────── /** * true/false, 1/0, "1"/"0", "true"/"false", "yes"/"no"; null for anything else. */ private static function to_bool( $value ): ?bool { if ( is_bool( $value ) ) { return $value; } if ( ! is_scalar( $value ) ) { return null; } return filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ); } /** * The request body as an array: JSON first, form fields as the fallback. */ private function body( \WP_REST_Request $request ): array { $json = $request->get_json_params(); if ( is_array( $json ) ) { return $json; } return (array) $request->get_body_params(); } /** * An instance of a plugin class without running its constructor. * * The constructors of AIREWP_Settings and its siblings do one thing: * register hooks. A second instance inside a request would register * every hook twice, and the update_option hook among them fires the * rebuild. What this class needs from those objects is a method that * happens not to be static, so the object is created without the wiring. */ private static function bare( string $class ): object { return ( new \ReflectionClass( $class ) )->newInstanceWithoutConstructor(); } private function respond( array $data, int $status = 200 ): \WP_REST_Response { return new \WP_REST_Response( [ 'api_version' => self::API_VERSION, 'plugin_version' => AIREWP_VERSION ] + $data, $status ); } private function error( string $code, string $message, int $status, array $data = [] ): \WP_Error { return new \WP_Error( $code, $message, array_merge( [ 'status' => $status ], $data ) ); } }