summary" line * became "> summary", apostrophes became "’"). */ public static function to_plain_text( string $body ): string { $body = wp_strip_all_tags( $body, false ); return html_entity_decode( $body, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); } public function maybe_serve(): void { $is_full = (int) get_query_var( self::QUERY_VAR_FULL ) === 1; $is_txt = (int) get_query_var( self::QUERY_VAR ) === 1; if ( ! $is_full && ! $is_txt ) { return; } $settings = AEOMATIC_Settings::get( 'llms_txt' ); if ( empty( $settings['enabled'] ) ) { status_header( 404 ); nocache_headers(); exit; } header( 'Content-Type: text/plain; charset=utf-8' ); header( 'X-Robots-Tag: noindex' ); header( 'X-Content-Type-Options: nosniff' ); header( 'Cache-Control: public, max-age=' . (int) self::CACHE_TTL ); // The endpoint is intentionally world-readable. Online validators and // AI clients fetch it via browser fetch(), which requires CORS. header( 'Access-Control-Allow-Origin: *' ); header( 'Access-Control-Allow-Methods: GET, HEAD, OPTIONS' ); if ( $is_full ) { AEOMATIC_Activity::bump( 'llms_full_serves' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- text/plain + nosniff; tags stripped in to_plain_text(). echo self::to_plain_text( self::cached_body( self::CACHE_KEY_FULL, fn(): string => $this->build_full_content( $settings ) ) ); exit; } AEOMATIC_Activity::bump( 'llms_serves' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- text/plain + nosniff; tags stripped in to_plain_text(). echo self::to_plain_text( self::cached_body( self::CACHE_KEY, fn(): string => $this->build_content( $settings ) ) ); exit; } /** * Build the llms.txt body. */ private function build_content( array $settings ): string { $site_name = get_bloginfo( 'name' ); $site_url = home_url( '/' ); $tagline = get_bloginfo( 'description' ); $out = '# ' . $site_name . "\n\n"; $summary = trim( (string) ( ! empty( $settings['description'] ) ? $settings['description'] : $tagline ), " \t\n\r\0\x0B" ); if ( '' !== $summary ) { $out .= '> ' . $summary . "\n\n"; } $out .= 'Site URL: ' . $site_url . "\n"; $out .= 'Full content: ' . home_url( '/llms-full.txt' ) . "\n\n"; // Key pages (those marked in primary nav menu). $key_pages = $this->get_key_pages(); if ( $key_pages ) { $out .= "## Key pages\n\n"; foreach ( $key_pages as $page ) { $out .= sprintf( "- [%s](%s)", $page['title'], $page['url'] ); if ( ! empty( $page['desc'] ) ) { $out .= ': ' . $page['desc']; } $out .= "\n"; } $out .= "\n"; } // Recent / featured content from every enabled post type. if ( ! empty( $settings['auto_include'] ) ) { $grouped = $this->get_recent_content_by_type(); foreach ( $grouped as $section ) { if ( empty( $section['posts'] ) ) { continue; } $out .= '## ' . $section['heading'] . "\n\n"; foreach ( $section['posts'] as $p ) { $out .= sprintf( "- [%s](%s)", $p['title'], $p['url'] ); if ( ! empty( $p['desc'] ) ) { $out .= ': ' . $p['desc']; } $out .= "\n"; } $out .= "\n"; } } // Extra URLs from settings. if ( ! empty( $settings['extra_urls'] ) ) { $urls = array_filter( array_map( 'trim', preg_split( '/\r\n|\r|\n/', $settings['extra_urls'] ) ) ); if ( $urls ) { $out .= "## Additional resources\n\n"; foreach ( $urls as $url ) { $out .= '- ' . esc_url( $url ) . "\n"; } $out .= "\n"; } } $out .= "## Ignore\n\n"; $out .= "- /wp-admin/\n"; $out .= "- /wp-login.php\n"; $out .= "- /wp-json/\n"; $out .= "- /cart/\n"; $out .= "- /checkout/\n"; $out .= "- /my-account/\n"; $out .= "- /feed/\n"; $out .= "- /xmlrpc.php\n\n"; $out .= '---' . "\n"; $out .= 'Generated by AEOmatic v' . AEOMATIC_VERSION . ' on ' . gmdate( 'Y-m-d' ) . "\n"; return $out; } /** * Build the llms-full.txt body — the full extracted text of every * published page and post (across enabled post types), one Markdown * `##` block per item. This is the AI-readable full-content companion * to the llms.txt index. */ private function build_full_content( array $settings ): string { $site_name = get_bloginfo( 'name' ); $site_url = home_url( '/' ); $tagline = get_bloginfo( 'description' ); $out = '# ' . $site_name . ' — ' . __( 'Full content', 'aeomatic' ) . "\n\n"; $out .= '> ' . ( ! empty( $settings['description'] ) ? $settings['description'] : $tagline ) . "\n\n"; $out .= 'Site URL: ' . $site_url . "\n"; $out .= 'Index: ' . home_url( '/llms.txt' ) . "\n\n"; $enabled = AEOMATIC_Post_Types::get_enabled_slugs(); $objects = AEOMATIC_Post_Types::get_public_post_types(); // Bounded separately from the llms.txt index: this body inlines every // item's extracted text, so it costs far more per item. $max_items = (int) apply_filters( 'aeomatic_llms_full_max_items', self::FULL_MAX_ITEMS_DEFAULT ); $truncated = array(); foreach ( $enabled as $slug ) { if ( ! isset( $objects[ $slug ] ) ) { continue; } $query = new WP_Query( array( 'post_type' => $slug, 'post_status' => 'publish', 'has_password' => false, // Protected posts stay out of llms.txt / llms-full.txt. 'posts_per_page' => $max_items, 'orderby' => 'date', 'order' => 'DESC', 'no_found_rows' => true, ) ); if ( $max_items > 0 && count( $query->posts ) >= $max_items ) { $truncated[] = $objects[ $slug ]->labels->name ?? $slug; } foreach ( $query->posts as $post ) { $out .= '## ' . wp_strip_all_tags( get_the_title( $post ) ) . "\n\n"; $out .= 'URL: ' . get_permalink( $post ) . "\n\n"; $extracted = AEOMATIC_Content_Extractor::extract( $post ); $text = trim( (string) ( $extracted['text'] ?? '' ) ); if ( '' === $text ) { $text = trim( wp_strip_all_tags( get_the_excerpt( $post ) ) ); } $out .= ( '' !== $text ? $text : __( '(No extractable text content.)', 'aeomatic' ) ) . "\n\n"; } } $out .= '---' . "\n"; if ( ! empty( $truncated ) ) { $out .= sprintf( /* translators: 1: per-type item limit, 2: comma-separated post type labels */ __( 'Note: capped at %1$d items per type. Truncated: %2$s.', 'aeomatic' ), $max_items, implode( ', ', $truncated ) ) . PHP_EOL; } $out .= 'Generated by AEOmatic v' . AEOMATIC_VERSION . ' on ' . gmdate( 'Y-m-d' ) . "\n"; return $out; } /** * Is this post listed in the llms.txt index? * * Read by AEOMATIC_Analyzer_Technical so the readiness score reflects the * inclusion rules the endpoint actually applies: the feature must be on, * auto-include must be on, the post type enabled, and the post itself * published and not password-protected. */ public static function is_post_included( WP_Post $post ): bool { $settings = (array) AEOMATIC_Settings::get( 'llms_txt' ); if ( empty( $settings['enabled'] ) || empty( $settings['auto_include'] ) ) { return false; } if ( 'publish' !== $post->post_status || '' !== (string) $post->post_password ) { return false; } return AEOMATIC_Post_Types::is_enabled( $post->post_type ); } /** * Get pages from the primary nav menu (best heuristic for "important pages"). */ private function get_key_pages(): array { $pages = array(); $locations = get_nav_menu_locations(); $menu_id = $locations['primary'] ?? ( reset( $locations ) ?: 0 ); if ( $menu_id ) { $items = wp_get_nav_menu_items( $menu_id ); if ( $items ) { foreach ( $items as $item ) { if ( '0' !== (string) $item->menu_item_parent ) { continue; // top-level only } $pages[] = array( 'title' => $item->title, 'url' => $item->url, 'desc' => $item->description, ); if ( count( $pages ) >= 10 ) { break; } } } } // Fallback: top-level published pages by menu_order. if ( empty( $pages ) ) { $wp_pages = get_pages( array( 'parent' => 0, 'sort_column' => 'menu_order,post_title', 'number' => 10, ) ); foreach ( (array) $wp_pages as $p ) { if ( '' !== (string) $p->post_password ) { continue; } $pages[] = array( 'title' => get_the_title( $p ), 'url' => get_permalink( $p ), 'desc' => wp_strip_all_tags( get_the_excerpt( $p ) ), ); } } return $pages; } /** * Get published items grouped by post type. Skips post types the user * has disabled in settings. Pages are included as their own "Pages" * section (in addition to the curated nav-menu key-pages list above). * * Lists every published item — there is no fixed cap. Sites with very * large archives can bound the list per type via the * `aeomatic_llms_max_items` filter (default -1 = unlimited). * * @return array */ private function get_recent_content_by_type(): array { $enabled = AEOMATIC_Post_Types::get_enabled_slugs(); $objects = AEOMATIC_Post_Types::get_public_post_types(); $out = array(); /** Per-type item limit. -1 = all published items (no cap). */ $max_items = (int) apply_filters( 'aeomatic_llms_max_items', -1 ); foreach ( $enabled as $slug ) { $obj = $objects[ $slug ] ?? null; if ( ! $obj ) { continue; } $query = new WP_Query( array( 'post_type' => $slug, 'post_status' => 'publish', 'has_password' => false, // Protected posts stay out of llms.txt / llms-full.txt. 'posts_per_page' => $max_items, 'orderby' => 'date', 'order' => 'DESC', 'no_found_rows' => true, ) ); if ( empty( $query->posts ) ) { continue; } $posts = array(); foreach ( $query->posts as $post ) { $posts[] = array( 'title' => get_the_title( $post ), 'url' => get_permalink( $post ), 'desc' => wp_strip_all_tags( get_the_excerpt( $post ) ), ); } $out[] = array( 'heading' => $obj->labels->name ?? ucfirst( $slug ), 'posts' => $posts, ); } return $out; } /** * Legacy helper kept for backward compatibility. */ private function get_recent_posts(): array { $query = new WP_Query( array( 'post_type' => 'post', 'post_status' => 'publish', 'has_password' => false, // Protected posts stay out of llms.txt / llms-full.txt. 'posts_per_page' => 25, 'orderby' => 'date', 'order' => 'DESC', 'no_found_rows' => true, ) ); $out = array(); foreach ( $query->posts as $post ) { $out[] = array( 'title' => get_the_title( $post ), 'url' => get_permalink( $post ), 'desc' => wp_strip_all_tags( get_the_excerpt( $post ) ), ); } return $out; } }