self::build_compact( $settings, $pages, $posts ), 'llms-full.txt' => self::build_full( $settings, $pages, $posts ), ]; } /** * Generate both llms.txt and llms-full.txt. * * The cache is filled before the files are written, so the dynamic answer * is never older than the file next to it. * * @return bool True when both files were written. The caller needs to know: * a rebuild that silently failed leaves the previous version's * file, including any password-protected content it carried, * in place. */ public static function generate( array $settings ): bool { $built = self::build( $settings ); self::remember( $built ); $compact_ok = AI_Ready_WP::write_file( AIREWP_ROOT_PATH . 'llms.txt', $built['llms.txt'] ); $full_ok = AI_Ready_WP::write_file( AIREWP_ROOT_PATH . 'llms-full.txt', $built['llms-full.txt'] ); // Both writes always run; no short-circuit. return $compact_ok && $full_ok; } /** * The document as served without a file: from the cache, or built now. * * A cache written by another version of the plugin is not trusted; the * format may have changed with it. * * @param string $which 'llms.txt' or 'llms-full.txt'. */ public static function cached( string $which, array $settings ): string { $cache = get_transient( self::CACHE ); if ( is_array( $cache ) && isset( $cache[ $which ] ) && AIREWP_VERSION === ( $cache['version'] ?? '' ) ) { return (string) $cache[ $which ]; } $built = self::build( $settings ); self::remember( $built ); return $built[ $which ] ?? ''; } /** * Drop the cached documents, for when the feature is switched off. */ public static function forget(): void { delete_transient( self::CACHE ); } private static function remember( array $built ): void { set_transient( self::CACHE, $built + [ 'version' => AIREWP_VERSION, 'built_at' => time() ], self::CACHE_TTL ); } /** * Build compact llms.txt. */ private static function build_compact( array $settings, array $pages, array $posts ): string { $lines = []; // Header — fall back to site name/tagline, skip the blockquote when // there is no description at all (a bare "> " line is invalid llms.txt). $name = $settings['company_name'] ?: get_bloginfo( 'name' ); $desc = $settings['company_desc'] ?: get_bloginfo( 'description' ); $lines[] = '# ' . $name; $lines[] = ''; if ( '' !== trim( (string) $desc ) ) { $lines[] = '> ' . $desc; $lines[] = ''; } // Services if ( ! empty( $settings['services'] ) ) { $lines[] = '## Services'; $lines[] = ''; $services = array_map( 'trim', explode( ',', $settings['services'] ) ); foreach ( $services as $service ) { $lines[] = '- ' . $service; } $lines[] = ''; } // Pages if ( ! empty( $pages ) ) { $lines[] = '## Pages'; $lines[] = ''; foreach ( $pages as $page ) { $lines[] = sprintf( '- [%s](%s)', $page['title'], $page['url'] ); } $lines[] = ''; } // Posts (latest 20) if ( ! empty( $posts ) ) { $lines[] = '## Articles'; $lines[] = ''; $recent = array_slice( $posts, 0, 20 ); foreach ( $recent as $post ) { $lines[] = sprintf( '- [%s](%s)', $post['title'], $post['url'] ); } if ( count( $posts ) > 20 ) { $lines[] = sprintf( '- ... and %d more articles', count( $posts ) - 20 ); } $lines[] = ''; } // Contact $lines[] = '## Contact'; $lines[] = ''; if ( ! empty( $settings['company_email'] ) ) { $lines[] = '- Email: ' . $settings['company_email']; } if ( ! empty( $settings['company_phone'] ) ) { $lines[] = '- Phone: ' . $settings['company_phone']; } $lines[] = '- Website: ' . home_url(); $lines[] = ''; // AI-discovery pointers — only advertise endpoints that are actually // enabled, otherwise llms.txt links to 404s on default installs. $lines[] = '## AI Discovery'; $lines[] = ''; if ( ! empty( $settings['enable_agents_json'] ) ) { $lines[] = sprintf( '- [agents.json](%s) — AI Agent Tool Discovery', home_url( '/.well-known/agents.json' ) ); } if ( ! empty( $settings['enable_agent_card'] ) ) { $lines[] = sprintf( '- [agent-card.json](%s) — A2A Protocol v0.3', home_url( '/.well-known/agent-card.json' ) ); $lines[] = sprintf( '- [agent.json](%s) — A2A v0.3 (canonical)', home_url( '/.well-known/agent.json' ) ); } if ( ! empty( $settings['enable_mcp_json'] ) ) { $lines[] = sprintf( '- [mcp.json](%s) — MCP Server Discovery', home_url( '/.well-known/mcp.json' ) ); } if ( ! empty( $settings['enable_rest_api'] ) ) { $lines[] = sprintf( '- [REST API](%s) — AI agent endpoints', home_url( '/wp-json/airewp/v1/' ) ); } $lines[] = sprintf( '- [llms-full.txt](%s) — Full content version', home_url( '/llms-full.txt' ) ); $lines[] = ''; // Footer $lines[] = '---'; $lines[] = sprintf( 'Generated by AI-Ready WP %s | %s', AIREWP_VERSION, gmdate( 'Y-m-d' ) ); return implode( "\n", $lines ); } /** * Build full llms-full.txt with content. */ private static function build_full( array $settings, array $pages, array $posts ): string { $lines = []; // Header — same fallback rules as build_compact(). $name = $settings['company_name'] ?: get_bloginfo( 'name' ); $desc = $settings['company_desc'] ?: get_bloginfo( 'description' ); $lines[] = '# ' . $name; $lines[] = ''; if ( '' !== trim( (string) $desc ) ) { $lines[] = '> ' . $desc; $lines[] = ''; } // Services with descriptions if ( ! empty( $settings['services'] ) ) { $lines[] = '## Services'; $lines[] = ''; $services = array_map( 'trim', explode( ',', $settings['services'] ) ); foreach ( $services as $service ) { $lines[] = '### ' . $service; $lines[] = ''; } } // Pages with full content if ( ! empty( $pages ) ) { $lines[] = '## Pages'; $lines[] = ''; foreach ( $pages as $page ) { $lines[] = '### ' . $page['title']; $lines[] = ''; $lines[] = 'URL: ' . $page['url']; $lines[] = ''; if ( ! empty( $page['excerpt'] ) ) { $lines[] = $page['excerpt']; $lines[] = ''; } if ( ! empty( $page['content'] ) ) { // to_plain_text(), not wp_strip_all_tags(): the latter // leaves shortcodes standing, so every page built with a // page builder used to land here as its raw shortcode tree. $content = AI_Ready_WP::to_plain_text( $page['content'] ); // Limit content to prevent massive files if ( strlen( $content ) > 2000 ) { $content = substr( $content, 0, 2000 ) . '...'; } $lines[] = $content; $lines[] = ''; } } } // Posts with excerpts if ( ! empty( $posts ) ) { $lines[] = '## Articles'; $lines[] = ''; foreach ( $posts as $post ) { $lines[] = '### ' . $post['title']; $lines[] = ''; $lines[] = 'URL: ' . $post['url']; if ( ! empty( $post['date'] ) ) { $lines[] = 'Date: ' . $post['date']; } $lines[] = ''; if ( ! empty( $post['excerpt'] ) ) { $lines[] = $post['excerpt']; $lines[] = ''; } } } // Contact $lines[] = '## Contact'; $lines[] = ''; if ( ! empty( $settings['company_name'] ) ) { $lines[] = '- Company: ' . $settings['company_name']; } if ( ! empty( $settings['company_email'] ) ) { $lines[] = '- Email: ' . $settings['company_email']; } if ( ! empty( $settings['company_phone'] ) ) { $lines[] = '- Phone: ' . $settings['company_phone']; } if ( ! empty( $settings['company_address'] ) ) { $addr = $settings['company_address']; if ( ! empty( $settings['company_zip'] ) ) { $addr .= ', ' . $settings['company_zip']; } if ( ! empty( $settings['company_city'] ) ) { $addr .= ' ' . $settings['company_city']; } if ( ! empty( $settings['company_country'] ) ) { $addr .= ', ' . $settings['company_country']; } $lines[] = '- Address: ' . $addr; } $lines[] = '- Website: ' . home_url(); $lines[] = ''; // Footer $lines[] = '---'; $lines[] = sprintf( 'Generated by AI-Ready WP %s | %s', AIREWP_VERSION, gmdate( 'Y-m-d' ) ); return implode( "\n", $lines ); } /** * Get all public pages. * * 'has_password' => false is what keeps locked pages out of llms-full.txt. * That file used to carry $post->post_content of password-protected pages * verbatim (2000 characters of it), and llms-full.txt is a static file that * anyone can fetch. The check cannot be post_password_required() here: * generation runs for whoever saved a post or for WP-Cron, so it would ask * "does this session know the password" — the wrong question for a file * that is served to the whole world. */ private static function get_public_pages(): array { $query = new WP_Query( [ 'post_type' => 'page', 'post_status' => 'publish', 'has_password' => false, 'posts_per_page' => 100, 'orderby' => 'menu_order', 'order' => 'ASC', ] ); $pages = []; foreach ( $query->posts as $post ) { if ( ! self::is_publishable( $post ) ) { continue; } $pages[] = [ 'title' => $post->post_title, 'url' => get_permalink( $post ), // The excerpt needs the same cleaning as the content, and for a // post it is the ONLY text that reaches llms-full.txt. Core's // wp_trim_excerpt() calls strip_shortcodes(), which does nothing // when the page builder is not loaded, so the raw tree came // through here even after the content path was fixed. 'excerpt' => AI_Ready_WP::to_plain_text( get_the_excerpt( $post ) ), 'content' => $post->post_content, ]; } wp_reset_postdata(); return $pages; } /** * Get all public posts. */ private static function get_public_posts(): array { $query = new WP_Query( [ 'post_type' => 'post', 'post_status' => 'publish', 'has_password' => false, 'posts_per_page' => 100, 'orderby' => 'date', 'order' => 'DESC', ] ); $posts = []; foreach ( $query->posts as $post ) { if ( ! self::is_publishable( $post ) ) { continue; } $posts[] = [ 'title' => $post->post_title, 'url' => get_permalink( $post ), 'date' => get_the_date( 'Y-m-d', $post ), 'excerpt' => AI_Ready_WP::to_plain_text( get_the_excerpt( $post ) ), ]; } wp_reset_postdata(); return $posts; } /** * Second gate, right where the content is read. * * The query argument above is the real filter; this one guards the case * where another plugin rewrites the query through pre_get_posts or * posts_where and the condition silently disappears. Nothing that carries * a password reaches the generated files. */ private static function is_publishable( $post ): bool { return $post instanceof WP_Post && 'publish' === $post->post_status && '' === (string) $post->post_password; } }