wpdb = $wpdb; } /** * Run all checks and return a structured report. * * @return array{ * score: int, * grade: string, * summary: array{pass: int, warn: int, fail: int, total: int}, * checks: array> * } */ public function run(): array { $checks = array( $this->check_woocommerce(), $this->check_permalinks(), $this->check_site_public(), $this->check_schema_enabled(), $this->check_site_schema_enabled(), $this->check_products_published(), $this->check_product_identifiers(), $this->check_default_brand(), $this->check_feeds_generated(), $this->check_llms_txt(), $this->check_crawler_activity(), $this->check_onboarding(), ); /** * Filters the store health checks. * * @param array> $checks The check results. * @param SiteHealth $health The health instance. */ $checks = (array) apply_filters( 'aicommerce_site_health_checks', $checks, $this ); $summary = array( 'pass' => 0, 'warn' => 0, 'fail' => 0, 'total' => 0, ); foreach ( $checks as $check ) { if ( ! isset( $check['status'] ) || self::STATUS_INFO === $check['status'] ) { continue; } ++$summary['total']; if ( isset( $summary[ $check['status'] ] ) ) { ++$summary[ $check['status'] ]; } } $score = $summary['total'] > 0 ? (int) round( ( ( $summary['pass'] + ( $summary['warn'] * 0.5 ) ) / $summary['total'] ) * 100 ) : 0; return array( 'score' => $score, 'grade' => $this->grade( $score ), 'summary' => $summary, 'checks' => array_values( $checks ), ); } /** * Build a single check result. * * @param string $id Check identifier. * @param string $label Human label. * @param string $status One of the STATUS_* constants. * @param string $message Guidance message. * @param array $extra Extra fields (e.g., action_url). * * @return array */ private function result( string $id, string $label, string $status, string $message, array $extra = array() ): array { return array_merge( array( 'id' => $id, 'label' => $label, 'status' => $status, 'message' => $message, ), $extra ); } /** * Convert a numeric score into a letter grade. * * @param int $score The 0-100 score. * * @return string */ private function grade( int $score ): string { if ( $score >= 90 ) { return 'A'; } if ( $score >= 75 ) { return 'B'; } if ( $score >= 60 ) { return 'C'; } if ( $score >= 40 ) { return 'D'; } return 'F'; } /** * Check that WooCommerce is active. * * @return array */ private function check_woocommerce(): array { if ( class_exists( 'WooCommerce' ) ) { $version = defined( 'WC_VERSION' ) ? WC_VERSION : ''; return $this->result( 'woocommerce', __( 'WooCommerce active', 'ai-discoverability-kit' ), self::STATUS_PASS, $version ? sprintf( /* translators: %s: WooCommerce version. */ __( 'WooCommerce %s is active.', 'ai-discoverability-kit' ), $version ) : __( 'WooCommerce is active.', 'ai-discoverability-kit' ) ); } return $this->result( 'woocommerce', __( 'WooCommerce active', 'ai-discoverability-kit' ), self::STATUS_FAIL, __( 'WooCommerce is required for product schema and feeds.', 'ai-discoverability-kit' ) ); } /** * Check pretty permalinks (required for /llms.txt and manifest endpoints). * * @return array */ private function check_permalinks(): array { $structure = (string) get_option( 'permalink_structure', '' ); if ( '' !== $structure ) { return $this->result( 'permalinks', __( 'Pretty permalinks', 'ai-discoverability-kit' ), self::STATUS_PASS, __( 'Pretty permalinks are enabled — discovery endpoints will work.', 'ai-discoverability-kit' ) ); } return $this->result( 'permalinks', __( 'Pretty permalinks', 'ai-discoverability-kit' ), self::STATUS_FAIL, __( 'Enable pretty permalinks so /llms.txt and the AI manifest are reachable.', 'ai-discoverability-kit' ), array( 'action_url' => admin_url( 'options-permalink.php' ) ) ); } /** * Check the site is publicly visible to crawlers. * * @return array */ private function check_site_public(): array { if ( '1' === (string) get_option( 'blog_public', '1' ) ) { return $this->result( 'site_public', __( 'Search engine visibility', 'ai-discoverability-kit' ), self::STATUS_PASS, __( 'Your site is visible to search engines and AI crawlers.', 'ai-discoverability-kit' ) ); } return $this->result( 'site_public', __( 'Search engine visibility', 'ai-discoverability-kit' ), self::STATUS_FAIL, __( 'Search engine visibility is discouraged in Settings → Reading; AI crawlers will be blocked.', 'ai-discoverability-kit' ), array( 'action_url' => admin_url( 'options-reading.php' ) ) ); } /** * Check product schema injection is enabled. * * @return array */ private function check_schema_enabled(): array { if ( get_option( 'aicommerce_schema_enabled', true ) ) { return $this->result( 'schema_enabled', __( 'Product schema output', 'ai-discoverability-kit' ), self::STATUS_PASS, __( 'JSON-LD product schema is being injected on product pages.', 'ai-discoverability-kit' ) ); } return $this->result( 'schema_enabled', __( 'Product schema output', 'ai-discoverability-kit' ), self::STATUS_WARN, __( 'Product schema output is turned off in Tyralix AI Discoverability settings.', 'ai-discoverability-kit' ) ); } /** * Check site-level schema is enabled. * * @return array */ private function check_site_schema_enabled(): array { if ( get_option( 'aicommerce_site_schema_enabled', true ) ) { return $this->result( 'site_schema_enabled', __( 'Store schema output', 'ai-discoverability-kit' ), self::STATUS_PASS, __( 'Organization, WebSite and OnlineStore schema are output on your home page.', 'ai-discoverability-kit' ) ); } return $this->result( 'site_schema_enabled', __( 'Store schema output', 'ai-discoverability-kit' ), self::STATUS_WARN, __( 'Store-level schema is turned off; AI models get less context about your brand.', 'ai-discoverability-kit' ) ); } /** * Check that at least one product is published. * * @return array */ private function check_products_published(): array { $counts = wp_count_posts( 'product' ); $count = $counts ? (int) $counts->publish : 0; if ( $count > 0 ) { return $this->result( 'products', __( 'Published products', 'ai-discoverability-kit' ), self::STATUS_PASS, sprintf( /* translators: %d: number of products. */ _n( '%d published product found.', '%d published products found.', $count, 'ai-discoverability-kit' ), $count ) ); } return $this->result( 'products', __( 'Published products', 'ai-discoverability-kit' ), self::STATUS_WARN, __( 'No published products yet — publish products to generate schema and feeds.', 'ai-discoverability-kit' ) ); } /** * Check how many products have GTIN/brand identifiers. * * @return array */ private function check_product_identifiers(): array { $counts = wp_count_posts( 'product' ); $total = $counts ? (int) $counts->publish : 0; if ( 0 === $total ) { return $this->result( 'identifiers', __( 'Product identifiers', 'ai-discoverability-kit' ), self::STATUS_INFO, __( 'Publish products to start tracking identifier coverage.', 'ai-discoverability-kit' ) ); } // Count published products that have a GTIN or brand meta value. // Core table names only; no user input in the query. // phpcs:disable WordPress.DB, PluginCheck.Security.DirectDB.UnescapedDBParameter $with_ids = (int) $this->wpdb->get_var( "SELECT COUNT(DISTINCT p.ID) FROM {$this->wpdb->posts} p INNER JOIN {$this->wpdb->postmeta} pm ON pm.post_id = p.ID WHERE p.post_type = 'product' AND p.post_status = 'publish' AND pm.meta_key IN ( '_aicommerce_gtin', '_aicommerce_brand' ) AND pm.meta_value <> ''" ); // phpcs:enable WordPress.DB, PluginCheck.Security.DirectDB.UnescapedDBParameter $pct = $total > 0 ? (int) round( ( $with_ids / $total ) * 100 ) : 0; if ( $pct >= 80 ) { $status = self::STATUS_PASS; } elseif ( $pct >= 30 ) { $status = self::STATUS_WARN; } else { $status = self::STATUS_FAIL; } return $this->result( 'identifiers', __( 'Product identifiers', 'ai-discoverability-kit' ), $status, sprintf( /* translators: 1: percentage, 2: count with identifiers, 3: total. */ __( '%1$d%% of products (%2$d of %3$d) have a brand or GTIN. Identifiers improve AI matching.', 'ai-discoverability-kit' ), $pct, $with_ids, $total ), array( 'coverage' => $pct ) ); } /** * Check a default brand is configured. * * @return array */ private function check_default_brand(): array { $brand = (string) get_option( 'aicommerce_default_brand', '' ); if ( '' !== trim( $brand ) ) { return $this->result( 'default_brand', __( 'Default brand', 'ai-discoverability-kit' ), self::STATUS_PASS, sprintf( /* translators: %s: brand name. */ __( 'Default brand set to "%s".', 'ai-discoverability-kit' ), $brand ) ); } return $this->result( 'default_brand', __( 'Default brand', 'ai-discoverability-kit' ), self::STATUS_WARN, __( 'Set a default brand so products without one still expose brand data.', 'ai-discoverability-kit' ) ); } /** * Check whether product feeds have been generated. * * @return array */ private function check_feeds_generated(): array { $upload_dir = wp_upload_dir(); $feed_dir = trailingslashit( $upload_dir['basedir'] ) . 'aicommerce/feeds/'; $found = array(); foreach ( array( 'google-feed.xml', 'openai-feed.json', 'bing-feed.xml', 'perplexity-feed.json' ) as $file ) { if ( file_exists( $feed_dir . $file ) ) { $found[] = $file; } } if ( ! empty( $found ) ) { return $this->result( 'feeds', __( 'Product feeds', 'ai-discoverability-kit' ), self::STATUS_PASS, sprintf( /* translators: %d: number of feeds. */ _n( '%d product feed generated.', '%d product feeds generated.', count( $found ), 'ai-discoverability-kit' ), count( $found ) ) ); } return $this->result( 'feeds', __( 'Product feeds', 'ai-discoverability-kit' ), self::STATUS_WARN, __( 'No product feeds generated yet — generate feeds so AI shopping agents can index your catalog.', 'ai-discoverability-kit' ) ); } /** * Informational check: is /llms.txt configured to serve? * * @return array */ private function check_llms_txt(): array { $structure = (string) get_option( 'permalink_structure', '' ); if ( '' === $structure ) { return $this->result( 'llms_txt', __( 'llms.txt endpoint', 'ai-discoverability-kit' ), self::STATUS_WARN, __( 'Enable pretty permalinks so /llms.txt is served.', 'ai-discoverability-kit' ) ); } return $this->result( 'llms_txt', __( 'llms.txt endpoint', 'ai-discoverability-kit' ), self::STATUS_PASS, sprintf( /* translators: %s: llms.txt URL. */ __( 'Your AI guide is available at %s.', 'ai-discoverability-kit' ), home_url( '/llms.txt' ) ), array( 'url' => home_url( '/llms.txt' ) ) ); } /** * Informational check: recent AI crawler activity. * * @return array */ private function check_crawler_activity(): array { $table = $this->wpdb->prefix . 'aicommerce_crawler_log'; // Table name is built from $wpdb->prefix; the date filter is prepared. // phpcs:disable WordPress.DB, PluginCheck.Security.DirectDB.UnescapedDBParameter $exists = $this->wpdb->get_var( $this->wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); if ( $table !== $exists ) { return $this->result( 'crawler_activity', __( 'AI crawler activity', 'ai-discoverability-kit' ), self::STATUS_INFO, __( 'Crawler monitoring will begin once AI bots visit your store.', 'ai-discoverability-kit' ) ); } $since = gmdate( 'Y-m-d H:i:s', time() - ( 30 * DAY_IN_SECONDS ) ); $visits = (int) $this->wpdb->get_var( $this->wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE request_time >= %s", $since ) ); // phpcs:enable WordPress.DB, PluginCheck.Security.DirectDB.UnescapedDBParameter return $this->result( 'crawler_activity', __( 'AI crawler activity', 'ai-discoverability-kit' ), self::STATUS_INFO, sprintf( /* translators: %d: number of crawler visits. */ _n( '%d AI crawler visit in the last 30 days.', '%d AI crawler visits in the last 30 days.', $visits, 'ai-discoverability-kit' ), $visits ), array( 'visits_30d' => $visits ) ); } /** * Check onboarding completion. * * @return array */ private function check_onboarding(): array { if ( get_option( 'aicommerce_onboarding_complete', false ) ) { return $this->result( 'onboarding', __( 'Setup wizard', 'ai-discoverability-kit' ), self::STATUS_PASS, __( 'You have completed the setup wizard.', 'ai-discoverability-kit' ) ); } return $this->result( 'onboarding', __( 'Setup wizard', 'ai-discoverability-kit' ), self::STATUS_WARN, __( 'Finish the setup wizard to configure schema, feeds and crawler rules.', 'ai-discoverability-kit' ) ); } }