*/ public const FILES = [ 'robots.txt', 'llms.txt', 'llms-full.txt', 'humans.txt', 'ai.txt', 'ai-context.json', '.well-known/security.txt', ]; /** * SEO plugins, in detection order: constant => slug and name. * * @var array> */ private const SEO_PLUGINS = [ 'WPSEO_VERSION' => [ 'slug' => 'yoast', 'name' => 'Yoast SEO', ], 'RANK_MATH_VERSION' => [ 'slug' => 'rank-math', 'name' => 'Rank Math', ], 'SEOPRESS_VERSION' => [ 'slug' => 'seopress', 'name' => 'SEOPress', ], 'AIOSEO_VERSION' => [ 'slug' => 'aioseo', 'name' => 'All in One SEO', ], 'THE_SEO_FRAMEWORK_VERSION' => [ 'slug' => 'seo-framework', 'name' => 'The SEO Framework', ], 'SLIM_SEO_VER' => [ 'slug' => 'slim-seo', 'name' => 'Slim SEO', ], ]; /** * Page cache plugins, in detection order: constant => slug and name. * * @var array> */ private const CACHE_PLUGINS = [ 'WP_ROCKET_VERSION' => [ 'slug' => 'wp-rocket', 'name' => 'WP Rocket', ], 'W3TC' => [ 'slug' => 'w3-total-cache', 'name' => 'W3 Total Cache', ], 'WPCACHEHOME' => [ 'slug' => 'wp-super-cache', 'name' => 'WP Super Cache', ], 'LSCWP_V' => [ 'slug' => 'litespeed-cache', 'name' => 'LiteSpeed Cache', ], 'WPFC_MAIN_PATH' => [ 'slug' => 'wp-fastest-cache', 'name' => 'WP Fastest Cache', ], 'CE_VERSION' => [ 'slug' => 'cache-enabler', 'name' => 'Cache Enabler', ], 'SiteGround_Optimizer\\VERSION' => [ 'slug' => 'siteground-optimizer', 'name' => 'SiteGround Optimizer', ], ]; /** * Option holding the sitemap address the site was last seen to answer. * * @var string */ public const SITEMAP_OPTION = 'acid_aeo_sitemap_url'; /** * Sitemap paths the common SEO plugins use, by slug. * * A starting guess, not an answer: every one of these is a module that can * be switched off. `resolve_sitemap()` is what settles it. * * @var array */ private const SITEMAP_PATHS = [ 'yoast' => '/sitemap_index.xml', 'rank-math' => '/sitemap_index.xml', 'aioseo' => '/sitemap.xml', 'seopress' => '/sitemaps.xml', 'seo-framework' => '/sitemap.xml', 'slim-seo' => '/sitemap.xml', ]; /** * Detection result for the current request. * * @var array|null */ private static ?array $detected = null; /** * Everything the audit, the wizard and the REST API need to know. * * Memoized: a single admin request can run the audit, answer * `GET /environment` and prefill the wizard, and none of this changes while * the request is being served. * * @return array Detection result. */ public static function detect(): array { if ( null !== self::$detected ) { return self::$detected; } $woocommerce = defined( 'WC_VERSION' ); $counts = self::post_counts(); self::$detected = [ 'seo_plugin' => self::seo_plugin(), 'cache_plugin' => self::cache_plugin(), 'woocommerce' => $woocommerce, 'multilingual' => self::multilingual(), 'permalinks' => '' !== (string) get_option( 'permalink_structure', '' ), 'https' => wp_is_using_https(), 'blog_public' => (bool) get_option( 'blog_public', 1 ), 'server' => self::server(), 'physical_files' => self::physical_files(), 'site_type_guess' => self::site_type_guess( $woocommerce, $counts ), 'language' => strtolower( substr( (string) get_locale(), 0, 2 ) ), 'post_counts' => $counts, 'prefill' => self::prefill(), ]; return self::$detected; } /** * Drops the memoized detection. * * For tests, and for code that changes the site between two reads (the * wizard writing settings, the audit applying a fix). * * @return void */ public static function reset(): void { self::$detected = null; } /** * The active SEO plugin. * * @return array|null `slug` and `name`, or null when none is active. */ public static function seo_plugin(): ?array { return self::first_defined( self::SEO_PLUGINS ); } /** * The sitemap a crawler should be pointed at. * * The common SEO plugins replace the core sitemap with their own index at a * different path. * * @return string Absolute sitemap URL. */ public static function sitemap_url(): string { $plugin = self::seo_plugin(); $slug = null !== $plugin ? $plugin['slug'] : ''; $mapped = self::SITEMAP_PATHS[ $slug ] ?? ''; // The address the audit last saw answer, when there is one. Which plugin // is installed says less than it looks: Rank Math, SEOPress and All in // One SEO ship the sitemap as a module that can be off, and The SEO // Framework answers at `/sitemap.xml` while core's own address stops // answering — three ways to publish a 404 to every crawler that reads // this file. The rewrite rules do not settle it either; core's rule // outlives the plugin that switched its handler off. $stored = get_option( self::SITEMAP_OPTION, '' ); if ( is_string( $stored ) && '' !== $stored && false !== filter_var( $stored, FILTER_VALIDATE_URL ) ) { return $stored; } return self::guessed_sitemap_url( $mapped ); } /** * Asks the site which sitemap address it actually answers, and remembers it. * * Deliberately not called while a file is being generated: a generator that * makes HTTP requests to its own site is a generator that is slow on the one * request that had to rebuild the cache. This runs where a request to * ourselves is expected anyway — the audit — and on the daily maintenance * pass, which is often enough for a setting somebody changed yesterday. * * Nothing is stored when the host blocks loopback requests: there is no * answer to be had, and a stored guess would outlive its guessing. * * @return string The resolved URL, or the guess when nothing answered. */ public static function resolve_sitemap(): string { $plugin = self::seo_plugin(); $slug = null !== $plugin ? $plugin['slug'] : ''; $mapped = self::SITEMAP_PATHS[ $slug ] ?? ''; if ( ! Loopback::is_available() ) { return self::guessed_sitemap_url( $mapped ); } foreach ( array_filter( [ $mapped, '/wp-sitemap.xml', '/sitemap.xml', '/sitemap_index.xml', '/sitemaps.xml' ] ) as $path ) { $url = home_url( (string) $path ); $result = Loopback::fetch( $url ); if ( ! empty( $result['ok'] ) && self::looks_like_sitemap( $result ) ) { update_option( self::SITEMAP_OPTION, $url, false ); return $url; } } delete_option( self::SITEMAP_OPTION ); return self::guessed_sitemap_url( $mapped ); } /** * Whether what came back is a sitemap rather than a page saying otherwise. * * HTTP 200 is not an answer on its own. A host whose unknown paths are * served by the home page, a soft 404, or the redirect the loopback follows * all hand back a perfectly successful HTML document — and a stored address * is published to every crawler through robots.txt and llms.txt, and * preferred over the guess that would have been right. * * The root element is what settles it. The sitemaps protocol names `urlset` * and `sitemapindex`, and allows an RSS or Atom feed to stand in, so all * four count; a namespace prefix in front of them does too. The XML * declaration is not required — Slim SEO's output starts at ` $result One `Loopback::fetch()` result. * @return bool True when the body reads as a sitemap. */ private static function looks_like_sitemap( array $result ): bool { $type = strtolower( (string) ( $result['content_type'] ?? '' ) ); if ( false !== strpos( $type, 'html' ) ) { return false; } $body = substr( (string) ( $result['body'] ?? '' ), 0, 2048 ); return 1 === preg_match( '#<(?:[a-z0-9_-]+:)?(?:urlset|sitemapindex|rss|feed)[\s>]#i', $body ); } /** * The address a sitemap is most likely to live at, worked out rather than asked. * * @param string $mapped Path known for the active SEO plugin, empty when there is none. * @return string Absolute URL. */ private static function guessed_sitemap_url( string $mapped ): string { if ( '' === $mapped && self::core_sitemap_serving() ) { return home_url( '/wp-sitemap.xml' ); } return home_url( '' !== $mapped ? $mapped : '/wp-sitemap.xml' ); } /** * Whether WordPress is still serving its own sitemap. * * Asked through the server object rather than `wp_sitemaps_enabled()`: that * wrapper is gone in WordPress 7.0, while `WP_Sitemaps::sitemaps_enabled()` * — which it called — is still there and still the authority. Checked at * both levels so the answer is "no" rather than a fatal on whatever ships * next. * * @return bool True when core answers `/wp-sitemap.xml`. */ private static function core_sitemap_serving(): bool { if ( ! function_exists( 'wp_sitemaps_get_server' ) ) { return false; } $server = wp_sitemaps_get_server(); if ( ! is_object( $server ) || ! method_exists( $server, 'sitemaps_enabled' ) ) { return false; } return (bool) $server->sitemaps_enabled(); } /** * The active page cache plugin. * * @return array|null `slug` and `name`, or null when none is active. */ private static function cache_plugin(): ?array { return self::first_defined( self::CACHE_PLUGINS ); } /** * The first plugin of a map whose constant is defined. * * @param array> $map Constant => slug and name. * @return array|null The match, or null. */ private static function first_defined( array $map ): ?array { foreach ( $map as $constant => $plugin ) { if ( defined( $constant ) ) { return $plugin; } } return null; } /** * The active translation plugin. * * Only reported as a warning: the generated files are single language in v1. * * @return string|null `polylang`, `wpml`, or null. */ private static function multilingual(): ?string { if ( defined( 'POLYLANG_VERSION' ) ) { return 'polylang'; } if ( defined( 'ICL_SITEPRESS_VERSION' ) ) { return 'wpml'; } return null; } /** * The web server family, used for the `.well-known` setup hints. * * @return string `apache`, `nginx`, `litespeed` or `other`. */ private static function server(): string { $software = isset( $_SERVER['SERVER_SOFTWARE'] ) ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) ) : ''; foreach ( [ 'litespeed', 'nginx', 'apache' ] as $family ) { if ( false !== strpos( $software, $family ) ) { return $family; } } return 'other'; } /** * Which of the plugin's files also exist on disk. * * @return array Relative path => exists. */ private static function physical_files(): array { $files = []; foreach ( self::FILES as $path ) { $files[ $path ] = file_exists( Filesystem::webroot() . $path ); } return $files; } /** * Published post counts, per public post type. * * Not only core's two: a site whose content is entirely in custom post types * — a portfolio, a knowledge base, a listing site — would otherwise be * counted as having published nothing at all. * * @return array Post type => published count. */ private static function post_counts(): array { $counts = []; foreach ( self::counted_post_types() as $post_type ) { $counted = wp_count_posts( $post_type ); $counts[ $post_type ] = is_object( $counted ) ? (int) ( $counted->publish ?? 0 ) : 0; } return $counts; } /** * The public post types worth counting. * * Attachments are public and are not content anybody publishes; core's two * are named first so the shape of the answer does not depend on the order * WordPress happens to register things in. * * @return array Post type names. */ private static function counted_post_types(): array { // Keyed by name whichever shape was asked for, so a filter or a test // that hands back objects is read the same way. $types = get_post_types( [ 'public' => true ] ); $types = is_array( $types ) ? array_map( 'strval', array_keys( $types ) ) : []; $types = array_diff( $types, [ 'attachment' ] ); return array_values( array_unique( array_merge( array_intersect( [ 'post', 'page' ], $types ), $types ) ) ); } /** * Which of the counted post types are page-like. * * @return array Hierarchical public post type names. */ private static function page_like_post_types(): array { $types = get_post_types( [ 'public' => true, 'hierarchical' => true, ] ); return is_array( $types ) ? array_map( 'strval', array_keys( $types ) ) : [ 'page' ]; } /** * What kind of site this looks like, used to preselect the wizard answers. * * @param bool $woocommerce Whether WooCommerce is active. * @param array $counts Published post counts. * @return string `ecommerce`, `blog` or `business`. */ private static function site_type_guess( bool $woocommerce, array $counts ): string { return self::guess_site_type( $woocommerce, $counts, self::page_like_post_types() ); } /** * The same guess, from counts alone. * * A site is a blog when it keeps publishing articles and has few pages — * "articles" being whatever it publishes that is not page-like, so a news * CPT counts the same way `post` does. * * Public because it is pure, which is what the unit test exercises. * * @param bool $woocommerce Whether WooCommerce is active. * @param array $counts Post type => published count. * @param array $hierarchical Post types that are page-like. * @return string `ecommerce`, `blog` or `business`. */ public static function guess_site_type( bool $woocommerce, array $counts, array $hierarchical ): string { if ( $woocommerce ) { return 'ecommerce'; } $pages = 0; $articles = 0; foreach ( $counts as $post_type => $count ) { if ( in_array( (string) $post_type, array_map( 'strval', $hierarchical ), true ) ) { $pages += (int) $count; continue; } $articles += (int) $count; } if ( $articles >= 10 && $articles > $pages ) { return 'blog'; } return 'business'; } /** * Identity values read from other plugins, for the wizard's empty fields. * * Sources fill only what earlier ones left empty, in order: Yoast, Rank * Math, WooCommerce, then WordPress itself. * * @return array `name`, `description`, `email`, `logo_id`, `schema_type`, * `same_as`, `address` and `phone`. */ private static function prefill(): array { $prefill = [ 'name' => '', 'description' => '', 'email' => '', 'logo_id' => 0, 'schema_type' => '', 'same_as' => [], 'address' => [ 'street' => '', 'city' => '', 'region' => '', 'postal' => '', 'country' => '', ], 'phone' => '', ]; foreach ( [ self::yoast_prefill(), self::rank_math_prefill(), self::woocommerce_prefill(), self::wordpress_prefill() ] as $source ) { $prefill = self::fill( $prefill, $source ); } $prefill['name'] = sanitize_text_field( $prefill['name'] ); $prefill['description'] = sanitize_textarea_field( $prefill['description'] ); $prefill['email'] = sanitize_email( $prefill['email'] ); $prefill['phone'] = sanitize_text_field( $prefill['phone'] ); $prefill['schema_type'] = (string) preg_replace( '/[^A-Za-z]/', '', $prefill['schema_type'] ); $prefill['address'] = array_map( 'sanitize_text_field', $prefill['address'] ); $prefill['same_as'] = array_values( array_filter( array_map( 'esc_url_raw', $prefill['same_as'] ) ) ); return $prefill; } /** * Copies the values a source knows into the slots still empty. * * @param array $prefill Prefill built so far. * @param array $source Values from one source. * @return array Merged prefill. */ private static function fill( array $prefill, array $source ): array { foreach ( [ 'name', 'description', 'email', 'schema_type', 'phone' ] as $field ) { $value = self::text( $source[ $field ] ?? '' ); if ( '' === $prefill[ $field ] && '' !== $value ) { $prefill[ $field ] = $value; } } if ( 0 === $prefill['logo_id'] ) { $prefill['logo_id'] = max( 0, (int) ( $source['logo_id'] ?? 0 ) ); } $address = is_array( $source['address'] ?? null ) ? $source['address'] : []; foreach ( $prefill['address'] as $field => $current ) { $value = self::text( $address[ $field ] ?? '' ); if ( '' === $current && '' !== $value ) { $prefill['address'][ $field ] = $value; } } $same_as = is_array( $source['same_as'] ?? null ) ? $source['same_as'] : []; $prefill['same_as'] = array_values( array_unique( array_merge( $prefill['same_as'], $same_as ) ) ); return $prefill; } /** * Identity values stored by Yoast SEO. * * @return array Prefill fragment. */ private static function yoast_prefill(): array { if ( ! defined( 'WPSEO_VERSION' ) ) { return []; } $titles = self::option_array( 'wpseo_titles' ); $social = self::option_array( 'wpseo_social' ); $is_person = 'person' === self::text( $titles['company_or_person'] ?? '' ); $handle = ltrim( self::text( $social['twitter_site'] ?? '' ), '@' ); return [ 'name' => self::text( $titles['company_name'] ?? '' ), 'schema_type' => $is_person ? 'Person' : 'Organization', 'logo_id' => (int) ( $is_person ? ( $titles['person_logo_id'] ?? 0 ) : ( $titles['company_logo_id'] ?? 0 ) ), 'same_as' => self::urls( [ $social['facebook_site'] ?? '', '' !== $handle ? 'https://x.com/' . $handle : '', $social['instagram_url'] ?? '', $social['linkedin_url'] ?? '', $social['youtube_url'] ?? '', $social['pinterest_url'] ?? '', $social['other_social_urls'] ?? [], ] ), ]; } /** * Identity values stored by Rank Math. * * @return array Prefill fragment. */ private static function rank_math_prefill(): array { if ( ! defined( 'RANK_MATH_VERSION' ) ) { return []; } $titles = self::option_array( 'rank-math-options-titles' ); $type = self::text( $titles['knowledgegraph_type'] ?? '' ); $address = is_array( $titles['local_address'] ?? null ) ? $titles['local_address'] : []; $types = [ 'person' => 'Person', 'company' => 'Organization', ]; return [ 'name' => self::text( $titles['knowledgegraph_name'] ?? '' ), 'schema_type' => $types[ $type ] ?? '', 'logo_id' => (int) ( $titles['knowledgegraph_logo_id'] ?? 0 ), 'phone' => self::text( $titles['phone'] ?? '' ), 'email' => self::text( $titles['email'] ?? '' ), 'address' => [ 'street' => self::text( $address['streetAddress'] ?? $address['street'] ?? '' ), 'city' => self::text( $address['addressLocality'] ?? $address['locality'] ?? '' ), 'region' => self::text( $address['addressRegion'] ?? $address['region'] ?? '' ), 'postal' => self::text( $address['postalCode'] ?? $address['postalcode'] ?? '' ), 'country' => self::text( $address['addressCountry'] ?? $address['country'] ?? '' ), ], 'same_as' => self::urls( [ $titles['social_url_facebook'] ?? '', $titles['social_url_twitter'] ?? '', $titles['social_url_instagram'] ?? '', $titles['social_url_linkedin'] ?? '', $titles['social_url_youtube'] ?? '', ] ), ]; } /** * Store address stored by WooCommerce. * * `woocommerce_default_country` holds `RO` or `RO:IS`, i.e. country and * optional state. * * @return array Prefill fragment. */ private static function woocommerce_prefill(): array { if ( ! defined( 'WC_VERSION' ) ) { return []; } $country = explode( ':', self::text( get_option( 'woocommerce_default_country', '' ) ) ); return [ 'address' => [ 'street' => self::text( get_option( 'woocommerce_store_address', '' ) ), 'city' => self::text( get_option( 'woocommerce_store_city', '' ) ), 'region' => self::text( $country[1] ?? '' ), 'postal' => self::text( get_option( 'woocommerce_store_postcode', '' ) ), 'country' => self::text( $country[0] ?? '' ), ], ]; } /** * What WordPress itself knows, used when no plugin filled a field. * * @return array Prefill fragment. */ private static function wordpress_prefill(): array { $logo_id = (int) get_theme_mod( 'custom_logo', 0 ); return [ 'name' => (string) get_bloginfo( 'name' ), 'description' => (string) get_bloginfo( 'description' ), 'email' => self::text( get_option( 'admin_email', '' ) ), 'logo_id' => $logo_id > 0 ? $logo_id : (int) get_option( 'site_icon', 0 ), 'schema_type' => 'Organization', ]; } /** * Flattens a list of URL values, dropping the empty ones. * * @param array $values URLs, possibly nested one level. * @return array URLs. */ private static function urls( array $values ): array { $urls = []; foreach ( $values as $value ) { if ( is_array( $value ) ) { $urls = array_merge( $urls, self::urls( $value ) ); continue; } $value = is_scalar( $value ) ? trim( (string) $value ) : ''; if ( '' !== $value ) { $urls[] = $value; } } return $urls; } /** * A value from another plugin's option as a trimmed string. * * Foreign options are read defensively: anything but a scalar becomes an * empty string instead of a conversion warning. * * @param mixed $value Raw value. * @return string Trimmed string. */ private static function text( $value ): string { return is_scalar( $value ) ? trim( (string) $value ) : ''; } /** * Reads an option that another plugin stores as an array. * * @param string $name Option name. * @return array The option, or an empty array. */ private static function option_array( string $name ): array { $value = get_option( $name, [] ); return is_array( $value ) ? $value : []; } }