bavarianrankengine/bavarian-rank-engine/includes/Features/LlmsTxt.php

237 lines
7 KiB
PHP

<?php
namespace BavarianRankEngine\Features;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class LlmsTxt {
private const OPTION_KEY = 'bre_llms_settings';
private const CACHE_KEY = 'bre_llms_cache';
private const NOTICE_META = 'bre_dismissed_llms_rank_math';
public function register(): void {
add_action( 'parse_request', array( $this, 'maybe_serve' ), 1 );
add_action( 'init', array( $this, 'add_rewrite_rule' ) );
add_filter( 'query_vars', array( $this, 'add_query_var' ) );
add_action( 'admin_notices', array( $this, 'rank_math_notice' ) );
add_action( 'wp_ajax_bre_dismiss_llms_notice', array( $this, 'ajax_dismiss_notice' ) );
}
public function maybe_serve(): void {
$uri = isset( $_SERVER['REQUEST_URI'] ) ? strtok( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ), '?' ) : '';
if ( $uri === '/llms.txt' ) {
$this->serve_page( 1 );
return;
}
if ( preg_match( '#^/llms-(\d+)\.txt$#', $uri, $m ) ) {
$this->serve_page( (int) $m[1] );
}
}
public function rank_math_notice(): void {
if ( ! defined( 'RANK_MATH_VERSION' ) ) {
return;
}
if ( get_user_meta( get_current_user_id(), self::NOTICE_META, true ) ) {
return;
}
$settings = self::getSettings();
if ( empty( $settings['enabled'] ) ) {
return;
}
$nonce = wp_create_nonce( 'bre_dismiss_llms_notice' );
?>
<div class="notice notice-info is-dismissible" id="bre-llms-rank-math-notice">
<p><?php esc_html_e( 'Bavarian Rank Engine serves llms.txt with priority — no action needed in Rank Math.', 'bavarian-rank-engine' ); ?></p>
</div>
<script>
jQuery(document).on('click','#bre-llms-rank-math-notice .notice-dismiss',function(){
jQuery.post(window.ajaxurl,{action:'bre_dismiss_llms_notice',nonce:'<?php echo esc_js( $nonce ); ?>'});
});
</script>
<?php
}
public function ajax_dismiss_notice(): void {
check_ajax_referer( 'bre_dismiss_llms_notice', 'nonce' );
update_user_meta( get_current_user_id(), self::NOTICE_META, '1' );
wp_send_json_success();
}
public function add_rewrite_rule(): void {
add_rewrite_rule( '^llms\.txt$', 'index.php?bre_llms=1', 'top' );
}
public function add_query_var( array $vars ): array {
$vars[] = 'bre_llms';
return $vars;
}
private function serve_page( int $page ): void {
$settings = self::getSettings();
if ( empty( $settings['enabled'] ) ) {
status_header( 404 );
exit;
}
$cache_key = self::CACHE_KEY . '_p' . $page;
$cached = get_transient( $cache_key );
if ( $cached === false ) {
$cached = $this->build( $settings, $page );
set_transient( $cache_key, $cached, 0 );
}
$etag = '"' . md5( $cached ) . '"';
$last_modified = $this->get_last_modified();
header( 'Content-Type: text/plain; charset=utf-8' );
header( 'ETag: ' . $etag );
header( 'Last-Modified: ' . gmdate( 'D, d M Y H:i:s', $last_modified ) . ' GMT' );
header( 'Cache-Control: public, max-age=3600' );
$if_none_match = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) : '';
if ( $if_none_match === $etag ) {
status_header( 304 );
exit;
}
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $cached;
exit;
}
private function get_last_modified(): int {
global $wpdb;
if ( ! isset( $wpdb ) ) {
return time();
}
$latest = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
"SELECT UNIX_TIMESTAMP(MAX(post_modified_gmt)) FROM {$wpdb->posts}
WHERE post_status = 'publish'"
);
return $latest ? (int) $latest : time();
}
public static function clear_cache(): void {
global $wpdb;
if ( ! isset( $wpdb ) ) {
return;
}
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_bre_llms_cache%'" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_bre_llms_cache%'" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
}
private function build( array $s, int $page = 1 ): string {
$max_links = max( 50, (int) ( $s['max_links'] ?? 500 ) );
$post_types = $s['post_types'] ?? array( 'post', 'page' );
$all_posts = $this->get_all_posts( $post_types );
$total = count( $all_posts );
$pages = $total > 0 ? (int) ceil( $total / $max_links ) : 1;
$offset = ( $page - 1 ) * $max_links;
$page_posts = array_slice( $all_posts, $offset, $max_links );
$out = '';
if ( $page === 1 ) {
if ( ! empty( $s['title'] ) ) {
$out .= '# ' . $s['title'] . "\n\n";
}
if ( ! empty( $s['description_before'] ) ) {
$out .= trim( $s['description_before'] ) . "\n\n";
}
if ( ! empty( $s['custom_links'] ) ) {
$out .= "## Featured Resources\n\n";
foreach ( explode( "\n", trim( $s['custom_links'] ) ) as $line ) {
$line = trim( $line );
if ( $line !== '' ) {
$out .= $line . "\n";
}
}
$out .= "\n";
}
}
if ( ! empty( $page_posts ) ) {
$out .= "## Content\n\n";
foreach ( $page_posts as $post ) {
$out .= sprintf(
'- [%s](%s) — %s',
$post->post_title,
get_permalink( $post ),
get_the_date( 'Y-m-d', $post )
) . "\n";
}
$out .= "\n";
}
if ( $pages > 1 ) {
$out .= "## More\n\n";
for ( $p = 1; $p <= $pages; $p++ ) {
if ( $p === $page ) {
continue;
}
$filename = $p === 1 ? 'llms.txt' : "llms-{$p}.txt";
$url = home_url( '/' . $filename );
$out .= "- [{$filename}]({$url})\n";
}
$out .= "\n";
}
if ( $page === 1 ) {
if ( ! empty( $s['description_after'] ) ) {
$out .= "\n---\n" . trim( $s['description_after'] ) . "\n";
}
if ( ! empty( $s['description_footer'] ) ) {
$out .= "\n---\n" . trim( $s['description_footer'] ) . "\n";
}
}
return $out;
}
private function get_all_posts( array $post_types ): array {
if ( empty( $post_types ) ) {
return array();
}
$query = new \WP_Query(
array(
'post_type' => $post_types,
'post_status' => 'publish',
'posts_per_page' => -1,
'orderby' => 'date',
'order' => 'DESC',
'no_found_rows' => true,
)
);
$posts = $query->posts;
wp_reset_postdata();
return $posts;
}
/**
* Flush rewrite rules on activation.
* Call this from your activation hook.
*/
public function flush_rules(): void {
$this->add_rewrite_rule();
flush_rewrite_rules();
}
public static function getSettings(): array {
$defaults = array(
'enabled' => false,
'title' => '',
'description_before' => '',
'description_after' => '',
'description_footer' => '',
'custom_links' => '',
'post_types' => array( 'post', 'page' ),
'max_links' => 500,
);
$saved = get_option( self::OPTION_KEY, array() );
return array_merge( $defaults, is_array( $saved ) ? $saved : array() );
}
}