PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.15
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.15
2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 2.6.4 2.6.3 All 139 releases
metasync / llms-txt / class-metasync-llms-txt-generator.php

class-metasync-llms-txt-generator.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.15, at llms-txt/class-metasync-llms-txt-generator.php

479 lines 15.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // If this file is called directly, abort.
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * LLMs.txt generator.
9 *
10 * Serves virtual /llms.txt and /llms-full.txt routes via template_redirect,
11 * caches output in transients, and invalidates the cache on post or
12 * settings changes. Modeled after Metasync_Sitemap_Generator.
13 *
14 * @package Metasync
15 * @subpackage Metasync/llms-txt
16 */
17 class Metasync_Llms_Txt_Generator
18 {
19 const OPTION_KEY = 'metasync_llms_txt_settings';
20 const TRANSIENT_SHORT = 'metasync_llms_txt';
21 const TRANSIENT_FULL = 'metasync_llms_full_txt';
22 const CONFLICT_TRANSIENT = 'metasync_llms_conflict';
23 const CACHE_TTL = 12 * HOUR_IN_SECONDS;
24
25 /**
26 * Initialise the generator and register hooks.
27 */
28 public function __construct()
29 {
30 add_action('template_redirect', array($this, 'serve_virtual_llms_txt'), 1);
31
32 // Invalidate the cache whenever a post is saved, deleted, or trashed.
33 // save_post is gated by post_type so unrelated CPTs do not bust the cache.
34 // deleted_post / trashed_post stay unconditional — post_type cannot always
35 // be reliably resolved after deletion.
36 add_action('save_post', array($this, 'maybe_invalidate_cache'));
37 add_action('deleted_post', array($this, 'invalidate_cache'));
38 add_action('trashed_post', array($this, 'invalidate_cache'));
39
40 // Invalidate when the settings are updated.
41 add_action('update_option_' . self::OPTION_KEY, array($this, 'invalidate_cache'));
42 add_action('add_option_' . self::OPTION_KEY, array($this, 'invalidate_cache'));
43
44 // Sync robots.txt when the enabled setting changes.
45 add_action('update_option_' . self::OPTION_KEY, array($this, 'on_settings_changed'), 10, 2);
46 add_action('add_option_' . self::OPTION_KEY, array($this, 'on_settings_changed'), 10, 2);
47 }
48
49 /**
50 * Serve the virtual /llms.txt or /llms-full.txt route.
51 */
52 public function serve_virtual_llms_txt()
53 {
54 if (is_admin()) {
55 return;
56 }
57
58 $request_uri = isset($_SERVER['REQUEST_URI']) ? esc_url_raw(wp_unslash($_SERVER['REQUEST_URI'])) : '';
59 $request_uri = strtok($request_uri, '?');
60 $path = rtrim(parse_url($request_uri, PHP_URL_PATH), '/');
61
62 $is_short = ($path === '/llms.txt');
63 $is_full = ($path === '/llms-full.txt');
64
65 if (!$is_short && !$is_full) {
66 return;
67 }
68
69 // Let a physical file win if one exists.
70 $filename = $is_full ? 'llms-full.txt' : 'llms.txt';
71 if (file_exists(ABSPATH . $filename)) {
72 return;
73 }
74
75 $settings = $this->get_settings();
76
77 if (empty($settings['enabled'])) {
78 return;
79 }
80
81 // Detect conflicts for admin notice purposes only — MetaSync always serves when enabled.
82 $this->detect_plugin_conflict();
83
84 if ($is_full && empty($settings['llms_full_enabled'])) {
85 status_header(404);
86 return;
87 }
88
89 $transient_key = $is_full ? self::TRANSIENT_FULL : self::TRANSIENT_SHORT;
90 $content = get_transient($transient_key);
91
92 if (false === $content || $content === '') {
93 $content = $is_full ? $this->generate_full() : $this->generate();
94 if ($content !== '') {
95 set_transient($transient_key, $content, self::CACHE_TTL);
96 }
97 }
98
99 status_header(200);
100 header('Content-Type: text/plain; charset=utf-8');
101 header('X-Robots-Tag: noindex');
102 echo $content;
103 exit;
104 }
105
106 /**
107 * Build /llms.txt content.
108 *
109 * @return string
110 */
111 public function generate()
112 {
113 $settings = $this->get_settings();
114 $site_name = get_bloginfo('name');
115 $tagline = !empty($settings['custom_description'])
116 ? $settings['custom_description']
117 : get_bloginfo('description');
118
119 $content = '# ' . $site_name . "\n\n";
120 if (!empty($tagline)) {
121 $content .= '> ' . $tagline . "\n\n";
122 }
123
124 $posts = $this->query_posts($settings);
125
126 // Group posts by post type for cleaner section headers.
127 $grouped = [];
128 foreach ($posts as $post) {
129 $grouped[$post->post_type][] = $post;
130 }
131
132 $type_labels = [
133 'page' => 'About',
134 'post' => 'Blog',
135 ];
136
137 foreach ($grouped as $type => $items) {
138 $label = isset($type_labels[$type]) ? $type_labels[$type] : ucfirst($type);
139 $content .= '## ' . $label . "\n";
140 foreach ($items as $post) {
141 $desc = $this->resolve_description($post);
142 $line = '- [' . wp_strip_all_tags($post->post_title) . '](' . get_permalink($post) . ')';
143 if ($desc !== '') {
144 $line .= ': ' . $desc;
145 }
146 $content .= $line . "\n";
147 }
148 $content .= "\n";
149 }
150
151 if (!empty($settings['llms_full_enabled'])) {
152 $content .= "## Optional\n";
153 $content .= '- [llms-full.txt](' . site_url('/llms-full.txt') . '): Extended version with full content' . "\n";
154 }
155
156 return $content;
157 }
158
159 /**
160 * Build /llms-full.txt content.
161 *
162 * @return string
163 */
164 public function generate_full()
165 {
166 if (!class_exists('Metasync_Html_To_Markdown')) {
167 $converter_file = plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-html-to-markdown.php';
168 if (file_exists($converter_file)) {
169 require_once $converter_file;
170 }
171 }
172
173 $settings = $this->get_settings();
174 $site_name = get_bloginfo('name');
175 $tagline = !empty($settings['custom_description'])
176 ? $settings['custom_description']
177 : get_bloginfo('description');
178
179 $content = '# ' . $site_name . "\n\n";
180 if (!empty($tagline)) {
181 $content .= '> ' . $tagline . "\n\n";
182 }
183
184 $posts = $this->query_posts_full($settings);
185 $max_bytes = 1048576; // 1 MB
186
187 foreach ($posts as $post) {
188 $entry = "\n---\n\n";
189 if (class_exists('Metasync_Html_To_Markdown')) {
190 $entry .= Metasync_Html_To_Markdown::convert_post($post->ID, [
191 'include_frontmatter' => true,
192 'include_featured_image' => true,
193 ]);
194 $entry .= "\n";
195 } else {
196 $entry .= '# ' . $post->post_title . "\n\n";
197 $entry .= wp_strip_all_tags($post->post_content) . "\n";
198 }
199
200 if (strlen($content) + strlen($entry) > $max_bytes) {
201 break;
202 }
203
204 $content .= $entry;
205 }
206
207 return $content;
208 }
209
210 /**
211 * Sync the robots.txt LLMs.txt reference when settings are saved.
212 *
213 * Called on both update_option_ and add_option_ hooks. For add_option the
214 * hook passes ($option, $value) but we only need the second argument, which
215 * is the new value in both cases.
216 *
217 * @param mixed $old_value Previous option value (or option name for add_option).
218 * @param mixed $new_value New option value.
219 */
220 public function on_settings_changed( $old_value, $new_value ) {
221 $robots_file = plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php';
222 if (!class_exists('Metasync_Robots_Txt') && file_exists($robots_file)) {
223 require_once $robots_file;
224 }
225
226 if (!class_exists('Metasync_Robots_Txt')) {
227 return;
228 }
229
230 $robots = Metasync_Robots_Txt::get_instance();
231 $llms_url = site_url('/llms.txt');
232
233 if (!empty($new_value['enabled'])) {
234 $robots->add_llms_txt_url($llms_url);
235 } else {
236 $robots->remove_llms_txt_url($llms_url);
237 }
238 }
239
240 /**
241 * Invalidate the cache only when the saved post's type is in the
242 * configured LLMs.txt post_types list.
243 *
244 * @param int $post_id
245 */
246 public function maybe_invalidate_cache($post_id)
247 {
248 $post = get_post($post_id);
249 if (!$post) {
250 return;
251 }
252
253 $saved = get_option(self::OPTION_KEY, []);
254 $post_types = (is_array($saved) && !empty($saved['post_types']) && is_array($saved['post_types']))
255 ? $saved['post_types']
256 : ['page', 'post'];
257
258 if (in_array($post->post_type, $post_types, true)) {
259 $this->invalidate_cache();
260 }
261 }
262
263 /**
264 * Flush cached LLMs.txt content.
265 */
266 public function invalidate_cache()
267 {
268 delete_transient(self::TRANSIENT_SHORT);
269 delete_transient(self::TRANSIENT_FULL);
270 }
271
272 /**
273 * Fetch posts that should appear in the listing.
274 *
275 * @param array $settings
276 * @return WP_Post[]
277 */
278 private function query_posts($settings)
279 {
280 $post_types = !empty($settings['post_types']) && is_array($settings['post_types'])
281 ? $settings['post_types']
282 : ['page', 'post'];
283
284 $max_posts = isset($settings['max_posts']) ? (int) $settings['max_posts'] : 50;
285 if ($max_posts < 1) {
286 $max_posts = 50;
287 }
288 if ($max_posts > 500) {
289 $max_posts = 500;
290 }
291
292 $excluded = !empty($settings['excluded_ids']) && is_array($settings['excluded_ids'])
293 ? array_map('absint', $settings['excluded_ids'])
294 : [];
295
296 $args = [
297 'post_type' => $post_types,
298 'post_status' => 'publish',
299 'posts_per_page' => $max_posts,
300 'orderby' => 'modified',
301 'order' => 'DESC',
302 'no_found_rows' => true,
303 'meta_query' => [
304 [
305 'relation' => 'OR',
306 [
307 'key' => '_metasync_robots_index',
308 'compare' => 'NOT EXISTS',
309 ],
310 [
311 'key' => '_metasync_robots_index',
312 'value' => 'noindex',
313 'compare' => '!=',
314 ],
315 ],
316 ],
317 ];
318
319 if (!empty($excluded)) {
320 $args['post__not_in'] = $excluded;
321 }
322
323 $query = new WP_Query($args);
324 return $query->posts ?: [];
325 }
326
327 /**
328 * Fetch posts for /llms-full.txt (uses the separate max_posts_full limit).
329 *
330 * @param array $settings
331 * @return WP_Post[]
332 */
333 private function query_posts_full($settings)
334 {
335 $override = $settings;
336 $max = isset($settings['max_posts_full']) ? (int) $settings['max_posts_full'] : 25;
337 if ($max < 1) {
338 $max = 25;
339 }
340 if ($max > 500) {
341 $max = 500;
342 }
343 $override['max_posts'] = $max;
344 return $this->query_posts($override);
345 }
346
347 /**
348 * Resolve the description for a post using the documented priority:
349 * _metasync_seo_desc → _metasync_otto_description → manual excerpt → auto excerpt.
350 *
351 * @param WP_Post $post
352 * @return string
353 */
354 private function resolve_description($post)
355 {
356 $desc = get_post_meta($post->ID, '_metasync_seo_desc', true);
357 if (!empty($desc)) {
358 return $this->sanitize_description($desc);
359 }
360
361 $desc = get_post_meta($post->ID, '_metasync_otto_description', true);
362 if (!empty($desc)) {
363 return $this->sanitize_description($desc);
364 }
365
366 if (!empty($post->post_excerpt)) {
367 return $this->sanitize_description($post->post_excerpt);
368 }
369
370 $auto = wp_trim_words(wp_strip_all_tags(strip_shortcodes($post->post_content)), 20, '…');
371 return $this->sanitize_description($auto);
372 }
373
374 /**
375 * Collapse whitespace and normalise a description string for markdown output.
376 *
377 * @param string $desc
378 * @return string
379 */
380 private function sanitize_description($desc)
381 {
382 $desc = wp_strip_all_tags((string) $desc);
383 $desc = preg_replace('/\s+/', ' ', $desc);
384 return trim($desc);
385 }
386
387 /**
388 * Get plugin settings with defaults merged.
389 *
390 * @return array
391 */
392 public function get_settings()
393 {
394 $defaults = [
395 'enabled' => false,
396 'post_types' => ['page', 'post'],
397 'max_posts' => 50,
398 'max_posts_full' => 25,
399 'excluded_ids' => [],
400 'custom_description' => '',
401 'llms_full_enabled' => false,
402 ];
403
404 $saved = get_option(self::OPTION_KEY, []);
405 if (!is_array($saved)) {
406 $saved = [];
407 }
408
409 return array_merge($defaults, $saved);
410 }
411
412 /**
413 * Detect whether another SEO plugin already handles /llms.txt.
414 *
415 * Caches the result in a short-lived transient so admin notices can
416 * reflect the current state without re-checking on every request.
417 *
418 * @return bool
419 */
420 public function detect_plugin_conflict()
421 {
422 $conflict = false;
423
424 // Yoast SEO.
425 if (class_exists('WPSEO_Options')) {
426 $wpseo = get_option('wpseo');
427 if (is_array($wpseo) && !empty($wpseo['enable_llms_txt'])) {
428 $conflict = true;
429 }
430 }
431
432 // Rank Math.
433 if (!$conflict && class_exists('RankMath')) {
434 $rank_math_modules = get_option('rank_math_modules', []);
435 if (is_array($rank_math_modules) && in_array('llms-txt', $rank_math_modules, true)) {
436 $conflict = true;
437 }
438 if (!$conflict) {
439 $rank_math_general = get_option('rank-math-options-general');
440 if (is_array($rank_math_general) && !empty($rank_math_general['llms_txt_enable'])) {
441 $conflict = true;
442 }
443 }
444 }
445
446 // All in One SEO.
447 if (!$conflict && function_exists('aioseo')) {
448 try {
449 $aioseo = aioseo();
450 if (isset($aioseo->options) && isset($aioseo->options->searchAppearance)
451 && isset($aioseo->options->searchAppearance->global)
452 && isset($aioseo->options->searchAppearance->global->llmsEnabled)
453 && $aioseo->options->searchAppearance->global->llmsEnabled) {
454 $conflict = true;
455 }
456 } catch (\Throwable $e) {
457 // Ignore – AIOSEO API changed or not fully loaded.
458 }
459
460 if (!$conflict) {
461 $aioseo_raw = get_option('aioseo_options');
462 $aioseo_options = is_string($aioseo_raw) ? json_decode($aioseo_raw, true) : $aioseo_raw;
463 if (is_array($aioseo_options)
464 && !empty($aioseo_options['searchAppearance']['global']['llmsEnabled'])) {
465 $conflict = true;
466 }
467 }
468 }
469
470 if ($conflict) {
471 set_transient(self::CONFLICT_TRANSIENT, true, HOUR_IN_SECONDS);
472 } else {
473 delete_transient(self::CONFLICT_TRANSIENT);
474 }
475
476 return $conflict;
477 }
478 }
479