file_manager = $file_manager; $this->wpdb = $wpdb; } /** * Register the rewrite rule for /.well-known/ai-commerce.json. * * @return void */ public function register_rewrite_rule(): void { add_rewrite_rule( '^\.well-known/ai-commerce\.json$', 'index.php?aicommerce_manifest=1', 'top' ); } /** * Add the query var for the manifest endpoint. * * @param array $vars Existing query vars. * * @return array Modified query vars. */ public function add_query_var( array $vars ): array { $vars[] = 'aicommerce_manifest'; return $vars; } /** * Serve the manifest if the query var is set. * * @return void */ public function maybe_serve(): void { if ( ! get_query_var( 'aicommerce_manifest' ) ) { return; } // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged @header( 'Content-Type: application/json; charset=utf-8' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged @header( 'Cache-Control: public, max-age=3600' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged @header( 'Access-Control-Allow-Origin: *' ); $data = $this->build_manifest(); /** * Filters the ai-commerce.json manifest data. * * @param array $data The manifest data. */ $data = apply_filters( 'aicommerce/manifest/data', $data ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped echo wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT ); exit; } /** * Build the manifest data structure. * * @return array The manifest data. */ public function build_manifest(): array { $feeds_data = $this->get_feed_status(); // Only expose a contact email if the admin has explicitly opted in to a // public one. The WordPress admin email is private and never published. $public_email = sanitize_email( (string) get_option( 'aicommerce_llms_contact_email', '' ) ); /** This filter is documented in src/Crawler/LlmsTxtGenerator.php */ $public_email = (string) apply_filters( 'aicommerce_llms_contact_email', $public_email ); $contact_email = ( '' !== $public_email && is_email( $public_email ) ) ? $public_email : null; // WooCommerce is optional: guard all WC-only calls so the public manifest // endpoint never fatals on a site without WooCommerce. $currency = function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : null; $total_products = 0; if ( post_type_exists( 'product' ) ) { $counts = wp_count_posts( 'product' ); $total_products = isset( $counts->publish ) ? (int) $counts->publish : 0; } return array( 'version' => '1.0', 'merchant' => array( 'name' => get_bloginfo( 'name' ), 'url' => home_url(), 'logo' => get_site_icon_url( 512 ) ?: null, 'contact_email' => $contact_email, 'currency' => $currency, ), 'catalog' => array( 'total_products' => $total_products, 'feeds' => $this->build_feeds_section( $feeds_data ), 'schema_markup' => true, 'ai_readiness_score' => $this->get_average_readiness_score(), ), 'policies' => array( 'return_policy_url' => get_option( 'aicommerce_return_policy_url' ) ?: null, 'privacy_policy_url' => get_privacy_policy_url() ?: null, 'terms_url' => get_option( 'aicommerce_terms_url' ) ?: null, ), 'crawling' => array( 'allowed_bots' => array( 'GPTBot', 'Google-Extended', 'PerplexityBot', 'ClaudeBot' ), 'sitemap_url' => $this->get_sitemap_url(), ), 'mcp_endpoint' => rest_url( 'aicommerce/v1/mcp' ), 'mcp_discovery' => home_url( '/.well-known/mcp.json' ), 'generated_at' => current_time( 'c' ), ); } /** * Build the feeds section of the manifest. * * @param array $feeds_data Feed status data from the database. * * @return array Feeds section. */ private function build_feeds_section( array $feeds_data ): array { $feed_configs = array( 'google' => array( 'format' => 'xml/rss2+g', 'extension' => 'xml', ), 'openai' => array( 'format' => 'json', 'extension' => 'json', ), 'bing' => array( 'format' => 'xml/rss2+g', 'extension' => 'xml', ), 'perplexity' => array( 'format' => 'json', 'extension' => 'json', ), ); $feeds = array(); foreach ( $feed_configs as $type => $config ) { $status = $feeds_data[ $type ] ?? null; $has_file = $status && 'complete' === ( $status['status'] ?? '' ); $filename = $type . '-feed.' . $config['extension']; $feeds[ $type ] = array( 'url' => $has_file ? $this->file_manager->get_feed_url( $filename ) : null, 'format' => $config['format'], 'updated_at' => $status['generated_at'] ?? null, ); } return $feeds; } /** * Get feed status from the database. * * @return array Feed status indexed by feed_type. */ private function get_feed_status(): array { $feed_table = $this->wpdb->prefix . 'aicommerce_feeds'; // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $rows = $this->wpdb->get_results( "SELECT feed_type, status, file_url, generated_at FROM {$feed_table}", ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $status = array(); foreach ( ( $rows ?: array() ) as $row ) { $status[ $row['feed_type'] ] = $row; } return $status; } /** * Get average AI readiness score across products. * * Returns cached average from the readiness_cache table, or null. * * @return int|null Average score or null if not available. */ private function get_average_readiness_score(): ?int { $cache_table = $this->wpdb->prefix . 'aicommerce_readiness_cache'; // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $avg = $this->wpdb->get_var( "SELECT AVG(score) FROM {$cache_table}" ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter return null !== $avg ? (int) round( (float) $avg ) : null; } /** * Get the sitemap URL. * * @return string Sitemap URL. */ private function get_sitemap_url(): string { if ( function_exists( 'get_sitemap_url' ) ) { $url = get_sitemap_url( 'index' ); if ( $url ) { return $url; } } return home_url( '/wp-sitemap.xml' ); } }