|null */ private static ?array $identity = null; /** * The site identity, with WordPress values filled in for empty settings. * * Resolved once per request: a page view asks for it from the schema graph, * the head tags and every generated file, and each answer costs two * attachment lookups and a user lookup. `reset()` drops it. * * @return array The `identity` settings plus `home_url`, `language`, * `logo_url`, `image_url`, `founder` and `search_url`. */ public static function identity(): array { if ( null !== self::$identity ) { return self::$identity; } $identity = Options::get( 'identity' ); $identity = is_array( $identity ) ? $identity : []; $identity['home_url'] = home_url( '/' ); $identity['name'] = self::first_non_empty( $identity['name'] ?? '', (string) get_bloginfo( 'name' ) ); $identity['description'] = self::first_non_empty( $identity['description'] ?? '', (string) get_bloginfo( 'description' ) ); $identity['language'] = self::first_non_empty( (string) Options::get( 'general.language', '' ), strtolower( substr( (string) get_locale(), 0, 2 ) ) ); $identity['logo_url'] = self::attachment_url( (int) ( $identity['logo_id'] ?? 0 ) ); $identity['image_url'] = self::attachment_url( (int) ( $identity['image_id'] ?? 0 ) ); $identity['founder'] = self::founder( (int) ( $identity['founder_user_id'] ?? 0 ) ); $identity['search_url'] = home_url( '/?s={search_term_string}' ); if ( '' === $identity['logo_url'] ) { $identity['logo_url'] = (string) get_site_icon_url( 512 ); } self::$identity = $identity; return self::$identity; } /** * Drops the memoized identity. * * For tests, and for anything that changes the settings or the media * library and then reads the identity back in the same PHP process — the * settings screen saving a section, WP-CLI, cron. * * @return void */ public static function reset(): void { self::$identity = null; } /** * The AI crawler policy, resolved into per-bot decisions. * * @return array Policy array ready for `Generators\RobotsGenerator`. */ public static function policy(): array { $policy = Options::get( 'policy' ); $policy = is_array( $policy ) ? $policy : []; $preset = (string) ( $policy['preset'] ?? 'citable' ); $identity = self::identity(); $paths = is_array( $policy['disallow_paths'] ?? null ) ? $policy['disallow_paths'] : []; $contact = self::first_non_empty( $policy['permissions_contact'] ?? '', (string) ( $identity['email'] ?? '' ) ); return [ 'bots' => Registry::resolve( $preset, is_array( $policy['bots'] ?? null ) ? $policy['bots'] : [] ), 'disallow_paths' => array_values( array_unique( array_merge( [ '/wp-admin/', '/?s=', '/search/' ], $paths ) ) ), 'always_allow' => self::allow_paths( self::file_urls() ), 'content_signal' => Registry::content_signal( $preset, is_array( $policy['content_signal'] ?? null ) ? $policy['content_signal'] : [] ), 'site_name' => $identity['name'], 'policy_url' => self::file_urls()['ai_txt'], 'generated' => gmdate( 'Y-m-d' ), 'license' => (string) ( $policy['license'] ?? 'all_rights' ), 'license_url' => (string) ( $policy['license_url'] ?? '' ), 'license_label' => self::license_label(), 'permissions_contact' => $contact, 'preset' => $preset, ]; } /** * The Content-Signal header value for the current policy. * * @return string E.g. `search=yes, ai-input=yes, ai-train=no`. */ public static function content_signal_string(): string { $policy = Options::get( 'policy' ); $policy = is_array( $policy ) ? $policy : []; return RobotsGenerator::content_signal_line( Registry::content_signal( (string) ( $policy['preset'] ?? 'citable' ), is_array( $policy['content_signal'] ?? null ) ? $policy['content_signal'] : [] ) ); } /** * The `Allow:` lines that keep the AI files readable whatever else is blocked. * * Derived from the addresses the files are actually served at rather than * written out: on plain permalinks `Router` serves them as * `/?acid_aeo_file=llms`, and on a site in a subdirectory every path carries * that directory. A hardcoded `/llms.txt` on either is an `Allow:` line for * a 404, while the address a crawler was given stays disallowed. * * Public because it is pure, which is what the unit test exercises. * * @param array $urls Output of `file_urls()`. * @return array Root relative paths, in the order the files are listed. */ public static function allow_paths( array $urls ): array { $paths = []; foreach ( [ 'llms', 'llms_full', 'ai_txt', 'humans', 'ai_context', 'security' ] as $key ) { $parts = wp_parse_url( (string) ( $urls[ $key ] ?? '' ) ); if ( ! is_array( $parts ) || '' === (string) ( $parts['path'] ?? '' ) ) { continue; } $query = (string) ( $parts['query'] ?? '' ); $paths[] = '' === $query ? (string) $parts['path'] : $parts['path'] . '?' . $query; } return array_values( array_unique( $paths ) ); } /** * Absolute URLs of every file the plugin publishes. * * `Endpoints\Router` owns the route table, so a file that some module has * registered is linked exactly as it is served (including the query var * form on plain permalinks). Files whose module is switched off, or not * written yet, keep their canonical path. * * @return array File key => absolute URL. */ public static function file_urls(): array { return [ 'llms' => self::file_url( 'llms', 'llms.txt' ), 'llms_full' => self::file_url( 'llms_full', 'llms-full.txt' ), 'humans' => self::file_url( 'humans', 'humans.txt' ), 'ai_txt' => self::file_url( 'ai_txt', 'ai.txt' ), 'ai_context' => self::file_url( 'ai_context', 'ai-context.json' ), 'security' => self::file_url( 'security', '.well-known/security.txt' ), 'sitemap' => Environment::sitemap_url(), 'feed' => (string) get_feed_link(), ]; } /** * Canonical URL of the page WordPress is currently rendering. * * WordPress has no general answer to this: `get_permalink()` covers only * singular pages, and reading the request back from `$_SERVER` would carry * whatever tracking parameters brought the visitor here into a `` tag * and into the schema graph. So each kind of query is asked for its own * canonical link, and a query that has none is left without a URL rather * than given a wrong one. * * @return string Absolute URL, or an empty string when the query has no canonical URL. */ public static function current_url(): string { if ( is_front_page() ) { return home_url( '/' ); } if ( is_home() ) { $page_id = (int) get_option( 'page_for_posts', 0 ); $url = $page_id > 0 ? get_permalink( $page_id ) : ''; return is_string( $url ) && '' !== $url ? $url : home_url( '/' ); } if ( is_singular() ) { $url = get_permalink(); return is_string( $url ) ? $url : ''; } if ( is_author() ) { return (string) get_author_posts_url( (int) get_queried_object_id() ); } if ( is_category() || is_tag() || is_tax() ) { $url = get_term_link( (int) get_queried_object_id() ); return is_string( $url ) ? $url : ''; } if ( is_post_type_archive() ) { $object = get_queried_object(); $url = isset( $object->name ) ? get_post_type_archive_link( (string) $object->name ) : false; return is_string( $url ) ? $url : ''; } if ( is_search() ) { return (string) get_search_link(); } if ( is_date() ) { return self::date_archive_url(); } return ''; } /** * Canonical URL of the year, month or day archive being rendered. * * @return string Absolute URL, or an empty string when the query names no year. */ private static function date_archive_url(): string { $year = (int) get_query_var( 'year' ); $month = (int) get_query_var( 'monthnum' ); $day = (int) get_query_var( 'day' ); if ( $year <= 0 ) { return ''; } if ( $day > 0 && $month > 0 ) { return (string) get_day_link( $year, $month, $day ); } return $month > 0 ? (string) get_month_link( $year, $month ) : (string) get_year_link( $year ); } /** * Human readable name of the content licence chosen in the settings. * * @return string Licence label, or the custom licence URL. */ public static function license_label(): string { $license = (string) Options::get( 'policy.license', 'all_rights' ); switch ( $license ) { case 'cc_by': return 'CC BY 4.0'; case 'cc_by_nc': return 'CC BY-NC 4.0'; case 'cc_by_sa': return 'CC BY-SA 4.0'; case 'custom': return (string) Options::get( 'policy.license_url', '' ); default: return __( 'All rights reserved. Indexing, summarization and citation with attribution permitted; bulk reproduction or model training requires written permission.', 'acid-aeo' ); } } /** * URL of one published file. * * @param string $slug Router slug, e.g. `llms`. * @param string $path Canonical path used while the file is not registered. * @return string Absolute URL. */ private static function file_url( string $slug, string $path ): string { $url = Router::url( $slug ); return '' !== $url ? $url : home_url( '/' . $path ); } /** * Full size URL of an attachment. * * @param int $attachment_id Attachment ID. * @return string URL, or an empty string when there is no such attachment. */ private static function attachment_url( int $attachment_id ): string { if ( $attachment_id <= 0 ) { return ''; } $url = wp_get_attachment_image_url( $attachment_id, 'full' ); return is_string( $url ) ? $url : ''; } /** * The site founder, as name and author archive URL. * * @param int $user_id User ID from the settings. * @return array|null Founder details, or null when unset or unknown. */ private static function founder( int $user_id ): ?array { if ( $user_id <= 0 ) { return null; } $user = get_userdata( $user_id ); if ( ! $user ) { return null; } return [ 'id' => $user_id, 'name' => (string) $user->display_name, 'url' => (string) get_author_posts_url( $user_id ), ]; } /** * The first of two values that is not an empty string. * * @param mixed $value Preferred value, usually from the settings. * @param string $fallback Fallback value, usually from WordPress. * @return string The chosen value. */ private static function first_non_empty( $value, string $fallback ): string { $value = is_scalar( $value ) ? trim( (string) $value ) : ''; return '' !== $value ? $value : $fallback; } }