*/ private const STATUS_ORDER = [ Check::FAIL => 0, Check::WARN => 1, Check::PASS => 2, Check::SKIP => 3, ]; /** * Modules to audit instead of the booted ones, or null for the real thing. * * @var array|null */ private static ?array $modules = null; /** * Whether the site level checks are collected. * * @var bool */ private static bool $core = true; /** * Sets the modules the audit asks, replacing the booted ones. * * The seam a test needs: `run()` otherwise reaches through `Plugin` into * every module the plugin ships, and a test of how the runner treats one * disabled module should not have to stand up thirteen real ones. * * @param array|null $modules `Module` instances or class names, or null to * go back to `Plugin::MODULES`. * @return void */ public static function use_modules( ?array $modules ): void { self::$modules = $modules; } /** * Whether the site level checks are part of the audit. * * @param bool $enabled True to collect `core_checks()`, false to leave them out. * @return void */ public static function use_core_checks( bool $enabled ): void { self::$core = $enabled; } /** * Runs the audit. * * @param bool $fresh Whether to ignore the cached result. * @return array Keys `score`, `earned`, `possible`, `counts`, `checks`, * `computed_at` and `band`. */ public static function run( bool $fresh = false ): array { if ( ! $fresh ) { $cached = self::cached(); if ( null !== $cached ) { return $cached; } } else { // A fresh audit has to look at the site as it is now, and the // reachability checks read through a cache of their own. Loopback::flush(); Checks::reset(); // Which sitemap address actually answers is a question with no // offline answer, and the generators must never stop to ask it // mid-render. Here is the one moment it can be asked honestly: // after the flush, so the probe reads the site rather than a // ten minute old memory of it, and before the checks, which are // about to report on whatever address this settles on. The // responses it collects stay in the loopback cache, so the checks // reuse them instead of fetching the same URLs again. Environment::resolve_sitemap(); } $checks = self::module_checks(); if ( self::$core ) { $checks = array_merge( $checks, self::guarded( 'core', [ self::class, 'core_checks' ] ) ); } $score = self::score( $checks ); $result = [ 'score' => $score['score'], 'earned' => $score['earned'], 'possible' => $score['possible'], 'counts' => $score['counts'], 'checks' => array_map( static fn( Check $check ): array => $check->to_array(), self::sort( $checks ) ), 'computed_at' => gmdate( 'c' ), 'band' => self::band( $score['score'] ), ]; Cache::set( self::CACHE_KEY, $result, self::TTL ); update_option( self::LAST_OPTION, [ 'score' => $result['score'], 'band' => $result['band'], 'counts' => $result['counts'], 'computed_at' => $result['computed_at'], ], false ); return $result; } /** * The last audit, while it is still cached. * * Read only: it never runs anything. The checks fetch the site over HTTP, * which is a fine thing to do on a screen somebody opened to measure the * site and a bad thing to do from a widget that draws itself on every * visit to wp-admin's front page. A caller that only wants a number it can * show asks for this and offers to run the audit when the answer is null. * * @return array|null The cached result, or null when there is none. */ public static function cached(): ?array { $cached = Cache::get( self::CACHE_KEY ); return is_array( $cached ) ? $cached : null; } /** * The last audit's summary, however old, or null before the first audit. * * @return array|null Keys `score`, `band`, `counts`, `computed_at`. */ public static function last(): ?array { $last = get_option( self::LAST_OPTION, false ); return is_array( $last ) && isset( $last['score'] ) ? $last : null; } /** * Adds up a list of checks. * * Pure: the same list always scores the same, whatever the site looks like. * * @param array $checks Checks to score. * @return array Keys `score` (0–100), `earned`, `possible` and `counts`. */ public static function score( array $checks ): array { $counts = [ Check::PASS => 0, Check::WARN => 0, Check::FAIL => 0, Check::SKIP => 0, ]; $earned = 0.0; $possible = 0; foreach ( $checks as $check ) { if ( ! $check instanceof Check ) { continue; } $status = $check->status(); $weight = $check->weight(); ++$counts[ $status ]; if ( Check::SKIP === $status ) { continue; } $possible += $weight; if ( Check::PASS === $status ) { $earned += $weight; } elseif ( Check::WARN === $status ) { $earned += $weight / 2; } } return [ 'score' => $possible > 0 ? (int) round( $earned / $possible * 100 ) : 100, 'earned' => $earned, 'possible' => $possible, 'counts' => $counts, ]; } /** * The word that goes with a score. * * @param int $score Score, 0–100. * @return string `excellent`, `good`, `fair` or `poor`. */ public static function band( int $score ): string { if ( $score >= self::BAND_EXCELLENT ) { return 'excellent'; } if ( $score >= self::BAND_GOOD ) { return 'good'; } return $score >= self::BAND_FAIR ? 'fair' : 'poor'; } /** * Orders checks the way they are read: worst and heaviest first. * * Two checks of the same status and weight are ordered by id, so the list * does not shuffle between two runs of the same audit. * * @param array $checks Checks to order. * @return array Ordered checks. The list passed in is left alone. */ public static function sort( array $checks ): array { usort( $checks, static function ( Check $a, Check $b ): int { $order = ( self::STATUS_ORDER[ $a->status() ] ?? 4 ) <=> ( self::STATUS_ORDER[ $b->status() ] ?? 4 ); if ( 0 !== $order ) { return $order; } $weight = $b->weight() <=> $a->weight(); return 0 !== $weight ? $weight : strcmp( $a->id(), $b->id() ); } ); return $checks; } /** * The checks that belong to the site rather than to one module. * * @return array Checks. */ public static function core_checks(): array { $environment = Environment::detect(); return [ self::permalinks( $environment ), self::https( $environment ), self::blog_public( $environment ), self::feed_full(), self::logo(), self::seo_defer(), self::loopback(), self::author_profiles(), ]; } /** * The rows a CLI table is printed from. * * @param array $result Result of `run()`. * @return array> Rows of `id`, `status`, `label` and `message`. */ public static function summary_for_cli( array $result ): array { $rows = []; foreach ( is_array( $result['checks'] ?? null ) ? $result['checks'] : [] as $check ) { if ( ! is_array( $check ) ) { continue; } $rows[] = [ 'id' => (string) ( $check['id'] ?? '' ), 'status' => (string) ( $check['status'] ?? '' ), 'label' => (string) ( $check['label'] ?? '' ), 'message' => (string) ( $check['message'] ?? '' ), ]; } return $rows; } /** * Every module's contribution, in boot order. * * A module that is switched off does not have its findings collected: it * would report its own file as missing, which is not a fault but a * decision. It contributes one entry saying so instead, and * `disabled_check()` decides what that entry says. * * @return array Checks. */ private static function module_checks(): array { $checks = []; foreach ( self::modules() as $module ) { $slug = $module->slug(); if ( ! $module->is_enabled() ) { $checks[] = self::disabled_check( $module, $slug ); continue; } $checks = array_merge( $checks, self::guarded( $slug, [ $module, 'checks' ] ) ); } return $checks; } /** * What the audit says about a module that is switched off. * * Being off is usually an accident, so the default is a warning carrying * the switch that undoes it. It is not always an accident, though: a * module that is off by design costs the site nothing, and warning about * it would lower a score that has nothing wrong with it. * * So a switched off module is given one chance to speak for itself. It is * asked for its checks, and if it returns a skip under the very id this * method would otherwise emit — `{slug}_disabled` — that is taken as the * module saying "this is how I am meant to be", and a skip is left out of * the score entirely. Anything else it returns is ignored: those checks * are about the files it is not publishing, which is the decision, not a * fault. * * @param Module $module The switched off module. * @param string $slug Module slug. * @return Check The finding. */ private static function disabled_check( Module $module, string $slug ): Check { $id = $slug . '_disabled'; foreach ( self::guarded( $slug, [ $module, 'checks' ] ) as $check ) { if ( $id === $check->id() && Check::SKIP === $check->status() ) { return $check; } } return Check::warn( $id, $slug, sprintf( /* translators: %s: module name. */ __( 'Module "%s" is disabled', 'acid-aeo' ), $module->title() ), __( 'Enable it to publish its files and pass its checks.', 'acid-aeo' ), Check::WEIGHT_IMPORTANT, 'enable_' . $slug ); } /** * The modules to audit, in boot order. * * Prefers the booted instance of each class: those are the ones that * registered their file with `Endpoints\Router`, so they are the ones that * know the URL to check. * * @return array Modules. */ private static function modules(): array { $sources = null !== self::$modules ? self::$modules : Plugin::MODULES; $booted = []; $modules = []; if ( null === self::$modules ) { foreach ( Plugin::instance()->modules() as $module ) { $booted[ get_class( $module ) ] = $module; } } foreach ( $sources as $source ) { if ( $source instanceof Module ) { $modules[] = $source; continue; } $class_name = (string) $source; if ( ! class_exists( $class_name ) ) { continue; } $module = $booted[ $class_name ] ?? new $class_name(); if ( $module instanceof Module ) { $modules[] = $module; } } return $modules; } /** * Runs one contributor, turning anything it throws into a finding. * * A module that breaks must not take the audit down with it: the audit is * the screen an administrator opens precisely when something is wrong. * * @param string $slug Module slug, or `core`. * @param callable $contributor Returns a list of checks. * @return array Checks. */ private static function guarded( string $slug, callable $contributor ): array { try { $checks = call_user_func( $contributor ); } catch ( Throwable $error ) { return [ Check::fail( $slug . '_error', $slug, __( 'Audit error', 'acid-aeo' ), $error->getMessage(), Check::WEIGHT_NICE ), ]; } $valid = []; foreach ( is_array( $checks ) ? $checks : [] as $check ) { if ( $check instanceof Check ) { $valid[] = $check; } } return $valid; } /** * Pretty permalinks, without which no generated file has an address. * * @param array $environment Detection result. * @return Check The finding. */ private static function permalinks( array $environment ): Check { $label = __( 'Pretty permalinks', 'acid-aeo' ); if ( ! empty( $environment['permalinks'] ) ) { return Check::pass( 'core_permalinks', 'core', $label, __( 'Permalinks are on, so every file this plugin publishes has a clean address.', 'acid-aeo' ), Check::WEIGHT_CRITICAL ); } return Check::fail( 'core_permalinks', 'core', $label, __( 'The site runs on plain permalinks, so /llms.txt, /ai.txt and the other files cannot be served under their own paths.', 'acid-aeo' ), Check::WEIGHT_CRITICAL, 'set_permalinks' ); } /** * HTTPS, which most crawlers now require before they read anything. * * @param array $environment Detection result. * @return Check The finding. */ private static function https( array $environment ): Check { $label = __( 'HTTPS', 'acid-aeo' ); if ( ! empty( $environment['https'] ) ) { return Check::pass( 'core_https', 'core', $label, __( 'The site is served over HTTPS.', 'acid-aeo' ), Check::WEIGHT_IMPORTANT ); } return Check::warn( 'core_https', 'core', $label, __( 'The site is not served over HTTPS. Answer engines rank and cite plain HTTP pages lower, and some refuse them outright.', 'acid-aeo' ), Check::WEIGHT_IMPORTANT ); } /** * Whether the site asks search engines to stay away. * * @param array $environment Detection result. * @return Check The finding. */ private static function blog_public( array $environment ): Check { $label = __( 'Search engine visibility', 'acid-aeo' ); if ( ! empty( $environment['blog_public'] ) ) { return Check::pass( 'core_blog_public', 'core', $label, __( 'The site allows search engines and answer engines to read it.', 'acid-aeo' ), Check::WEIGHT_CRITICAL ); } return Check::fail( 'core_blog_public', 'core', $label, __( 'Reading > Search engine visibility is switched on, so WordPress asks every crawler to stay away and robots.txt disallows everything.', 'acid-aeo' ), Check::WEIGHT_CRITICAL, 'set_blog_public' ); } /** * Whether the RSS feed carries whole posts or teasers. * * @return Check The finding. */ private static function feed_full(): Check { $label = __( 'Full text RSS feed', 'acid-aeo' ); if ( ! (bool) get_option( 'rss_use_excerpt', 0 ) ) { return Check::pass( 'core_feed_full', 'core', $label, __( 'The feed carries the full text of each post.', 'acid-aeo' ), Check::WEIGHT_IMPORTANT ); } return Check::warn( 'core_feed_full', 'core', $label, __( 'The feed carries excerpts only. An assistant that reads the feed sees the first paragraph of every article and nothing else.', 'acid-aeo' ), Check::WEIGHT_IMPORTANT, 'enable_full_feed' ); } /** * Whether the site has an image to be identified by. * * @return Check The finding. */ private static function logo(): Check { $label = __( 'Site logo', 'acid-aeo' ); $identity = SiteData::identity(); if ( '' !== (string) ( $identity['logo_url'] ?? '' ) ) { return Check::pass( 'core_logo', 'core', $label, __( 'The site has a logo, which the organization schema and the AI context file both point at.', 'acid-aeo' ), Check::WEIGHT_IMPORTANT ); } return Check::warn( 'core_logo', 'core', $label, __( 'No logo and no site icon are set, so the organization schema has no image to identify the site by.', 'acid-aeo' ), Check::WEIGHT_IMPORTANT ); } /** * Whether the page level schema is left to the SEO plugin. * * Informational: deferring is the right answer when an SEO plugin is * active, and printing a second organization node would be the wrong one. * * @return Check The finding. */ private static function seo_defer(): Check { $label = __( 'Schema ownership', 'acid-aeo' ); $seo = Environment::seo_plugin(); if ( null !== $seo && Schema::defers_to_seo() ) { return Check::skip( 'core_seo_defer', 'core', $label, sprintf( /* translators: %s: SEO plugin name. */ __( '%s is active and owns the page level schema. Acid AEO adds only what it does not write: the author profile and the FAQ.', 'acid-aeo' ), (string) $seo['name'] ), Check::WEIGHT_NICE ); } return Check::pass( 'core_seo_defer', 'core', $label, __( 'Acid AEO writes the full schema graph for this site.', 'acid-aeo' ), Check::WEIGHT_NICE ); } /** * Whether the site can reach itself, which is what the audit measures with. * * @return Check The finding. */ private static function loopback(): Check { $label = __( 'Loopback requests', 'acid-aeo' ); if ( Loopback::is_available() ) { return Check::pass( 'core_loopback', 'core', $label, __( 'The site can request its own URLs, so every published file was checked as a crawler would see it.', 'acid-aeo' ), Check::WEIGHT_NICE ); } return Check::warn( 'core_loopback', 'core', $label, __( 'This host blocks requests the site makes to itself, so the audit could not fetch the published files. Everything that needed fetching was skipped rather than failed.', 'acid-aeo' ), Check::WEIGHT_NICE ); } /** * Whether anybody writing here has an author profile worth citing. * * @return Check The finding. */ private static function author_profiles(): Check { $label = __( 'Author profiles', 'acid-aeo' ); $authors = Checks::recent_authors( 10 ); if ( [] === $authors ) { return Check::skip( 'core_author_profiles', 'core', $label, __( 'There are no published posts to read authors from yet.', 'acid-aeo' ), Check::WEIGHT_NICE ); } foreach ( $authors as $author_id ) { if ( '' !== trim( (string) get_user_meta( $author_id, UserFields::JOB_TITLE, true ) ) ) { return Check::pass( 'core_author_profiles', 'core', $label, __( 'At least one recent author has a job title, so the author schema says who wrote the page and why they would know.', 'acid-aeo' ), Check::WEIGHT_NICE ); } } return Check::warn( 'core_author_profiles', 'core', $label, __( 'No recent author has a job title. Fill in the Acid AEO fields on a user profile so the author schema can say who wrote the page.', 'acid-aeo' ), Check::WEIGHT_NICE ); } }