<?php
/**
* The llms.txt module.
*
* @package RankMath
* @subpackage RankMath\LLMS
*/
namespace RankMath\LLMS;
use RankMath\Helper;
use RankMath\Traits\Hooker;
use RankMath\Helpers\Arr;
use RankMath\Sitemap\Sitemap;
use RankMath\Sitemap\Router;
use RankMath\Helpers\Url;
use WP_Query;
defined( 'ABSPATH' ) || exit;
/**
* LLMS_Txt class.
*/
class LLMS_Txt {
use Hooker;
/**
* Class constructor.
*
* Registers hooks and filters for the llms.txt module.
*/
public function __construct() {
$this->action( 'init', 'add_rewrite_rule' );
$this->action( 'template_redirect', 'maybe_serve_llms_txt' );
$this->filter( 'rank_math/settings/general', 'add_settings' );
$this->action( 'wp_loaded', 'remove_canonical_redirect' );
}
/**
* Remove the canonical redirect for the llms.txt file.
*
* @hook wp_loaded
* @return void
*/
public function remove_canonical_redirect() {
if ( strpos( Url::get_current_url(), '/llms.txt' ) !== false ) {
remove_filter( 'template_redirect', 'redirect_canonical' );
}
}
/**
* Add the llms.txt settings tab to the General settings panel.
*
* @hook rank_math/settings/general
*
* @param array $tabs Option panel tabs.
* @return array Modified tabs array with llms.txt tab added.
*/
public function add_settings( $tabs ) {
Arr::insert(
$tabs,
[
'llms' => [
'icon' => 'rm-icon rm-icon-bot',
'title' => esc_html__( 'Edit llms.txt', 'rank-math' ),
'desc' => esc_html__( 'Configure your llms.txt file for custom crawling/indexing rules.', 'rank-math' ),
'file' => __DIR__ . '/options.php',
'classes' => 'rank-math-advanced-option',
'json' => [
'llmsUrl' => esc_url( home_url( '/llms.txt' ) ),
],
],
],
5
);
return $tabs;
}
/**
* Add the rewrite rule and query var for llms.txt.
*
* @hook init
* @return void
*/
public function add_rewrite_rule() {
add_rewrite_rule( '^llms\.txt$', 'index.php?llms_txt=1', 'top' );
add_rewrite_tag( '%llms_txt%', '1' );
}
/**
* Serve the llms.txt file if the endpoint is hit.
*
* @hook template_redirect
* @return void
*/
public function maybe_serve_llms_txt() {
if ( intval( get_query_var( 'llms_txt' ) ) !== 1 ) {
return;
}
if ( substr( Url::get_current_url(), -1 ) === '/' ) {
wp_safe_redirect( home_url( '/llms.txt' ) );
exit;
}
$this->output();
exit;
}
/**
* Output the llms.txt file content in Markdown format.
*
* @action rank_math/llms_txt/before_output Fires before llms.txt output is sent.
* @action rank_math/llms_txt/after_output Fires after llms.txt output is sent.
* @filter rank_math/llms_txt/posts_query_args Filter WP_Query args for posts.
* @filter rank_math/llms_txt/posts Filter post IDs to include.
* @filter rank_math/llms_txt/terms Filter term IDs to include.
* @filter rank_math/llms_txt/extra_content Filter extra content output.
*
* @return void
*/
public function output() {
if ( headers_sent() ) {
return;
}
header( 'Content-Type: text/plain; charset=utf-8' );
header( 'X-Robots-Tag: noindex, nofollow', true );
/**
* Fires before the llms.txt output is sent to the browser.
*
* @since 1.0.250
*/
$this->do_action( 'llms_txt/before_output' );
$limit = absint( Helper::get_settings( 'general.llms_limit', 100 ) );
$this->add_header_content();
$this->add_post_types_data( $limit );
$this->add_taxonomies_data( $limit );
$this->add_extra_content();
/**
* Fires after the llms.txt output is sent to the browser.
*
* @since 1.0.250
*/
$this->do_action( 'llms_txt/after_output' );
}
/**
* Adds header content to the llms.txt output.
*
* @return void
*/
private function add_header_content() {
$site_title = Helper::get_settings( 'titles.knowledgegraph_name', get_bloginfo( 'name' ) );
$site_desc = Helper::get_settings( 'titles.organization_description', get_bloginfo( 'description' ) );
$site_title .= $site_desc ? ': ' . $site_desc : '';
$this->output_line( 'Generated by Rank Math SEO, this is an llms.txt file designed to help LLMs better understand and index this website.' );
$this->output_line( '' );
$this->output_line( '# ' . esc_html( $site_title ) );
if ( ! Helper::is_module_active( 'sitemap' ) ) {
return;
}
$this->output_line( '' );
$sitemap_url = Router::get_base_url( Sitemap::get_sitemap_index_slug() . '.xml' );
$this->output_line( '## Sitemaps' );
$this->output_line( '[XML Sitemap](' . esc_url( $sitemap_url ) . '): Includes all crawlable and indexable pages.' );
// Add an extra blank line after the header content.
$this->output_line( '' );
}
/**
* Adds post type data to the llms.txt output.
*
* @param int $limit The maximum number of posts to include per post type.
* @return void
*/
private function add_post_types_data( $limit ) {
$post_types = Helper::get_settings( 'general.llms_post_types', [] );
if ( empty( $post_types ) ) {
return;
}
foreach ( $post_types as $post_type ) {
$args = [
'post_type' => $post_type,
'post_status' => 'publish',
'posts_per_page' => $limit,
'no_found_rows' => true,
];
/**
* Filter the WP_Query arguments used to fetch posts for llms.txt.
*
* @since 1.0.250
* @param array $args The WP_Query arguments.
* @return array Modified WP_Query arguments.
*/
$args = $this->do_filter( 'llms_txt/posts_query_args', $args );
$query = new \WP_Query( $args );
/**
* Filter the list of post IDs to be included in llms.txt for a post type.
*
* @since 1.0.250
* @param array $posts List of posts.
* @param array $args The WP_Query arguments.
* @return array Modified list of post IDs.
*/
$posts = $this->do_filter( 'llms_txt/posts', $query->posts, $args );
if ( empty( $posts ) ) {
continue;
}
$label = get_post_type_object( $post_type )->labels->name;
$this->output_line( '## ' . esc_html( $label ) );
foreach ( $posts as $object ) {
if ( ! Helper::is_post_indexable( $object ) ) {
continue;
}
$title = get_the_title( $object );
$link = get_permalink( $object );
$desc = wp_strip_all_tags( Helper::replace_vars( '%excerpt%', $object ) );
$this->output_line(
$desc
? '- [' . esc_html( $title ) . '](' . esc_url( $link ) . '): ' . esc_html( $desc )
: '- [' . esc_html( $title ) . '](' . esc_url( $link ) . ')'
);
}
$this->output_line( '' );
}
}
/**
* Adds taxonomy data to the llms.txt output.
*
* @param int $limit The maximum number of terms to include per taxonomy.
* @return void
*/
private function add_taxonomies_data( $limit ) {
$taxonomies = Helper::get_settings( 'general.llms_taxonomies', [] );
if ( empty( $taxonomies ) ) {
return;
}
foreach ( $taxonomies as $taxonomy ) {
$tax_obj = get_taxonomy( $taxonomy );
if ( ! $tax_obj ) {
continue;
}
$terms = get_terms(
[
'taxonomy' => $taxonomy,
'hide_empty' => true,
'number' => $limit,
]
);
/**
* Filter the list of terms to be included in llms.txt for a taxonomy.
*
* @since 1.0.250
* @param array $terms List of terms.
* @param string $taxonomy Taxonomy name.
* @return array Modified list of term IDs.
*/
$terms = $this->do_filter( 'llms_txt/terms', $terms, $taxonomy );
if ( empty( $terms ) ) {
continue;
}
$label = $tax_obj->labels->name;
$this->output_line( '## ' . esc_html( $label ) );
foreach ( $terms as $term ) {
if ( $term && Helper::is_term_indexable( $term ) ) {
$name = $term->name;
$link = get_term_link( $term );
$this->output_line( '- [' . esc_html( $name ) . '](' . esc_url( $link ) . ')' );
}
}
$this->output_line( '' );
}
}
/**
* Adds extra content to the end of the llms.txt output.
*
* @return void
*/
private function add_extra_content() {
/**
* Filter the extra content output at the end of llms.txt.
*
* @since 1.0.250
* @param string $extra The extra content string.
* @return string Modified extra content.
*/
$extra = $this->do_filter( 'llms_txt/extra_content', Helper::get_settings( 'general.llms_extra_content', '' ) );
if ( ! empty( $extra ) ) {
$this->output_line( esc_html( str_replace( "\n", "\n", $extra ) ) );
}
}
/**
* Outputs a line with a newline character (\n).
*
* @param string $content The content to output.
* @return void
*/
private function output_line( $content ) {
echo esc_html( $content ) . "\n";
}
}