true, 'llms_provider_mode' => 'auto', 'llms_generate_enabled' => true, 'llms_generate_full' => true, 'llms_generator_site_summary' => '', 'llms_generator_post_types' => array( 'page', 'post' ), 'llms_generator_max_items' => 60, 'llms_generator_respect_noindex' => true, 'llms_generator_include_categories' => true, 'llms_audit_check_full' => true, 'llms_audit_check_links' => true, 'llms_audit_max_links' => 20, 'llms_audit_min_links' => 3, 'llms_audit_max_bytes' => 100000, 'llms_rank_math_header_fix' => true, 'llms_header_fix_allow_index' => false, 'robots_txt_header_fix' => true, ); } /** * Main settings with llms defaults applied. * * @return array */ public static function settings() { $settings = wp_parse_args( get_option( 'ai_o_optimiser_settings', array() ), AI_O_Optimiser_Settings::defaults() ); $defaults = self::default_settings(); foreach ( $defaults as $key => $value ) { if ( ! array_key_exists( $key, $settings ) ) { $settings[ $key ] = $value; } } if ( empty( $settings['llms_provider_mode'] ) || ! in_array( $settings['llms_provider_mode'], array( 'auto', 'act', 'rank_math', 'disabled' ), true ) ) { $settings['llms_provider_mode'] = 'auto'; } if ( empty( $settings['llms_generator_post_types'] ) || ! is_array( $settings['llms_generator_post_types'] ) ) { $settings['llms_generator_post_types'] = $defaults['llms_generator_post_types']; } $settings['llms_generator_post_types'] = array_values( array_intersect( self::public_post_type_names(), array_map( 'sanitize_key', $settings['llms_generator_post_types'] ) ) ); if ( empty( $settings['llms_generator_post_types'] ) ) { $settings['llms_generator_post_types'] = $defaults['llms_generator_post_types']; } return $settings; } /** * Match a request path to one of the guidance files. * * @param string $path Request path. * @return array|null */ public static function match_guidance_path( $path ) { $path = '/' . ltrim( (string) $path, '/' ); $path = strtok( $path, '?' ); $path = rawurldecode( $path ); $home_path = wp_parse_url( home_url( '/' ), PHP_URL_PATH ); $home_path = $home_path ? rtrim( $home_path, '/' ) : ''; if ( $home_path && 0 === strpos( $path, $home_path . '/' ) ) { $path = substr( $path, strlen( $home_path ) ); $path = '/' . ltrim( $path, '/' ); } if ( '/llms.txt' === $path ) { return array( 'slug' => 'llms_txt', 'label' => 'llms.txt', 'full' => false, ); } if ( '/llms-full.txt' === $path ) { return array( 'slug' => 'llms_full_txt', 'label' => 'llms-full.txt', 'full' => true, ); } return null; } /** * Guidance files worth counting a request against, for tracking only. * * robots.txt belongs here and not in match_guidance_path(): it is the file * that tells a crawler what it may read, so a fetch of it is the same kind * of signal as a fetch of llms.txt. But match_guidance_path() also decides * what this plugin generates and serves, and this plugin does not serve * robots.txt -- WordPress does. Adding it there would have the generator * claim a file that is not its to write. * * @param string $path Request path. * @return array|null */ public static function match_tracked_guidance_path( $path ) { $match = self::match_guidance_path( $path ); if ( null !== $match ) { return $match; } $normalised = strtok( '/' . ltrim( rawurldecode( (string) $path ), '/' ), '?' ); $home_path = wp_parse_url( home_url( '/' ), PHP_URL_PATH ); $home_path = $home_path ? rtrim( $home_path, '/' ) : ''; if ( $home_path && 0 === strpos( $normalised, $home_path . '/' ) ) { $normalised = '/' . ltrim( substr( $normalised, strlen( $home_path ) ), '/' ); } if ( '/robots.txt' === $normalised ) { return array( 'slug' => 'robots_txt', 'label' => 'robots.txt', 'full' => false, ); } return null; } /** * Guidance URLs used by the audit screen. * * @return array */ public static function guidance_urls() { return array( 'llms_txt' => home_url( '/llms.txt' ), 'llms_full_txt' => home_url( '/llms-full.txt' ), ); } /** * Resolve which provider should serve llms.txt on the current site. * * @param array $settings Settings. * @return string auto-resolved provider: act|rank_math|disabled. */ private static function resolve_provider_mode( $settings ) { $mode = isset( $settings['llms_provider_mode'] ) ? sanitize_key( $settings['llms_provider_mode'] ) : 'auto'; if ( 'disabled' === $mode ) { return 'disabled'; } if ( 'rank_math' === $mode ) { return 'rank_math'; } if ( 'act' === $mode ) { return 'act'; } return self::rank_math_llms_module_active() ? 'rank_math' : 'act'; } /** * Check whether Rank Math's llms-txt module appears active. * * @return bool */ private static function rank_math_llms_module_active() { if ( class_exists( '\\RankMath\\Helper' ) && method_exists( '\\RankMath\\Helper', 'is_module_active' ) ) { try { return (bool) call_user_func( array( '\\RankMath\\Helper', 'is_module_active' ), 'llms-txt' ); } catch ( Exception $e ) { return false; } } return false; } /** * Serve generated llms.txt / llms-full.txt through WordPress when requested. */ public function maybe_serve_guidance_file() { if ( is_admin() || wp_doing_ajax() || wp_doing_cron() ) { return; } $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; $match = self::match_guidance_path( $request_uri ); if ( null === $match ) { return; } $settings = self::settings(); if ( empty( $settings['llms_generate_enabled'] ) ) { return; } $provider_mode = self::resolve_provider_mode( $settings ); if ( 'disabled' === $provider_mode ) { return; } if ( 'rank_math' === $provider_mode && empty( $match['full'] ) ) { return; } if ( ! empty( $match['full'] ) && empty( $settings['llms_generate_full'] ) ) { return; } $content = self::generate_guidance_content( ! empty( $match['full'] ), $settings ); if ( empty( $content ) ) { return; } status_header( 200 ); nocache_headers(); header( 'Content-Type: text/plain; charset=' . get_option( 'blog_charset', 'UTF-8' ) ); header( 'X-Robots-Tag: noindex' ); echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Plain text file response generated from escaped/sanitised content. exit; } /** * Apply the Rank Math llms.txt header compatibility fix when Rank Math serves the file. * * Rank Math may emit X-Robots-Tag values that include nofollow for /llms.txt. * For guidance files, noindex is acceptable, but nofollow can prevent AI crawlers * from using the internal links the file is meant to expose. This callback is * attached to Rank Math's own llms.txt output hook and is inert if Rank Math * never fires that hook. */ public function apply_rank_math_llms_header_fix() { $settings = self::settings(); if ( empty( $settings['llms_rank_math_header_fix'] ) || headers_sent() ) { return; } header_remove( 'X-Robots-Tag' ); header( 'X-Robots-Tag: noindex', true ); } /** * Safety-net header fix for /llms.txt, /llms-full.txt and /robots.txt. * * apply_rank_math_llms_header_fix() above only fires when Rank Math's own * llms-txt module triggers its documented output hook. Some Rank Math builds * and configurations set a blocking X-Robots-Tag (noindex, nofollow) on these * paths through a different code path that hook never sees — and Rank Math * does not apply any llms.txt-specific handling to /robots.txt at all, so a * blanket "noindex everything not explicitly allowed" rule can catch it too. * This runs late on every front-end request, checks the exact request path, * and corrects the header regardless of what set it or why — it does not * depend on Rank Math being active. */ public function enforce_ai_crawler_text_file_headers() { if ( is_admin() || wp_doing_ajax() || wp_doing_cron() || headers_sent() ) { return; } // Path matching first, settings second: self::settings() resolves the // site's public post types on every call, which is unnecessary work on // the overwhelming majority of requests that are neither a guidance // file nor robots.txt. This hook runs on every single front-end request. $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; $match = self::match_guidance_path( $request_uri ); $is_robots = null === $match && self::is_robots_txt_path( $request_uri ); if ( null === $match && ! $is_robots ) { return; } $settings = self::settings(); if ( null !== $match ) { if ( empty( $settings['llms_rank_math_header_fix'] ) ) { return; } $value = ! empty( $settings['llms_header_fix_allow_index'] ) ? 'index, follow' : 'noindex, follow'; header_remove( 'X-Robots-Tag' ); header( 'X-Robots-Tag: ' . $value, true ); return; } if ( ! empty( $settings['robots_txt_header_fix'] ) && $is_robots ) { header_remove( 'X-Robots-Tag' ); header( 'X-Robots-Tag: index, follow', true ); } } /** * Whether a request path is this site's /robots.txt, home-subdirectory aware. * * @param string $request_uri Raw request URI. * @return bool */ private static function is_robots_txt_path( $request_uri ) { $path = '/' . ltrim( (string) $request_uri, '/' ); $path = strtok( $path, '?' ); $path = rawurldecode( (string) $path ); $home_path = wp_parse_url( home_url( '/' ), PHP_URL_PATH ); $home_path = $home_path ? rtrim( $home_path, '/' ) : ''; if ( $home_path && 0 === strpos( $path, $home_path . '/' ) ) { $path = substr( $path, strlen( $home_path ) ); $path = '/' . ltrim( $path, '/' ); } return '/robots.txt' === $path; } /** * Build llms.txt or llms-full.txt content. * * @param bool $full Whether to generate the expanded file. * @param array|null $settings Optional settings override. * @return string */ public static function generate_guidance_content( $full = false, $settings = null ) { $settings = is_array( $settings ) ? $settings : self::settings(); $cache_key = self::guidance_cache_key( $full ); $cached = get_transient( $cache_key ); if ( false !== $cached ) { return (string) $cached; } $site_name = self::clean_text( get_bloginfo( 'name' ) ); if ( '' === $site_name ) { $site_name = wp_parse_url( home_url(), PHP_URL_HOST ); } $tagline = self::clean_text( get_bloginfo( 'description' ) ); $custom_summary = self::clean_text( isset( $settings['llms_generator_site_summary'] ) ? $settings['llms_generator_site_summary'] : '' ); $summary = $custom_summary ? $custom_summary : $tagline; $home = home_url( '/' ); $urls = self::guidance_urls(); $max_items = min( 200, max( 5, (int) $settings['llms_generator_max_items'] ) ); $items = self::guidance_items( $settings, $max_items ); $lines = array(); $lines[] = '# ' . $site_name . ( $full ? ' — Full AI Guidance' : '' ); $lines[] = ''; if ( $summary ) { $lines[] = '> ' . $summary; $lines[] = ''; } $lines[] = 'Official site: ' . $home; $lines[] = 'Generated: ' . gmdate( 'Y-m-d H:i:s' ) . ' UTC'; $lines[] = ''; $lines[] = '## Purpose'; $lines[] = $full ? 'This file gives AI assistants a fuller Markdown-readable overview of public site content.' : 'This file gives AI assistants a concise map of the site and links to useful public content.'; $lines[] = ''; if ( ! $full && ! empty( $settings['llms_generate_full'] ) ) { $lines[] = '## Expanded file'; $lines[] = '- [llms-full.txt](' . $urls['llms_full_txt'] . ') — fuller Markdown-readable content export.'; $lines[] = ''; } $lines[] = '## Primary URLs'; $lines[] = '- [Home](' . $home . ')'; $blog_url = get_post_type_archive_link( 'post' ); if ( $blog_url ) { $lines[] = '- [Posts](' . esc_url_raw( $blog_url ) . ')'; } $lines[] = '- [Sitemap](' . home_url( '/sitemap.xml' ) . ')'; $lines[] = ''; if ( ! empty( $settings['llms_generator_include_categories'] ) ) { $category_lines = self::category_lines(); if ( ! empty( $category_lines ) ) { $lines[] = '## Topics'; $lines = array_merge( $lines, $category_lines ); $lines[] = ''; } } if ( empty( $items ) ) { $lines[] = '## Public content'; $lines[] = '- No eligible public posts or pages were found.'; $lines[] = ''; } elseif ( $full ) { $lines[] = '## Content index'; foreach ( $items as $item ) { $lines[] = '- [' . $item['title'] . '](' . $item['url'] . ') — ' . $item['type_label']; } $lines[] = ''; $lines[] = '## Public content details'; foreach ( $items as $item ) { $lines[] = '### ' . $item['title']; $lines[] = ''; $lines[] = 'URL: ' . $item['url']; $lines[] = 'Type: ' . $item['type_label']; if ( ! empty( $item['modified'] ) ) { $lines[] = 'Updated: ' . $item['modified']; } if ( ! empty( $item['excerpt'] ) ) { $lines[] = 'Summary: ' . $item['excerpt']; } if ( ! empty( $item['body'] ) ) { $lines[] = ''; $lines[] = $item['body']; } $lines[] = ''; } } else { $lines[] = '## Public content'; foreach ( $items as $item ) { $line = '- [' . $item['title'] . '](' . $item['url'] . ')'; if ( ! empty( $item['excerpt'] ) ) { $line .= ' — ' . $item['excerpt']; } $lines[] = $line; } $lines[] = ''; } $lines[] = '## Notes for AI assistants'; $lines[] = '- Prefer the canonical URLs listed here when referencing this site.'; $lines[] = '- Do not use admin, login, preview, cart, checkout or account URLs as public sources.'; $lines[] = '- Respect robots.txt, noindex metadata and page-level access restrictions.'; $lines[] = ''; $content = self::normalise_line_endings( implode( "\n", $lines ) ); set_transient( $cache_key, $content, DAY_IN_SECONDS ); return $content; } /** * Flush cached generated guidance files. */ public static function flush_guidance_cache() { delete_transient( self::guidance_cache_key( false ) ); delete_transient( self::guidance_cache_key( true ) ); } /** * Cache key for generated guidance content. * * @param bool $full Whether the expanded file is requested. * @return string */ private static function guidance_cache_key( $full ) { return self::GUIDANCE_CACHE_KEY . ( $full ? 'full' : 'basic' ); } /** * Get eligible guidance items. * * @param array $settings Settings. * @param int $max_items Maximum items. * @return array */ private static function guidance_items( $settings, $max_items ) { $post_types = isset( $settings['llms_generator_post_types'] ) && is_array( $settings['llms_generator_post_types'] ) ? $settings['llms_generator_post_types'] : array( 'page', 'post' ); $post_types = array_values( array_intersect( self::public_post_type_names(), array_map( 'sanitize_key', $post_types ) ) ); if ( empty( $post_types ) ) { $post_types = array( 'page', 'post' ); } $query = new WP_Query( array( 'post_type' => $post_types, 'post_status' => 'publish', 'posts_per_page' => $max_items, 'orderby' => array( 'menu_order' => 'ASC', 'modified' => 'DESC' ), 'ignore_sticky_posts' => true, 'no_found_rows' => true, 'update_post_meta_cache' => true, 'update_post_term_cache' => false, ) ); $items = array(); if ( empty( $query->posts ) ) { return $items; } foreach ( $query->posts as $post ) { if ( ! $post instanceof WP_Post ) { continue; } if ( post_password_required( $post ) || ! empty( $post->post_password ) ) { continue; } if ( ! empty( $settings['llms_generator_respect_noindex'] ) && self::post_has_noindex( $post->ID ) ) { continue; } $url = get_permalink( $post ); if ( ! $url || self::looks_private_or_admin_url( $url ) ) { continue; } $title = self::markdown_inline_text( get_the_title( $post ) ); if ( '' === $title ) { $title = '(untitled ' . $post->ID . ')'; } $excerpt = self::post_excerpt( $post ); $body = self::post_body_excerpt( $post, 3200 ); $post_type_object = get_post_type_object( $post->post_type ); $type_label = $post_type_object && ! empty( $post_type_object->labels->singular_name ) ? $post_type_object->labels->singular_name : $post->post_type; $items[] = array( 'id' => $post->ID, 'title' => $title, 'url' => esc_url_raw( $url ), 'type_label' => self::markdown_inline_text( $type_label ), 'excerpt' => $excerpt, 'body' => $body, 'modified' => get_post_modified_time( 'Y-m-d', true, $post ), ); } return $items; } /** * Category lines for llms files. * * @return array */ private static function category_lines() { $terms = get_terms( array( 'taxonomy' => 'category', 'hide_empty' => true, 'number' => 50, ) ); if ( is_wp_error( $terms ) || empty( $terms ) ) { return array(); } $lines = array(); foreach ( $terms as $term ) { $url = get_term_link( $term ); if ( is_wp_error( $url ) || ! $url ) { continue; } $name = self::markdown_inline_text( $term->name ); $desc = self::clean_text( term_description( $term ) ); $line = '- [' . $name . '](' . esc_url_raw( $url ) . ')'; if ( $desc ) { $line .= ' — ' . self::trim_to_words( $desc, 24 ); } $lines[] = $line; } return $lines; } /** * List public post type names suitable for guidance files. * * Protected (not private): the admin-only settings screen in * AI_O_Optimiser_LLMS_Admin (class-ai-o-optimiser-llms-admin.php) also needs this to validate * the saved content-source selection. * * @return array */ protected static function public_post_type_names() { $post_types = get_post_types( array( 'public' => true ), 'names' ); unset( $post_types['attachment'] ); return array_values( $post_types ); } /** * Build a concise post excerpt. * * @param WP_Post $post Post. * @return string */ private static function post_excerpt( WP_Post $post ) { $raw = has_excerpt( $post ) ? $post->post_excerpt : $post->post_content; $text = self::clean_post_text( $raw ); return self::trim_to_words( $text, 28 ); } /** * Build expanded body excerpt for llms-full.txt. * * @param WP_Post $post Post. * @param int $max_chars Max characters. * @return string */ private static function post_body_excerpt( WP_Post $post, $max_chars = 3200 ) { $text = self::clean_post_text( $post->post_content ); if ( '' === $text ) { return ''; } if ( strlen( $text ) <= $max_chars ) { return $text; } $text = substr( $text, 0, $max_chars ); $text = preg_replace( '/\s+\S*$/', '', $text ); return rtrim( $text ) . '…'; } /** * Clean post content into plain Markdown-friendly text. * * @param string $text Raw content. * @return string */ private static function clean_post_text( $text ) { $text = strip_shortcodes( (string) $text ); $text = do_blocks( $text ); $text = wp_strip_all_tags( $text ); return self::clean_text( $text ); } /** * Clean generic text. * * @param string $text Raw text. * @return string */ private static function clean_text( $text ) { $text = html_entity_decode( wp_strip_all_tags( (string) $text ), ENT_QUOTES, get_bloginfo( 'charset' ) ); $text = str_replace( array( "\r\n", "\r" ), "\n", $text ); $text = preg_replace( '/[ \t]+/', ' ', $text ); $text = preg_replace( '/\n{3,}/', "\n\n", $text ); return trim( $text ); } /** * Clean single-line text for Markdown list/heading use. * * @param string $text Raw text. * @return string */ private static function markdown_inline_text( $text ) { $text = self::clean_text( $text ); $text = str_replace( array( '[', ']', '(', ')' ), array( '\\[', '\\]', '\\(', '\\)' ), $text ); $text = preg_replace( '/\s+/', ' ', $text ); return trim( $text ); } /** * Trim text to a word count. * * @param string $text Text. * @param int $words Max words. * @return string */ private static function trim_to_words( $text, $words ) { $text = self::clean_text( $text ); if ( '' === $text ) { return ''; } $trimmed = wp_trim_words( $text, max( 1, (int) $words ), '…' ); return self::markdown_inline_text( $trimmed ); } /** * Check common noindex metadata for an internal URL's post. * * Protected (not private): also used by the admin-only audit engine in * AI_O_Optimiser_LLMS_Admin to flag noindex internal links. * * @param int $post_id Post ID. * @return bool */ protected static function post_has_noindex( $post_id ) { $rank_math = get_post_meta( $post_id, 'rank_math_robots', true ); if ( is_array( $rank_math ) && in_array( 'noindex', $rank_math, true ) ) { return true; } if ( is_string( $rank_math ) && false !== stripos( $rank_math, 'noindex' ) ) { return true; } $yoast = get_post_meta( $post_id, '_yoast_wpseo_meta-robots-noindex', true ); if ( '1' === (string) $yoast ) { return true; } $aioseo = get_post_meta( $post_id, '_aioseo_robots_noindex', true ); if ( '1' === (string) $aioseo || 'on' === (string) $aioseo ) { return true; } return false; } /** * Detect URLs that should not normally be included in llms.txt. * * Protected (not private): also used by the admin-only audit engine in * AI_O_Optimiser_LLMS_Admin to flag private/admin-looking internal links. * * @param string $url URL. * @return bool */ protected static function looks_private_or_admin_url( $url ) { $path = (string) wp_parse_url( $url, PHP_URL_PATH ); $query = (string) wp_parse_url( $url, PHP_URL_QUERY ); $all = $path . '?' . $query; $patterns = array( '/wp-admin', '/wp-login.php', 'preview=true', 'preview_id=', 'action=edit', '_wpnonce=', 'customize_changeset_uuid=', '/cart', '/checkout', '/my-account', ); foreach ( $patterns as $pattern ) { if ( false !== stripos( $all, $pattern ) ) { return true; } } return false; } /** * Normalise text line endings. * * Protected (not private): also used by the admin-only audit engine in * AI_O_Optimiser_LLMS_Admin when analysing a fetched guidance file's raw body. * * @param string $text Raw text. * @return string */ protected static function normalise_line_endings( $text ) { $text = str_replace( array( "\r\n", "\r" ), "\n", (string) $text ); return rtrim( $text ) . "\n"; } }