settings = $settings; $this->integrations = $integrations; } /** * Register pretty endpoints. * * @return void */ public function register_rewrite_rules() { add_rewrite_rule( '^llms\.txt$', 'index.php?agizoai_agentic_llms=1', 'top' ); add_rewrite_rule( '^llms-full\.txt$', 'index.php?agizoai_agentic_llms_full=1', 'top' ); } /** * Register query vars. * * @param array $vars Query vars. * @return array */ public function register_query_vars( $vars ) { $vars[] = 'agizoai_agentic_llms'; $vars[] = 'agizoai_agentic_llms_full'; return $vars; } /** * Append llms.txt reference to robots.txt when enabled. * * @param string $output robots.txt output. * @param bool $public Whether site discourages search engines. * @return string */ public function append_robots_txt( $output, $public ) { unset( $public ); if ( ! $this->settings->get( 'enabled' ) || ! $this->settings->get( 'add_robots_reference' ) ) { return $output; } $output .= "\n# AgizoAI Agentic Browsing Optimizer\n"; $output .= 'LLMs: ' . home_url( '/llms.txt' ) . "\n"; if ( $this->settings->get( 'full_enabled' ) ) { $output .= 'LLMs-Full: ' . home_url( '/llms-full.txt' ) . "\n"; } return $output; } /** * Render endpoint if requested. * * @return void */ public function maybe_render_endpoint() { $is_llms = (bool) get_query_var( 'agizoai_agentic_llms' ); $is_llms_full = (bool) get_query_var( 'agizoai_agentic_llms_full' ); if ( ! $is_llms && ! $is_llms_full ) { return; } if ( ! $this->settings->get( 'enabled' ) || ( $is_llms_full && ! $this->settings->get( 'full_enabled' ) ) ) { status_header( 404 ); nocache_headers(); header( 'Content-Type: text/plain; charset=utf-8' ); echo esc_html__( 'Not enabled.', 'agizoai-agentic-browsing-optimizer' ); exit; } $content = $is_llms_full ? $this->generate_full() : $this->generate(); status_header( 200 ); nocache_headers(); header( 'Content-Type: text/plain; charset=utf-8' ); header( 'X-AgizoAI-Agentic: ' . AGIZOAI_AGENTIC_VERSION ); echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Plain text endpoint generated and escaped by generator. exit; } /** * Clear cached generated content. * * @return void */ public function clear_cache() { delete_transient( 'agizoai_agentic_llms_txt' ); delete_transient( 'agizoai_agentic_llms_full_txt' ); $this->settings->update( 'last_generated', current_time( 'mysql' ) ); } /** * Generate standard llms.txt. * * @param bool $force Force no cache. * @return string */ public function generate( $force = false ) { if ( ! $force ) { $cached = get_transient( 'agizoai_agentic_llms_txt' ); if ( is_string( $cached ) && '' !== $cached ) { return $cached; } } $options = $this->settings->get_options(); $mode = $options['generation_mode']; if ( 'manual' === $mode ) { $content = $this->normalise_text( $options['manual_llms'] ); if ( '' === trim( $content ) ) { $content = $this->generate_auto( false ); } } elseif ( 'hybrid' === $mode ) { $content = $this->normalise_text( $options['manual_llms'] ); if ( '' !== trim( $content ) ) { $content .= "\n\n---\n\n"; } $content .= $this->generate_auto( false ); } else { $content = $this->generate_auto( false ); } $content = $this->normalise_text( $content ); set_transient( 'agizoai_agentic_llms_txt', $content, DAY_IN_SECONDS ); return $content; } /** * Generate llms-full.txt. * * @param bool $force Force no cache. * @return string */ public function generate_full( $force = false ) { if ( ! $force ) { $cached = get_transient( 'agizoai_agentic_llms_full_txt' ); if ( is_string( $cached ) && '' !== $cached ) { return $cached; } } $content = $this->generate_auto( true ); $content = $this->normalise_text( $content ); set_transient( 'agizoai_agentic_llms_full_txt', $content, DAY_IN_SECONDS ); return $content; } /** * Build automatic llms text. * * @param bool $full Whether full mode. * @return string */ private function generate_auto( $full = false ) { $options = $this->settings->get_options(); $site_name = get_bloginfo( 'name' ); $description = get_bloginfo( 'description' ); $language = get_bloginfo( 'language' ); $home_url = home_url( '/' ); $max_items = $full ? absint( $options['full_max_items'] ) : absint( $options['max_items'] ); $site_purpose = trim( (string) $options['site_purpose'] ); $site_type = isset( $options['site_type'] ) ? sanitize_key( $options['site_type'] ) : 'custom'; $custom_intro = trim( (string) $options['custom_intro'] ); $date = current_time( 'Y-m-d' ); $lines = array(); $lines[] = '# ' . $this->escape_markdown_line( $site_name ); if ( '' !== $description ) { $lines[] = '> ' . $this->escape_markdown_line( $description ); } $lines[] = ''; $lines[] = '## Site information'; $lines[] = '- Site URL: ' . esc_url_raw( $home_url ); $lines[] = '- Language: ' . $this->escape_markdown_line( $language ); $lines[] = '- Generated: ' . $this->escape_markdown_line( $date ); $lines[] = '- Generated by: AgizoAI Agentic Browsing Optimizer v' . AGIZOAI_AGENTIC_VERSION; $lines[] = '- Site type: ' . $this->escape_markdown_line( $site_type ); if ( '' !== $site_purpose ) { $lines[] = '- Site purpose: ' . $this->escape_markdown_line( $site_purpose ); } if ( '' !== $custom_intro ) { $lines[] = ''; $lines[] = '## Guidance for AI agents'; $lines[] = $this->paragraph( $custom_intro ); } $lines[] = ''; $lines[] = '## Key URLs'; $lines[] = '- [Home](' . esc_url_raw( $home_url ) . ')'; $page_for_posts = (int) get_option( 'page_for_posts' ); if ( $page_for_posts > 0 ) { $blog_url = get_permalink( $page_for_posts ); if ( $blog_url ) { $lines[] = '- [Blog](' . esc_url_raw( $blog_url ) . ')'; } } $privacy_url = get_privacy_policy_url(); if ( $privacy_url ) { $lines[] = '- [Privacy Policy](' . esc_url_raw( $privacy_url ) . ')'; } $sitemap_url = $this->get_sitemap_url(); if ( $sitemap_url ) { $lines[] = '- [XML Sitemap](' . esc_url_raw( $sitemap_url ) . ')'; } $terms = $this->get_terms_for_llms( $options['include_taxonomies'], $full ? 200 : 30 ); if ( ! empty( $terms ) ) { $lines[] = ''; $lines[] = '## Public sections'; foreach ( $terms as $term ) { $term_link = get_term_link( $term ); if ( is_wp_error( $term_link ) ) { continue; } $lines[] = '- [' . $this->escape_markdown_link_text( $term->name ) . '](' . esc_url_raw( $term_link ) . ') - ' . $this->escape_markdown_line( $term->taxonomy ); } } $posts = $this->get_posts_for_llms( $options['include_post_types'], $max_items, (bool) $options['include_private'] ); if ( ! empty( $posts ) ) { $lines[] = ''; $lines[] = $full ? '## Public content index' : '## Important and recent public content'; foreach ( $posts as $post ) { if ( $this->should_skip_post( $post ) ) { continue; } $title = get_the_title( $post ); $link = get_permalink( $post ); $type = get_post_type( $post ); $updated = get_the_modified_date( 'Y-m-d', $post ); if ( ! $link || '' === $title ) { continue; } $line = '- [' . $this->escape_markdown_link_text( $title ) . '](' . esc_url_raw( $link ) . ')'; $line .= ' - ' . $this->escape_markdown_line( $type ); if ( $updated ) { $line .= ', updated ' . $this->escape_markdown_line( $updated ); } $summary = $this->get_post_summary( $post ); if ( '' !== $summary ) { $line .= '. ' . $this->escape_markdown_line( $summary ); } $lines[] = $line; } } if ( ! empty( $options['webmcp_enabled'] ) && ! empty( $options['webmcp_form_metadata_enabled'] ) ) { $lines[] = ''; $lines[] = '## Public form and WebMCP notes'; $lines[] = '- Declarative WebMCP-style form metadata is enabled where supported by the plugin.'; $lines[] = '- Form submissions should be treated as state-changing actions and require explicit user confirmation.'; if ( ! empty( $options['webmcp_search_form_enabled'] ) ) { $lines[] = '- The public search form may be exposed as a read-only search_public_content-style tool.'; } } if ( ! empty( $options['accessibility_scanner_enabled'] ) ) { $lines[] = ''; $lines[] = '## Accessibility for agents'; $lines[] = '- The site is configured to scan public content for labels, names, alt text, link clarity and heading structure.'; $lines[] = '- Agents should prefer elements with clear accessible names and labelled fields when interpreting public pages.'; } if ( ! empty( $options['layout_scanner_enabled'] ) ) { $lines[] = ''; $lines[] = '## Layout stability for agents'; $lines[] = '- The site is configured to scan public content for media, embeds and ad slots that may cause layout shifts.'; $lines[] = '- Agents should account for ads, sticky bars, popups and late-loading media when interpreting visible page positions.'; if ( ! empty( $options['layout_helper_css_enabled'] ) && ! empty( $options['layout_reserve_ad_slots'] ) ) { $lines[] = '- Common ad-slot reservation helper CSS is enabled to reduce unexpected content movement.'; } } if ( ! empty( $options['agent_tools_enabled'] ) && ! empty( $options['agent_tools_rest_enabled'] ) ) { $lines[] = ''; $lines[] = '## Public read-only agent tools'; $lines[] = '- Tool registry: ' . esc_url_raw( rest_url( 'agizoai-agentic/v1/tools' ) ); $lines[] = '- Mode: public read-only. These endpoints are for discovery and public content retrieval only.'; $lines[] = '- Available tool families may include site information, public search, public content summaries, recent content, navigation menus, public sections and public WooCommerce product discovery when enabled.'; } if ( ! empty( $options['ecommerce_product_tools_enabled'] ) && post_type_exists( 'product' ) ) { $lines[] = ''; $lines[] = '## WooCommerce product-discovery notes'; $lines[] = '- Public read-only product discovery may be available at: ' . esc_url_raw( rest_url( 'agizoai-agentic/v1/products' ) ); $lines[] = '- Product tools are limited to published public product data such as titles, URLs, public prices, stock status, summaries, categories and images.'; $lines[] = '- Agents must not assume access to carts, checkout, accounts, orders, customers, addresses or payment actions.'; } if ( $this->integrations && ! empty( $options['integrations_enabled'] ) && ! empty( $options['integration_add_seo_notes_to_llms'] ) ) { $summary = $this->integrations->summarize_integrations(); $lines[] = ''; $lines[] = '## SEO metadata for agents'; $lines[] = '- SEO metadata integration is enabled for agent summaries and public discovery files.'; $lines[] = '- Enabled metadata sources: ' . $this->escape_markdown_line( implode( ', ', (array) $summary['enabled_sources'] ) ); $lines[] = '- Agents should prefer canonical URLs, public meta descriptions and index/follow hints when available.'; if ( ! empty( $options['integration_expose_seo_metadata_in_tools'] ) ) { $lines[] = '- Public read-only content tools may include SEO metadata fields for published content.'; } } $lines[] = ''; $lines[] = '## Agentic browsing notes'; $lines[] = '- Public content only is intended for LLM and AI-agent discovery.'; $lines[] = '- Private, draft, password-protected and noindex content should not be treated as public agent-facing content.'; $lines[] = '- For any action that changes site state, agents should request explicit user confirmation.'; return implode( "\n", $lines ); } /** * Get posts for llms output. * * @param array $post_types Post types. * @param int $limit Limit. * @param bool $include_private Include private content. * @return array */ private function get_posts_for_llms( array $post_types, $limit, $include_private = false ) { $post_status = $include_private ? array( 'publish', 'private' ) : array( 'publish' ); if ( empty( $post_types ) ) { $post_types = array( 'post', 'page' ); } $args = array( 'post_type' => $post_types, 'post_status' => $post_status, 'posts_per_page' => absint( $limit ), 'orderby' => 'modified', 'order' => 'DESC', 'no_found_rows' => true, 'ignore_sticky_posts' => true, 'update_post_meta_cache' => true, 'update_post_term_cache' => false, 'suppress_filters' => false, ); $posts = get_posts( $args ); return is_array( $posts ) ? $posts : array(); } /** * Get public terms. * * @param array $taxonomies Taxonomies. * @param int $limit Limit. * @return array */ private function get_terms_for_llms( array $taxonomies, $limit ) { if ( empty( $taxonomies ) ) { return array(); } $terms = get_terms( array( 'taxonomy' => $taxonomies, 'hide_empty' => true, 'number' => absint( $limit ), 'orderby' => 'count', 'order' => 'DESC', ) ); if ( is_wp_error( $terms ) || ! is_array( $terms ) ) { return array(); } return $terms; } /** * Determine if a post should be skipped. * * @param WP_Post $post Post object. * @return bool */ public function should_skip_post( WP_Post $post ) { if ( post_password_required( $post ) ) { return true; } if ( 'attachment' === $post->post_type && ! $this->settings->get( 'include_attachments' ) ) { return true; } if ( $this->settings->get( 'respect_noindex' ) && $this->is_noindex( $post->ID ) ) { return true; } /** * Filter whether a post should be skipped from llms.txt. * * @param bool $skip Whether to skip. * @param WP_Post $post Post object. */ return (bool) apply_filters( 'agizoai_agentic_skip_post', false, $post ); } /** * Basic noindex detection across common SEO plugin meta keys. * * @param int $post_id Post ID. * @return bool */ public function is_noindex( $post_id ) { $post_id = absint( $post_id ); $yoast = get_post_meta( $post_id, '_yoast_wpseo_meta-robots-noindex', true ); if ( '1' === (string) $yoast || 'yes' === strtolower( (string) $yoast ) ) { return true; } $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 !== strpos( strtolower( $rank_math ), 'noindex' ) ) { return true; } $aioseo = get_post_meta( $post_id, '_aioseo_robots_noindex', true ); if ( '1' === (string) $aioseo || 'on' === strtolower( (string) $aioseo ) ) { return true; } $seopress = get_post_meta( $post_id, '_seopress_robots_index', true ); if ( 'yes' === strtolower( (string) $seopress ) || '1' === (string) $seopress ) { return true; } $rsm = get_post_meta( $post_id, '_rsm_robots_noindex', true ); if ( '1' === (string) $rsm || 'yes' === strtolower( (string) $rsm ) ) { return true; } /** * Filter noindex detection. * * @param bool $is_noindex Whether post is noindex. * @param int $post_id Post ID. */ return (bool) apply_filters( 'agizoai_agentic_is_noindex', false, $post_id ); } /** * Return sitemap URL. * * @return string */ private function get_sitemap_url() { if ( function_exists( 'wp_sitemaps_get_server' ) ) { return home_url( '/wp-sitemap.xml' ); } return home_url( '/sitemap.xml' ); } /** * Get post summary. * * @param WP_Post $post Post object. * @return string */ private function get_post_summary( WP_Post $post ) { if ( $this->integrations && $this->settings->get( 'integrations_enabled', 1 ) && $this->settings->get( 'integration_use_seo_descriptions_in_llms', 1 ) ) { $metadata = $this->integrations->get_post_metadata( $post->ID ); if ( ! empty( $metadata['description'] ) ) { return trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( $metadata['description'], true ) ) ); } } $excerpt = has_excerpt( $post ) ? get_the_excerpt( $post ) : ''; if ( '' === trim( $excerpt ) ) { $content = strip_shortcodes( $post->post_content ); $content = wp_strip_all_tags( $content, true ); $excerpt = wp_trim_words( $content, 24, '…' ); } return trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( $excerpt, true ) ) ); } /** * Escape line for Markdown/plain text. * * @param string $value Value. * @return string */ private function escape_markdown_line( $value ) { $value = wp_strip_all_tags( (string) $value, true ); $value = html_entity_decode( $value, ENT_QUOTES, get_bloginfo( 'charset' ) ); $value = preg_replace( '/\s+/', ' ', $value ); return trim( $value ); } /** * Escape link text. * * @param string $value Value. * @return string */ private function escape_markdown_link_text( $value ) { $value = $this->escape_markdown_line( $value ); $value = str_replace( array( '[', ']' ), array( '\\[', '\\]' ), $value ); return $value; } /** * Clean paragraph text. * * @param string $value Value. * @return string */ private function paragraph( $value ) { $value = wp_strip_all_tags( (string) $value, true ); $value = html_entity_decode( $value, ENT_QUOTES, get_bloginfo( 'charset' ) ); $value = preg_replace( "/\n{3,}/", "\n\n", $value ); return trim( $value ); } /** * Normalise endpoint text. * * @param string $text Text. * @return string */ private function normalise_text( $text ) { $text = (string) $text; $text = str_replace( "\r\n", "\n", $text ); $text = str_replace( "\r", "\n", $text ); $text = trim( $text ); return $text . "\n"; } }