'llms.txt' ] ); if ( (bool) Options::get( 'llms.full_enabled', true ) ) { Router::add_file( 'llms_full', 'llms-full\.txt', 'text/plain', [ $this, 'render_full' ], [ 'path' => 'llms-full.txt' ] ); } add_action( 'save_post', [ $this, 'invalidate_post' ], 10, 2 ); add_action( 'deleted_post', [ $this, 'invalidate_post' ], 10, 2 ); add_action( 'transition_post_status', [ $this, 'invalidate_transition' ], 10, 3 ); add_action( 'acid_aeo_settings_updated', [ $this, 'on_settings_updated' ] ); } /** * Renders `/llms.txt`. * * @return string File body. */ public function render(): string { return ( new LlmsTxtGenerator() )->generate( $this->data() ); } /** * Renders `/llms-full.txt`. * * @return string File body. */ public function render_full(): string { return ( new LlmsTxtGenerator() )->generate_full( $this->data(), $this->documents(), (int) Options::get( 'llms.full_max_bytes', 2000000 ) ); } /** * Audit checks contributed by this module. * * @return array Checks. */ public function checks(): array { $slug = $this->slug(); $url = Router::url( 'llms' ); return [ Checks::reachable( 'llms_reachable', $slug, __( 'llms.txt is reachable', 'acid-aeo' ), $url, [ 'type' => 'text/plain', 'weight' => Check::WEIGHT_CRITICAL, 'fix' => 'flush_rewrite', ] ), Checks::observed( 'llms_has_links', $slug, __( 'llms.txt points somewhere', 'acid-aeo' ), $url, static fn( string $body ): bool => substr_count( $body, '](http' ) >= self::MIN_LINKS, [ 'ok' => __( 'The index links to enough of the site for an assistant to find its way around.', 'acid-aeo' ), 'bad' => __( 'llms.txt lists almost no links. Choose key pages, or raise the number of recent posts, so the file is worth fetching.', 'acid-aeo' ), 'status' => Check::WARN, 'weight' => Check::WEIGHT_IMPORTANT, ] ), Checks::observed( 'llms_size', $slug, __( 'llms.txt stays an index', 'acid-aeo' ), $url, static fn( string $body ): bool => strlen( $body ) < self::MAX_BYTES, [ 'ok' => __( 'The index is small enough to be read whole.', 'acid-aeo' ), 'bad' => __( 'llms.txt is over 100 KB. It is meant to be the map, not the territory: lower the number of listed posts and let llms-full.txt carry the text.', 'acid-aeo' ), 'status' => Check::WARN, 'weight' => Check::WEIGHT_NICE, ] ), $this->full_check(), $this->physical_conflict(), ]; } /** * Whether the full text file is served, when the site asked for one. * * @return Check The finding. */ private function full_check(): Check { $label = __( 'llms-full.txt is reachable', 'acid-aeo' ); if ( ! (bool) Options::get( 'llms.full_enabled', true ) ) { return Check::skip( 'llms_full_reachable', $this->slug(), $label, __( 'The full text file is switched off, so nothing is published at /llms-full.txt.', 'acid-aeo' ), Check::WEIGHT_IMPORTANT ); } return Checks::reachable( 'llms_full_reachable', $this->slug(), $label, Router::url( 'llms_full' ), [ 'type' => 'text/plain', 'weight' => Check::WEIGHT_IMPORTANT, 'fix' => 'flush_rewrite', ] ); } /** * Whether an llms.txt on disk is shadowing the generated one. * * @return Check The finding. */ private function physical_conflict(): Check { return Checks::physical_conflict( 'llms_physical_conflict', $this->slug(), 'llms.txt', 'llms.txt' ); } /** * Drops every cached file after a change that could alter them. * * @return void */ public function invalidate(): void { Cache::invalidate_all(); } /** * Drops the cache after a post changed, ignoring the noise around a save. * * An autosave, a revision and a post type that is not published anywhere * cannot change a file this plugin generates, and each of them would cost a * cache version bump on every keystroke the editor sends. * * @param mixed $post_id Post ID. * @param mixed $post Post object, as `save_post` and `deleted_post` pass it. * @return void */ public function invalidate_post( $post_id, $post = null ): void { if ( $this->is_listed_post( (int) $post_id, $post ) ) { $this->invalidate(); } } /** * Drops the cache when a post actually changed status. * * A save that leaves the status alone fires this hook too, and `save_post` * has already handled it, so only a real transition is acted on here. * * @param mixed $new_status Status the post moves to. * @param mixed $old_status Status the post had. * @param mixed $post Post object. * @return void */ public function invalidate_transition( $new_status, $old_status, $post = null ): void { if ( $new_status === $old_status ) { return; } $this->invalidate_post( is_object( $post ) && isset( $post->ID ) ? $post->ID : 0, $post ); } /** * The site summary both files are rendered from. * * @return array Input for `Generators\LlmsTxtGenerator`. */ public function data(): array { $identity = SiteData::identity(); $policy = SiteData::policy(); $data = [ 'name' => (string) ( $identity['name'] ?? '' ), 'tagline' => $this->tagline( $identity ), 'about' => (string) Options::get( 'llms.about_text', '' ), 'language' => (string) ( $identity['language'] ?? '' ), 'home_url' => (string) ( $identity['home_url'] ?? '' ), 'sections' => $this->sections(), 'offerings' => $this->offerings(), 'faq' => $this->faq(), 'contact' => [ 'email' => (string) ( $identity['email'] ?? '' ), 'phone' => (string) ( $identity['phone'] ?? '' ), 'address' => $this->address( $identity ), 'hours' => $this->hours( $identity ), ], 'policy' => $this->policy_block( $policy ), 'optional' => $this->optional_links(), 'labels' => [ 'offerings' => __( 'Services', 'acid-aeo' ), 'faq' => __( 'Frequently asked questions', 'acid-aeo' ), 'contact' => __( 'Contact', 'acid-aeo' ), 'policy' => __( 'AI usage policy', 'acid-aeo' ), ], 'generated' => gmdate( 'Y-m-d' ), 'generator' => 'Acid AEO ' . ACID_AEO_VERSION, ]; /** * Filters the site summary `llms.txt` and `llms-full.txt` are built from. * * @param array $data Site summary, as described by `Modules\LlmsTxt::data()`. */ $data = apply_filters( 'acid_aeo_llms_data', $data ); return is_array( $data ) ? $data : []; } /** * The AI usage policy section, while there is a policy to state. * * `policy.enabled` is the one switch for the whole policy surface: with it * off, robots.txt carries no AI rules, no `Content-Signal` header is sent * and `ai.txt` says as much — so a policy section here would be the one * place still claiming terms nothing else backs up. * * @param array $policy Policy from `Data\SiteData::policy()`. * @return array Section input for the generator, or an empty array. */ private function policy_block( array $policy ): array { if ( ! (bool) Options::get( 'policy.enabled', true ) ) { return []; } return [ 'preset' => (string) ( $policy['preset'] ?? '' ), 'content_signal' => SiteData::content_signal_string(), 'license_label' => (string) ( $policy['license_label'] ?? '' ), 'ai_txt_url' => null !== Router::file( 'ai_txt' ) ? (string) ( $policy['policy_url'] ?? '' ) : '', 'permissions_contact' => (string) ( $policy['permissions_contact'] ?? '' ), ]; } /** * The link sections of the index file, in reading order. * * @return array> Sections of `title` and `items`. */ private function sections(): array { $sections = [ [ 'title' => __( 'Key pages', 'acid-aeo' ), 'items' => $this->items( ContentData::key_pages() ), ], [ 'title' => __( 'Recent articles', 'acid-aeo' ), 'items' => $this->items( $this->recent_posts( (int) Options::get( 'llms.posts_limit', 20 ) ) ), ], ]; $taxonomies = Options::get( 'llms.taxonomies', [] ); foreach ( is_array( $taxonomies ) ? $taxonomies : [] as $taxonomy ) { $taxonomy = (string) $taxonomy; $terms = ContentData::taxonomy_terms( $taxonomy ); if ( [] === $terms ) { continue; } $sections[] = [ 'title' => $this->taxonomy_title( $taxonomy ), 'items' => $this->items( $terms ), ]; } return $sections; } /** * The most recent posts of the configured post types. * * @param int $limit Maximum number of rows. * @return array> Rows from `Data\ContentData`. */ private function recent_posts( int $limit ): array { $post_types = Options::get( 'llms.post_types', [] ); return ContentData::recent_posts( is_array( $post_types ) ? $post_types : [], $limit ); } /** * Turns content rows into link items. * * Taxonomy terms carry a `name` where posts carry a `title`, so both shapes * are read here and the generator sees one. * * @param array> $rows Rows from `Data\ContentData`. * @return array> Items of `title`, `url` and `summary`. */ private function items( array $rows ): array { $items = []; foreach ( $rows as $row ) { $items[] = [ 'title' => (string) ( $row['title'] ?? $row['name'] ?? '' ), 'url' => (string) ( $row['url'] ?? '' ), 'summary' => (string) ( $row['summary'] ?? '' ), ]; } return $items; } /** * The plural label of a taxonomy. * * @param string $taxonomy Taxonomy name. * @return string Label, or the taxonomy name when it is not registered. */ private function taxonomy_title( string $taxonomy ): string { $object = get_taxonomy( $taxonomy ); return false !== $object && '' !== (string) $object->label ? (string) $object->label : $taxonomy; } /** * What the site sells, when the settings say to publish it. * * @return array> Offering rows. */ private function offerings(): array { if ( ! (bool) Options::get( 'llms.include_offerings', true ) ) { return []; } $offerings = Options::get( 'offerings', [] ); return is_array( $offerings ) ? $offerings : []; } /** * The site wide FAQ followed by the FAQ of every key page. * * @return array> Rows of `q` and `a`. */ private function faq(): array { if ( ! (bool) Options::get( 'llms.include_faq', true ) ) { return []; } $faq = ContentData::global_faq(); foreach ( ContentData::key_pages() as $page ) { $faq = array_merge( $faq, ContentData::faq_for( (int) ( $page['id'] ?? 0 ) ) ); } return $faq; } /** * The site's postal address on one line. * * @param array $identity Identity from `Data\SiteData`. * @return string Address, empty when none is set. */ private function address( array $identity ): string { $address = is_array( $identity['address'] ?? null ) ? $identity['address'] : []; $parts = []; foreach ( [ 'street', 'postal', 'city', 'region', 'country' ] as $key ) { $value = trim( (string) ( $address[ $key ] ?? '' ) ); if ( '' !== $value ) { $parts[] = $value; } } return implode( ', ', $parts ); } /** * The site's opening hours on one line. * * @param array $identity Identity from `Data\SiteData`. * @return string Hours, empty when none are set. */ private function hours( array $identity ): string { $hours = is_array( $identity['opening_hours'] ?? null ) ? $identity['opening_hours'] : []; return implode( ', ', array_filter( array_map( 'trim', array_map( 'strval', $hours ) ) ) ); } /** * The one sentence that follows the site name. * * @param array $identity Identity from `Data\SiteData`. * @return string Tagline, falling back to the site description. */ private function tagline( array $identity ): string { $tagline = trim( (string) ( $identity['tagline'] ?? '' ) ); return '' !== $tagline ? $tagline : trim( (string) ( $identity['description'] ?? '' ) ); } /** * The secondary links that close the index file. * * A file this plugin publishes is linked only while it is registered, so a * switched off module leaves no dead link behind. * * @return array> Items of `title`, `url` and `summary`. */ private function optional_links(): array { $files = SiteData::file_urls(); $links = [ $this->link( (string) ( $files['feed'] ?? '' ), __( 'RSS feed', 'acid-aeo' ) ), $this->link( (string) ( $files['sitemap'] ?? '' ), __( 'XML sitemap', 'acid-aeo' ) ), $this->published_link( 'humans', $files, __( 'Credits and colophon (humans.txt)', 'acid-aeo' ) ), $this->published_link( 'ai_context', $files, __( 'Machine readable site profile (ai-context.json)', 'acid-aeo' ) ), $this->published_link( 'llms_full', $files, __( 'Full text of every listed page (llms-full.txt)', 'acid-aeo' ) ), $this->published_link( 'security', $files, __( 'Security contact (security.txt)', 'acid-aeo' ) ), $this->link( (string) get_privacy_policy_url(), __( 'Privacy policy', 'acid-aeo' ) ), ]; return array_values( array_filter( $links ) ); } /** * One optional link, dropped when it has no target. * * @param string $url Absolute URL. * @param string $title Translated link title. * @return array|null Item, or null when there is no URL. */ private function link( string $url, string $title ): ?array { if ( '' === $url ) { return null; } return [ 'title' => $title, 'url' => $url, 'summary' => '', ]; } /** * One optional link to a file this plugin publishes. * * @param string $slug Router slug, e.g. `humans`. * @param array $files URLs from `Data\SiteData::file_urls()`. * @param string $title Translated link title. * @return array|null Item, or null when the file is not published. */ private function published_link( string $slug, array $files, string $title ): ?array { if ( null === Router::file( $slug ) ) { return null; } return $this->link( (string) ( $files[ $slug ] ?? '' ), $title ); } /** * The documents the full text file carries, key pages first. * * A page listed twice is rendered once, and a page whose content converts to * nothing is left out entirely. * * @return array> Rows for `generate_full()`. */ private function documents(): array { $rows = array_merge( ContentData::key_pages(), $this->recent_posts( self::full_documents() ) ); $documents = []; $seen = []; foreach ( $rows as $row ) { $post_id = (int) ( $row['id'] ?? 0 ); if ( $post_id <= 0 || isset( $seen[ $post_id ] ) ) { continue; } $seen[ $post_id ] = true; $markdown = ContentData::markdown_for( $post_id ); if ( '' === $markdown ) { continue; } $documents[] = [ 'title' => (string) ( $row['title'] ?? '' ), 'url' => (string) ( $row['url'] ?? '' ), 'date' => (string) ( $row['date'] ?? '' ), 'modified' => (string) ( $row['modified'] ?? '' ), 'author' => (string) ( $row['author_name'] ?? '' ), 'markdown' => $markdown, ]; } return $documents; } }