*/ public static function paths(): array { return [ '.well-known/agents.json' => 'enable_agents_json', '.well-known/agent-card.json' => 'enable_agent_card', '.well-known/agent.json' => 'enable_agent_card', '.well-known/mcp.json' => 'enable_mcp_json', // Served from a cache when there is no file (since 4.4.4), so the // URL is the fact that matters here as well. 'llms.txt' => 'enable_llms_txt', 'llms-full.txt' => 'enable_llms_txt', // Measured for the same reason as the rest, and it is the one // document this plugin does not write: it is filtered into // WordPress's own output. When something else answers /robots.txt // the filter never runs, and nothing in the settings can tell. 'robots.txt' => 'enable_robots_ai', ]; } /** * Last known state of one endpoint. * * @param string $path Path below the site root. * @param array $settings Plugin settings. * @param bool $may_measure Allow a fresh measurement when nothing is cached. */ public static function state( string $path, array $settings, bool $may_measure = false ): string { $status = self::get_status( $settings, $may_measure ); return $status['paths'][ $path ]['state'] ?? self::UNKNOWN; } /** * Whether an endpoint should count as working for the readiness score. * * UNKNOWN counts as working on purpose. It means the loopback request could * not be made at all, which plenty of hosts block, and it says nothing about * the endpoint. Marking a site down on the strength of a check that never * ran would be the same mistake as trusting the file on disk, in reverse. */ public static function is_working( string $path, array $settings, bool $may_measure = false ): bool { return self::BROKEN !== self::state( $path, $settings, $may_measure ); } /** * Read the cached measurement, optionally taking a fresh one. * * @param bool $may_measure Only admin and cron contexts pass true. The public * REST route must not be able to trigger four * outgoing requests per call. * * @return array{checked_at: int, paths: array} */ public static function get_status( array $settings, bool $may_measure = false ): array { $cached = get_transient( self::TRANSIENT ); if ( is_array( $cached ) && isset( $cached['paths'] ) ) { return $cached; } if ( ! $may_measure ) { return [ 'checked_at' => 0, 'paths' => [] ]; } return self::run( $settings ); } /** * Measure every enabled endpoint and cache the result. * * @return array{checked_at: int, paths: array} */ public static function run( array $settings ): array { $empty = [ 'checked_at' => 0, 'paths' => [] ]; // Not a lock. It stops a burst of admin requests on a cold cache from // each starting their own round of loopback calls; one of them wins and // the rest read the result a moment later. if ( get_transient( self::RUNNING ) ) { return $empty; } set_transient( self::RUNNING, 1, MINUTE_IN_SECONDS ); $result = [ 'checked_at' => time(), 'paths' => [] ]; foreach ( array_keys( self::paths() ) as $path ) { if ( ! self::expected( $path, $settings ) ) { continue; } $measured = self::request( $path ); $result['paths'][ $path ] = $measured; // The first endpoint that cannot be reached at all ends the round. // A host that blocks loopback requests blocks all of them, and // waiting out the timeout three more times would cost the admin // twenty seconds to learn nothing. if ( self::UNKNOWN === $measured['state'] ) { break; } } set_transient( self::TRANSIENT, $result, self::TTL ); delete_transient( self::RUNNING ); return $result; } /** * Whether this endpoint is supposed to answer at all. * * The feature toggle is not the whole story for mcp.json: switched on * with no servers listed, the URL answers 404 on purpose (see * AIREWP_MCP_JSON::serve()), and measuring it would report a broken web * server for a document that was never meant to exist. */ public static function expected( string $path, array $settings ): bool { if ( '.well-known/mcp.json' === $path ) { return AIREWP_MCP_JSON::is_active( $settings ); } $key = self::paths()[ $path ] ?? ''; return '' !== $key && ! empty( $settings[ $key ] ); } /** * Throw away the cached measurement so the next admin view takes a new one. */ public static function invalidate(): void { delete_transient( self::TRANSIENT ); } /** * Ask one URL and classify the answer. * * @return array{state: string, code: int} */ private static function request( string $path ): array { $response = wp_remote_get( home_url( '/' . ltrim( $path, '/' ) ), [ 'timeout' => self::TIMEOUT, // The site may well redirect www to apex or http to https, and // following that is the point: the answer a crawler gets is the // one at the end of the chain. 'redirection' => 3, // A site talking to itself routinely presents a certificate its // own PHP does not trust, on staging domains and behind // terminating proxies especially. WordPress core makes its own // loopback checks the same way. 'sslverify' => false, 'headers' => [ 'Accept' => str_ends_with( $path, '.json' ) ? 'application/json' : 'text/plain' ], ] ); if ( is_wp_error( $response ) ) { return [ 'state' => self::UNKNOWN, 'code' => 0 ]; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( 200 !== $code ) { return [ 'state' => self::BROKEN, 'code' => $code ]; } // 200 is not enough. A server that hands /.well-known/ to a static // handler answers 200 with an HTML page on some setups, and a caching // layer can serve anything at all. The document has to look like one. if ( ! self::looks_like_the_document( $path, (string) wp_remote_retrieve_body( $response ) ) ) { return [ 'state' => self::BROKEN, 'code' => $code ]; } return [ 'state' => self::OK, 'code' => $code ]; } /** * Whether a 200 body is the document and not, say, the theme's 404 page. * * The JSON documents have to parse. llms.txt and llms-full.txt open with * a Markdown H1, that is the one thing llmstxt.org requires of them. */ private static function looks_like_the_document( string $path, string $body ): bool { $body = trim( $body ); if ( '' === $body ) { return false; } if ( str_ends_with( $path, '.json' ) ) { return null !== json_decode( $body ); } // robots.txt is the odd one out. Any robots.txt is a valid robots.txt, // so "does it parse" answers nothing; the question is whether OUR // block is in the one being served. A site whose robots.txt comes from // somewhere else answers 200 with a perfectly good file that has none // of the crawler groups, none of the content signals and none of the // discovery pointers this plugin exists to add. if ( 'robots.txt' === $path ) { return str_contains( $body, AIREWP_Robots_Txt::BLOCK_MARKER ); } return str_starts_with( $body, '# ' ); } }