> */ private static array $authors = []; /** * Drops what has been memoized for the current request. * * @return void */ public static function reset(): void { self::$authors = []; } /** * Fetches one of the site's own URLs, through the audit's cache. * * For the few checks that inspect a response in a way no shared helper can * express: parsing a JSON-LD graph out of a page, reading a header back. * Ask `unreadable()` first; this returns the body it already fetched. * * @param string $url Absolute URL. * @return array Result, as described by `Audit\Loopback::fetch()`. */ public static function fetch( string $url ): array { return Loopback::fetch( $url ); } /** * Why a URL cannot be inspected, as a finding. * * The three ways looking at a URL can end before anything has been looked * at — the host refuses loopback requests, the file has no address, the * request came back as anything but a 200 — answered once for every check * that reads a response. * * @param string $id Stable identifier. * @param string $module Module slug. * @param string $label Translated name of what was checked. * @param string $url Absolute URL to fetch. * @param int $weight Weight, one of the `Check::WEIGHT_*` constants. * @param string|null $fix Id of the one-click fix, or null. * @return Check|null The finding, or null when the response can be inspected. */ public static function unreadable( string $id, string $module, string $label, string $url, int $weight = Check::WEIGHT_IMPORTANT, ?string $fix = null ): ?Check { if ( ! Loopback::is_available() ) { return Check::skip( $id, $module, $label, __( 'Loopback requests are blocked on this host', 'acid-aeo' ), $weight ); } if ( '' === trim( $url ) ) { return Check::fail( $id, $module, $label, __( 'The file has no address yet, so nothing is published. Save the permalinks and run the audit again.', 'acid-aeo' ), $weight, null !== $fix ? $fix : 'flush_rewrite' ); } $result = Loopback::fetch( $url ); if ( '' !== (string) $result['error'] ) { return Check::fail( $id, $module, $label, sprintf( /* translators: 1: URL, 2: error message. */ __( '%1$s could not be fetched: %2$s', 'acid-aeo' ), $url, (string) $result['error'] ), $weight, $fix ); } if ( 200 !== (int) $result['status'] ) { return Check::fail( $id, $module, $label, sprintf( /* translators: 1: URL, 2: HTTP status code. */ __( '%1$s answered with HTTP %2$d instead of 200.', 'acid-aeo' ), $url, (int) $result['status'] ), $weight, $fix ); } return null; } /** * Checks that a URL answers with 200, and optionally with the right type. * * @param string $id Stable identifier. * @param string $module Module slug. * @param string $label Translated name of what was checked. * @param string $url Absolute URL to fetch. * @param array $args Optional: `type` (expected content type, matched as a * substring), `weight`, `fix`, `test` (callable taking * the body and returning whether it is valid) and * `test_message` (shown when `test` says no). * @return Check The finding. */ public static function reachable( string $id, string $module, string $label, string $url, array $args = [] ): Check { $weight = (int) ( $args['weight'] ?? Check::WEIGHT_IMPORTANT ); $fix = isset( $args['fix'] ) ? (string) $args['fix'] : null; $problem = self::unreadable( $id, $module, $label, $url, $weight, $fix ); if ( null !== $problem ) { return $problem; } $result = Loopback::fetch( $url ); $type = (string) ( $args['type'] ?? '' ); if ( '' !== $type && false === strpos( strtolower( (string) $result['content_type'] ), strtolower( $type ) ) ) { return Check::fail( $id, $module, $label, sprintf( /* translators: 1: URL, 2: expected content type, 3: content type that was sent. */ __( '%1$s is served as "%3$s" instead of "%2$s", so clients that pick a format by type will ignore it.', 'acid-aeo' ), $url, $type, '' !== (string) $result['content_type'] ? (string) $result['content_type'] : __( 'nothing', 'acid-aeo' ) ), $weight, $fix ); } $test = $args['test'] ?? null; if ( is_callable( $test ) && ! (bool) call_user_func( $test, (string) $result['body'] ) ) { return Check::fail( $id, $module, $label, (string) ( $args['test_message'] ?? sprintf( /* translators: %s: URL. */ __( '%s is served, but its contents could not be read.', 'acid-aeo' ), $url ) ), $weight, $fix ); } return Check::pass( $id, $module, $label, sprintf( /* translators: %s: URL. */ __( '%s is served correctly.', 'acid-aeo' ), $url ), $weight ); } /** * Checks something about the body of a URL the site serves. * * @param string $id Stable identifier. * @param string $module Module slug. * @param string $label Translated name of what was checked. * @param string $url Absolute URL to fetch. * @param callable $test Takes the response body, returns whether it is as expected. * @param array $args Optional: `ok` and `bad` (translated messages for the two * outcomes), `status` (`fail` or `warn` when `test` says no), * `weight` and `fix`. * @return Check The finding. */ public static function observed( string $id, string $module, string $label, string $url, callable $test, array $args = [] ): Check { $weight = (int) ( $args['weight'] ?? Check::WEIGHT_IMPORTANT ); $fix = isset( $args['fix'] ) ? (string) $args['fix'] : null; $problem = self::unreadable( $id, $module, $label, $url, $weight, $fix ); if ( null !== $problem ) { return $problem; } $result = Loopback::fetch( $url ); if ( (bool) call_user_func( $test, (string) $result['body'] ) ) { return Check::pass( $id, $module, $label, (string) ( $args['ok'] ?? '' ), $weight ); } $bad = (string) ( $args['bad'] ?? '' ); return Check::WARN === (string) ( $args['status'] ?? Check::FAIL ) ? Check::warn( $id, $module, $label, $bad, $weight, $fix ) : Check::fail( $id, $module, $label, $bad, $weight, $fix ); } /** * Whether a file on disk shadows the one WordPress would serve. * * The web server answers with a physical file before WordPress sees the * request, so an `ai.txt` written by something else makes the generated * one unreachable. A file this plugin wrote (`StaticFiles::is_written()`) * is fine: that is what the static files module does, and it signs what * it writes. The signature is looked for at both ends of the file, since * llms.txt signs its last line. * * @param string $id Check id, e.g. `ai_txt_physical_conflict`. * @param string $module Module slug. * @param string $relative Path under the webroot, e.g. `.well-known/security.txt`. * @param string $file_label The file name as shown to people, e.g. `ai.txt`. * @param array|null $files Relative path => exists on disk; defaults to * `Environment::detect()['physical_files']`. * @return Check The finding. */ public static function physical_conflict( string $id, string $module, string $relative, string $file_label, ?array $files = null ): Check { $files = $files ?? ( Environment::detect()['physical_files'] ?? [] ); $label = sprintf( /* translators: %s: file name. */ __( 'No conflicting %s on disk', 'acid-aeo' ), $file_label ); if ( empty( $files[ $relative ] ) ) { return Check::pass( $id, $module, $label, sprintf( /* translators: %s: file name. */ __( 'Nothing on disk shadows the %s WordPress serves.', 'acid-aeo' ), $file_label ), Check::WEIGHT_IMPORTANT ); } if ( StaticFiles::is_written( Filesystem::webroot() . $relative ) ) { return Check::pass( $id, $module, $label, sprintf( /* translators: %s: file name. */ __( 'The %s on disk is the one this plugin wrote.', 'acid-aeo' ), $file_label ), Check::WEIGHT_IMPORTANT ); } return Check::warn( $id, $module, $label, sprintf( /* translators: %s: file name. */ __( 'The %s in the site root was written by something else. The web server answers with that file, so the one this plugin generates is never seen.', 'acid-aeo' ), $file_label ), Check::WEIGHT_IMPORTANT ); } /** * The distinct authors of the most recent published posts. * * Two checks ask the same question — has anybody here filled in an author * profile — so the query lives once, next to them, and runs once per audit. * * @param int $limit How many recent posts to read authors from. * @return array User IDs, most recently published first. */ public static function recent_authors( int $limit = 10 ): array { if ( isset( self::$authors[ $limit ] ) ) { return self::$authors[ $limit ]; } // The post types this site publishes, not the one most sites publish: a // site whose content lives entirely in a custom post type has authors // too, and asking only for `post` there reports every profile empty. $post_types = Options::get( 'llms.post_types' ); $post_types = is_array( $post_types ) && [] !== $post_types ? array_values( array_filter( array_map( 'strval', $post_types ) ) ) : [ 'post' ]; $posts = get_posts( [ 'post_type' => $post_types, 'post_status' => 'publish', 'numberposts' => max( 1, $limit ), 'orderby' => 'date', 'order' => 'DESC', 'suppress_filters' => false, ] ); $authors = []; foreach ( is_array( $posts ) ? $posts : [] as $post ) { $author_id = is_object( $post ) && isset( $post->post_author ) ? (int) $post->post_author : 0; if ( $author_id > 0 ) { $authors[ $author_id ] = $author_id; } } self::$authors[ $limit ] = array_values( $authors ); return self::$authors[ $limit ]; } }