PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.2
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / seo / class-llms-txt-manager.php

class-llms-txt-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.0.2, at includes/seo/class-llms-txt-manager.php

1,901 lines 68.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * LLMs.txt Manager Class
4 *
5 * Comprehensive LLMs.txt file management with AI-powered content generation,
6 * file writing, validation, and status monitoring. Implements structured
7 * information format for AI assistants and LLMs to better understand websites.
8 *
9 * @package ThinkRank
10 * @subpackage SEO
11 * @since 1.0.0
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\SEO;
17
18 // Ensure dependencies are loaded
19 if (!class_exists('ThinkRank\\SEO\\Abstract_SEO_Manager')) {
20 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-abstract-seo-manager.php';
21 }
22
23 if (!interface_exists('ThinkRank\\SEO\\Interfaces\\SEO_Manager_Interface')) {
24 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/interfaces/class-seo-manager-interface.php';
25 }
26
27 /**
28 * LLMs.txt Manager Class
29 *
30 * Manages LLMs.txt file generation, validation, and serving with AI-powered
31 * content creation based on website information and user input.
32 *
33 * @since 1.0.0
34 */
35 class LLMs_Txt_Manager extends Abstract_SEO_Manager {
36
37 /**
38 * WordPress filesystem instance
39 *
40 * @since 1.0.0
41 * @var \WP_Filesystem_Base|null
42 */
43 private $filesystem = null;
44
45 /**
46 * Whether the most recent save_settings() persisted a disable but failed to
47 * remove the published llms.txt file (so it may still be served). Callers
48 * check this via {@see unpublish_failed()} to surface a partial failure.
49 *
50 * @var bool
51 */
52 private bool $last_unpublish_failed = false;
53
54 /**
55 * Message from the last save that switched delivery mode but could not move
56 * the published document, or an empty string when the switch was clean.
57 *
58 * @since 2.1.0
59 * @var string
60 */
61 private string $last_delivery_warning = '';
62
63 /**
64 * LLMs.txt content sections configuration
65 *
66 * @since 1.0.0
67 * @var array
68 */
69 private array $content_sections = [
70 'project_overview' => [
71 'title' => 'Project Overview',
72 'required' => true,
73 'description' => 'High-level description of the website/project purpose',
74 'max_length' => 500
75 ],
76 'key_features' => [
77 'title' => 'Key Features',
78 'required' => true,
79 'description' => 'Main features and functionality of the website',
80 'max_length' => 300
81 ],
82 'architecture' => [
83 'title' => 'Architecture & Components',
84 'required' => false,
85 'description' => 'Technical architecture and key components',
86 'max_length' => 400
87 ],
88 'development_guidelines' => [
89 'title' => 'Development Guidelines',
90 'required' => false,
91 'description' => 'Coding standards and development practices',
92 'max_length' => 300
93 ],
94 'setup_instructions' => [
95 'title' => 'Setup Instructions',
96 'required' => false,
97 'description' => 'How to get the project running',
98 'max_length' => 400
99 ],
100 'ai_context' => [
101 'title' => 'Context for AI Assistants',
102 'required' => true,
103 'description' => 'Specific information to help AI understand the project',
104 'max_length' => 300
105 ]
106 ];
107
108 /**
109 * Maximum file size for LLMs.txt files (1MB)
110 *
111 * @since 1.0.0
112 * @var int
113 */
114 private const MAX_FILE_SIZE = 1048576; // 1MB in bytes
115
116 /**
117 * Marker used for ThinkRank's block in the site's .htaccess.
118 *
119 * The published llms.txt is a physical file, so the web server — not PHP —
120 * serves it and decides the response headers. Apache/LiteSpeed answer .txt
121 * with a bare `Content-Type: text/plain` (no charset), which makes browsers
122 * fall back to their legacy single-byte default and render UTF-8 content as
123 * mojibake ("Aktivitäten" → "Aktivitäten"); `X-Content-Type-Options:
124 * nosniff` removes even the sniffing fallback. This block pins the charset
125 * for that one file. See {@see serve_llms_txt()} for the PHP-served path.
126 *
127 * @var string
128 */
129 private const HTACCESS_MARKER = 'ThinkRank llms.txt';
130
131 /**
132 * Option holding the published llms.txt document.
133 *
134 * The published content lives here regardless of delivery mode, so the
135 * dynamic route has an authoritative source that does not depend on a
136 * physical file, and switching modes never loses the published document.
137 *
138 * @since 2.1.0
139 * @var string
140 */
141 private const CONTENT_OPTION = 'thinkrank_llms_txt_content';
142
143 /**
144 * Option holding the Unix timestamp of the last publish.
145 *
146 * @since 2.1.0
147 * @var string
148 */
149 private const PUBLISHED_AT_OPTION = 'thinkrank_llms_txt_published_at';
150
151 /**
152 * Delivery modes accepted by the `delivery_mode` setting.
153 *
154 * @since 2.1.0
155 * @var string[]
156 */
157 private const DELIVERY_MODES = ['auto', 'static', 'dynamic'];
158
159 /**
160 * Business type templates for content generation
161 *
162 * @since 1.0.0
163 * @var array
164 */
165 private array $business_types = [
166 'website' => 'website',
167 'blog' => 'Personal or professional blog',
168 'business' => 'Business/corporate website',
169 'ecommerce' => 'E-commerce/online store',
170 'portfolio' => 'Portfolio/showcase website',
171 'nonprofit' => 'Non-profit organization',
172 'educational' => 'Educational institution',
173 'news' => 'News/media website',
174 'community' => 'Community/forum website',
175 'saas' => 'Software as a Service',
176 'agency' => 'Agency/service provider',
177 'other' => 'Other type of website'
178 ];
179
180 /**
181 * Constructor
182 *
183 * @since 1.0.0
184 */
185 public function __construct() {
186 parent::__construct('llms_txt');
187 }
188
189 /**
190 * Initialize WordPress filesystem
191 *
192 * @since 1.0.0
193 * @return bool True if filesystem is initialized, false otherwise
194 */
195 private function init_filesystem(): bool {
196 if ($this->filesystem !== null) {
197 return true;
198 }
199
200 global $wp_filesystem;
201
202 if (!function_exists('WP_Filesystem')) {
203 require_once ABSPATH . 'wp-admin/includes/file.php';
204 }
205
206 $credentials = request_filesystem_credentials('', '', false, false, null);
207 if (!WP_Filesystem($credentials)) {
208 return false;
209 }
210
211 $this->filesystem = $wp_filesystem;
212 return true;
213 }
214
215 /**
216 * Check if directory is writable using WP_Filesystem
217 *
218 * @since 1.0.0
219 * @param string $path Directory path to check
220 * @return bool True if writable, false otherwise
221 */
222 private function is_directory_writable(string $path): bool {
223 if (!$this->init_filesystem()) {
224 return false;
225 }
226
227 return $this->filesystem->is_writable($path);
228 }
229
230 /**
231 * Check if file is writable using WP_Filesystem
232 *
233 * @since 1.0.0
234 * @param string $file File path to check
235 * @return bool True if writable, false otherwise
236 */
237 private function is_file_writable(string $file): bool {
238 if (!$this->init_filesystem()) {
239 return false;
240 }
241
242 return $this->filesystem->is_writable($file);
243 }
244 public function generate_llms_txt(array $user_input, array $options = []): array {
245 $llms_data = [
246 'content' => '',
247 'sections' => [],
248 'metadata' => [],
249 'validation' => [],
250 'file_info' => []
251 ];
252
253 // Get current settings
254 $settings = $this->get_settings('site');
255
256 // Merge saved settings underneath the provided input so that empty or
257 // partial $user_input falls back to the persisted configuration.
258 // Explicitly provided (non-empty) values win; blank ones are filled from
259 // saved settings. This lets callers generate from saved settings by
260 // passing an empty payload (e.g. the generate-llms-txt MCP ability),
261 // matching the documented behavior.
262 $provided = array_filter(
263 $user_input,
264 static function ($value) {
265 if (is_string($value)) {
266 return '' !== trim($value);
267 }
268 return null !== $value && [] !== $value;
269 }
270 );
271 $user_input = array_merge($settings, $provided);
272
273 // Check file status
274 $llms_file = ABSPATH . 'llms.txt';
275 $llms_data['file_info'] = [
276 'file_exists' => file_exists($llms_file),
277 'writable' => $this->is_directory_writable(dirname($llms_file)),
278 'file_path' => $llms_file,
279 'last_modified' => file_exists($llms_file) ? filemtime($llms_file) : null
280 ];
281
282 // Validate user input
283 $validation = $this->validate_user_input($user_input);
284 $llms_data['validation'] = $validation;
285
286 if (!$validation['valid']) {
287 return $llms_data;
288 }
289
290 // Generate content sections
291 $llms_data['sections'] = $this->build_content_sections($user_input, $settings);
292
293 // Build final LLMs.txt content
294 $site_name = $user_input['site_name'] ?? $settings['site_name'] ?? get_bloginfo('name');
295 $llms_data['content'] = $this->build_llms_txt_content($llms_data['sections'], $site_name);
296
297 // Add metadata
298 $llms_data['metadata'] = [
299 'generated_at' => gmdate('c'),
300 'website_url' => home_url(),
301 'generator' => 'ThinkRank SEO Plugin',
302 'content_length' => strlen($llms_data['content']),
303 'sections_count' => count($llms_data['sections'])
304 ];
305
306 return $llms_data;
307 }
308
309 /**
310 * Persist settings, unpublishing the physical file when the feature is
311 * disabled so a disable actually stops serving /llms.txt.
312 *
313 * @param string $context_type Context type.
314 * @param int|null $context_id Context ID.
315 * @param array $settings Settings to save.
316 * @return bool
317 */
318 public function save_settings(string $context_type, ?int $context_id, array $settings): bool {
319 $this->last_unpublish_failed = false;
320 $this->last_delivery_warning = '';
321 $previous_mode = $this->resolve_delivery_mode();
322
323 $result = parent::save_settings($context_type, $context_id, $settings);
324
325 // The cached status carries the resolved delivery mode, so it goes stale
326 // the moment settings change — even when nothing needs republishing.
327 delete_transient('thinkrank_llms_file_status');
328
329 // When a save explicitly disables the feature, delete the published file.
330 if ($result && array_key_exists('enabled', $settings) && empty($settings['enabled'])) {
331 if (!$this->delete_llms_txt_file()) {
332 // The settings were persisted, but the physical file could not be
333 // removed, so /llms.txt may still be served. Record it so callers
334 // report a partial failure instead of an unqualified success.
335 $this->last_unpublish_failed = true;
336 }
337
338 return $result;
339 }
340
341 // The published document has to sit where the active mode serves it from,
342 // or the site keeps answering on the old path: a leftover physical file
343 // shadows the dynamic route on every stack, and a database-only document
344 // is invisible to a stack now expecting a file. Reconciled on any save,
345 // not just an explicit mode change, so a site whose auto-detection now
346 // resolves differently — an nginx install upgrading into this fix with a
347 // static file already on disk — heals the next time settings are saved.
348 if ($result && $this->delivery_needs_reconcile($previous_mode)) {
349 $this->republish_for_delivery_mode();
350 }
351
352 return $result;
353 }
354
355 /**
356 * Whether the published document is out of step with the active mode.
357 *
358 * @since 2.1.0
359 *
360 * @param string $previous_mode Mode in force before the save.
361 * @return bool
362 */
363 private function delivery_needs_reconcile(string $previous_mode): bool {
364 $mode = $this->resolve_delivery_mode();
365 $file_exists = file_exists(ABSPATH . 'llms.txt');
366
367 if ('dynamic' === $mode) {
368 // A physical file would be served instead of the PHP route.
369 return $file_exists;
370 }
371
372 // Static: a stored document with no file behind it is unreachable on a
373 // stack that expects one. A mode flip also forces the charset block to
374 // be (re)written for a file that predates it.
375 return (!$file_exists && '' !== trim($this->get_published_content()))
376 || $mode !== $previous_mode;
377 }
378
379 /**
380 * Re-publish the current document under the active delivery mode.
381 *
382 * A no-op when nothing is published yet — this only moves an existing
383 * document, it never publishes on the user's behalf.
384 *
385 * @since 2.1.0
386 *
387 * @return void
388 */
389 private function republish_for_delivery_mode(): void {
390 $content = $this->get_published_content();
391
392 if ('' === trim($content)) {
393 // Published before the stored copy existed: recover it from the file.
394 $llms_file = ABSPATH . 'llms.txt';
395 if (file_exists($llms_file)) {
396 $read_result = $this->safe_file_read($llms_file);
397 if ($read_result['success']) {
398 $content = $read_result['content'];
399 }
400 }
401 }
402
403 if ('' === trim($content)) {
404 return;
405 }
406
407 $write = $this->write_llms_txt_to_file($content);
408
409 // The switch itself failed (an unwritable root on the way to static, a
410 // stuck file on the way to dynamic). The settings are saved, so report
411 // it rather than letting the mode read as applied when it is not.
412 if (empty($write['success'])) {
413 $this->last_delivery_warning = isset($write['message']) && '' !== (string) $write['message']
414 ? (string) $write['message']
415 : 'The delivery method was saved, but the published llms.txt could not be moved to it.';
416 }
417 }
418
419 /**
420 * Message from the last save whose delivery-mode switch could not be
421 * applied to the already-published document, or '' when there was none.
422 *
423 * @since 2.1.0
424 *
425 * @return string
426 */
427 public function delivery_switch_warning(): string {
428 return $this->last_delivery_warning;
429 }
430
431 /**
432 * Whether the last save_settings() disabled the feature but could not remove
433 * the published llms.txt file (which may therefore still be served).
434 *
435 * @return bool
436 */
437 public function unpublish_failed(): bool {
438 return $this->last_unpublish_failed;
439 }
440
441 /**
442 * Resolve the effective delivery mode for /llms.txt.
443 *
444 * `static` publishes a physical ABSPATH/llms.txt and lets the web server
445 * answer it; `dynamic` keeps the document in the database and lets the PHP
446 * route in {@see serve_llms_txt()} answer it. `auto` picks static only on
447 * Apache/LiteSpeed, the stacks that read the .htaccess charset block — on
448 * nginx a physical file is served with a bare `Content-Type: text/plain`
449 * that neither fix path can reach, which renders UTF-8 as mojibake (#419).
450 *
451 * Layered hosts (e.g. an nginx front end reporting as something else) can
452 * defeat the detection, which is why the setting also accepts an explicit
453 * override rather than relying on $is_apache alone.
454 *
455 * @since 2.1.0
456 *
457 * @param string|null $mode Optional. Raw setting value; read from the saved
458 * settings when null.
459 * @return string Either 'static' or 'dynamic'.
460 */
461 public function resolve_delivery_mode(?string $mode = null): string {
462 if (null === $mode) {
463 $settings = $this->get_settings('site');
464 $mode = (string) ($settings['delivery_mode'] ?? 'auto');
465 }
466
467 if ('static' === $mode || 'dynamic' === $mode) {
468 return $mode;
469 }
470
471 // $is_apache also covers LiteSpeed, which reads .htaccess the same way.
472 return !empty($GLOBALS['is_apache']) ? 'static' : 'dynamic';
473 }
474
475 /**
476 * The published llms.txt document, or an empty string when unpublished.
477 *
478 * @since 2.1.0
479 *
480 * @return string
481 */
482 public function get_published_content(): string {
483 $content = get_option(self::CONTENT_OPTION, '');
484
485 return is_string($content) ? $content : '';
486 }
487
488 /**
489 * Ask the common page/CDN cache layers to drop their copy of /llms.txt.
490 *
491 * A cached response outlives a republish, so without this a mode switch or
492 * a content change keeps serving the old document (and, on the static path,
493 * the old headers). Every call is guarded — a site running none of these
494 * simply gets the action hook, which integrations can use.
495 *
496 * @since 2.1.0
497 *
498 * @return void
499 */
500 private function purge_llms_txt_caches(): void {
501 $url = home_url('/llms.txt');
502
503 /**
504 * Fires after the published llms.txt changes, so cache layers ThinkRank
505 * does not know about can drop their copy.
506 *
507 * @since 2.1.0
508 *
509 * @param string $url Public URL of the llms.txt document.
510 */
511 do_action('thinkrank_llms_txt_updated', $url);
512
513 // LiteSpeed Cache and Nginx Helper both listen on their own actions.
514 // These are third-party hook names we fire, not ours to prefix.
515 do_action('litespeed_purge_url', $url); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
516 do_action('rt_nginx_helper_purge_all'); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
517
518 if (function_exists('rocket_clean_files')) {
519 rocket_clean_files([$url]);
520 }
521 if (function_exists('w3tc_flush_url')) {
522 w3tc_flush_url($url);
523 }
524 if (function_exists('wpsc_delete_url_cache')) {
525 wpsc_delete_url_cache($url);
526 }
527 }
528
529 /**
530 * Unpublish llms.txt: drop the stored document and any physical file.
531 *
532 * Both delivery modes are cleared, not just the active one, so a site that
533 * published under one mode and switched to the other is left with nothing
534 * still being served.
535 *
536 * @return bool True once nothing is left to serve.
537 */
538 public function delete_llms_txt_file(): bool {
539 delete_transient('thinkrank_llms_file_status');
540
541 delete_option(self::CONTENT_OPTION);
542 delete_option(self::PUBLISHED_AT_OPTION);
543
544 $removed = $this->delete_static_file();
545 $this->purge_llms_txt_caches();
546
547 return $removed;
548 }
549
550 /**
551 * Remove the physical ABSPATH/llms.txt and its .htaccess charset block.
552 *
553 * @since 2.1.0
554 *
555 * @return bool True if the file is absent or was removed.
556 */
557 private function delete_static_file(): bool {
558 $llms_file = ABSPATH . 'llms.txt';
559 if (!file_exists($llms_file)) {
560 $this->remove_htaccess_charset();
561 return true;
562 }
563 if (!$this->init_filesystem()) {
564 return false;
565 }
566
567 $deleted = (bool) $this->filesystem->delete($llms_file);
568 if ($deleted) {
569 // Leave no orphaned rule behind once the file is gone.
570 $this->remove_htaccess_charset();
571 }
572
573 return $deleted;
574 }
575
576 /**
577 * Pin the served charset of the physical llms.txt to UTF-8 via .htaccess.
578 *
579 * Scoped to the single file with <Files>, and wrapped in <IfModule> so a
580 * server without mod_mime ignores it instead of returning a 500. Nginx does
581 * not read .htaccess — there the PHP route in {@see serve_llms_txt()} is
582 * what carries the charset, provided no physical file shadows it.
583 *
584 * @since 1.32.0
585 *
586 * @return bool True when the block is in place.
587 */
588 private function sync_htaccess_charset(): bool {
589 // $is_apache also covers LiteSpeed, which reads .htaccess the same way.
590 if (empty($GLOBALS['is_apache'])) {
591 return false;
592 }
593
594 $htaccess = ABSPATH . '.htaccess';
595
596 if (file_exists($htaccess)) {
597 if (!$this->is_file_writable($htaccess)) {
598 return false;
599 }
600 } elseif (!$this->is_directory_writable(ABSPATH)) {
601 return false;
602 }
603
604 if (!function_exists('insert_with_markers')) {
605 require_once ABSPATH . 'wp-admin/includes/misc.php';
606 }
607
608 return (bool) insert_with_markers($htaccess, self::HTACCESS_MARKER, [
609 '<IfModule mod_mime.c>',
610 '<Files "llms.txt">',
611 "ForceType 'text/plain; charset=UTF-8'",
612 '</Files>',
613 '</IfModule>',
614 ]);
615 }
616
617 /**
618 * Remove ThinkRank's charset block from .htaccess.
619 *
620 * Strips the block outright rather than calling insert_with_markers() with
621 * an empty insertion — that leaves the BEGIN/END markers behind as litter.
622 *
623 * @since 1.32.0
624 *
625 * @return void
626 */
627 private function remove_htaccess_charset(): void {
628 $htaccess = ABSPATH . '.htaccess';
629
630 if (!file_exists($htaccess) || !$this->is_file_writable($htaccess)) {
631 return;
632 }
633
634 if (!$this->init_filesystem()) {
635 return;
636 }
637
638 $contents = $this->filesystem->get_contents($htaccess);
639 if (!is_string($contents) || false === strpos($contents, '# BEGIN ' . self::HTACCESS_MARKER)) {
640 return;
641 }
642
643 $marker = preg_quote(self::HTACCESS_MARKER, '/');
644 $cleaned = preg_replace(
645 '/\R*# BEGIN ' . $marker . '.*?# END ' . $marker . '[ \t]*\R?/s',
646 '',
647 $contents
648 );
649
650 if (!is_string($cleaned)) {
651 return;
652 }
653
654 // A file left holding nothing but our (now removed) block was ours to
655 // begin with — a pre-existing .htaccess would still have content.
656 if ('' === trim($cleaned)) {
657 $this->filesystem->delete($htaccess);
658 return;
659 }
660
661 // Keep the file newline-terminated after the block is cut out.
662 $this->filesystem->put_contents($htaccess, rtrim($cleaned, "\r\n") . "\n", FS_CHMOD_FILE);
663 }
664
665 /**
666 * Serve /llms.txt from PHP with an explicit UTF-8 charset.
667 *
668 * Only reached when the request actually gets to WordPress — i.e. when no
669 * physical llms.txt shadows the route, or on a stack that routes every
670 * request through index.php. Prefers the published file's exact bytes and
671 * falls back to regenerating from the saved settings, so the response is
672 * the same document either way, just with headers PHP controls.
673 *
674 * Called by \ThinkRank\Frontend\SEO_Manager on template_redirect.
675 *
676 * @since 1.32.0
677 *
678 * @return void
679 */
680 public function serve_llms_txt(): void {
681 $settings = $this->get_settings('site');
682
683 // Never resurrect the file for a site that turned the feature off.
684 if (empty($settings['enabled'])) {
685 return;
686 }
687
688 $content = '';
689 $llms_file = ABSPATH . 'llms.txt';
690
691 // In static mode a physical file is what the server would normally hand
692 // back, so prefer its exact bytes; in dynamic mode there is no file and
693 // the stored document is the authoritative copy.
694 if ('static' === $this->resolve_delivery_mode() && file_exists($llms_file)) {
695 $read_result = $this->safe_file_read($llms_file);
696 if ($read_result['success']) {
697 $content = $read_result['content'];
698 }
699 }
700
701 if ('' === trim($content)) {
702 $content = $this->get_published_content();
703 }
704
705 if ('' === trim($content)) {
706 $generated = $this->generate_llms_txt([]);
707 $content = (string) ($generated['content'] ?? '');
708 }
709
710 // Nothing configured yet: leave the 404 alone rather than serving a stub.
711 if ('' === trim($content)) {
712 return;
713 }
714
715 status_header(200);
716 header('Content-Type: text/plain; charset=utf-8');
717
718 // Plain-text file body — already sanitized on save by
719 // sanitize_llms_content(); escaping it here would corrupt the markdown.
720 echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
721 exit;
722 }
723
724 /**
725 * Write LLMs.txt content to filesystem
726 *
727 * @since 1.0.0
728 *
729 * @param string $content LLMs.txt content to write
730 * @return array Write operation result
731 */
732 public function write_llms_txt_to_file(string $content): array {
733 $result = [
734 'success' => false,
735 'message' => '',
736 'file_path' => '',
737 'permissions' => []
738 ];
739
740 // Refuse to publish when the feature is disabled. The React UI hides the
741 // publish button, but the REST endpoint and the MCP publish ability call
742 // this directly, so enforce the toggle here at the single write choke point.
743 $settings = $this->get_settings('site');
744 if (empty($settings['enabled'])) {
745 $result['message'] = 'LLMs.txt is disabled. Enable it before publishing.';
746 return $result;
747 }
748
749 $mode = $this->resolve_delivery_mode();
750 $result['delivery_mode'] = $mode;
751
752 $llms_file = ABSPATH . 'llms.txt';
753 $result['file_path'] = $llms_file;
754
755 // Dynamic delivery: the document lives in the database and /llms.txt is
756 // answered by serve_llms_txt(), which sets `charset=utf-8` itself. A
757 // physical file would shadow that route on every stack, so any leftover
758 // from a previous static publish has to go.
759 if ('dynamic' === $mode) {
760 if (!$this->delete_static_file()) {
761 $result['message'] = 'A physical llms.txt is still present and could not be removed. It would be served instead of the dynamic route.';
762 return $result;
763 }
764
765 $this->store_published_content($content);
766
767 $result['success'] = true;
768 $result['message'] = 'LLMs.txt published. It is served by WordPress as UTF-8 text.';
769 $result['bytes_written'] = strlen($content);
770 $result['charset_pinned'] = true;
771 $result['permissions'] = [
772 'directory_writable' => $this->is_directory_writable(ABSPATH),
773 'file_exists' => false,
774 'file_writable' => null,
775 ];
776
777 return $result;
778 }
779
780 // Security: Validate file path to prevent path traversal attacks
781 $real_llms_file = realpath(dirname($llms_file)) . DIRECTORY_SEPARATOR . basename($llms_file);
782 $allowed_dir = realpath(ABSPATH);
783
784 if (!$allowed_dir || strpos(dirname($real_llms_file), $allowed_dir) !== 0) {
785 $result['message'] = 'Invalid file path detected for security reasons.';
786 return $result;
787 }
788
789 // Check directory permissions
790 $result['permissions'] = [
791 'directory_writable' => $this->is_directory_writable(ABSPATH),
792 'file_exists' => file_exists($llms_file),
793 'file_writable' => file_exists($llms_file) ? $this->is_file_writable($llms_file) : null
794 ];
795
796 // Check if we can write to the directory
797 if (!$result['permissions']['directory_writable']) {
798 $result['message'] = 'WordPress root directory is not writable. Please check file permissions.';
799 return $result;
800 }
801
802 // Check if existing file is writable (if it exists)
803 if ($result['permissions']['file_exists'] && !$result['permissions']['file_writable']) {
804 $result['message'] = 'Existing llms.txt file is not writable. Please check file permissions.';
805 return $result;
806 }
807
808 // Write new content using WP_Filesystem
809 if (!$this->init_filesystem()) {
810 $result['message'] = 'Could not initialize WordPress filesystem.';
811 return $result;
812 }
813
814 if (!$this->filesystem->put_contents($llms_file, $content, FS_CHMOD_FILE)) {
815 $result['message'] = 'Failed to write llms.txt file.';
816 return $result;
817 }
818
819 $result['success'] = true;
820 $result['message'] = 'LLMs.txt file written successfully.';
821 $result['bytes_written'] = strlen($content);
822
823 // Pin the served charset to UTF-8. Best effort: a site without a
824 // writable .htaccess (or not on Apache/LiteSpeed) still gets a
825 // correctly written file, so this must never fail the publish.
826 $result['charset_pinned'] = $this->sync_htaccess_charset();
827
828 // Keep the stored copy in step with the file so a later switch to
829 // dynamic delivery serves the same document.
830 $this->store_published_content($content);
831
832 return $result;
833 }
834
835 /**
836 * Persist the published document and bust the caches that mirror it.
837 *
838 * @since 2.1.0
839 *
840 * @param string $content Published llms.txt content.
841 * @return void
842 */
843 private function store_published_content(string $content): void {
844 update_option(self::CONTENT_OPTION, $content, false);
845 update_option(self::PUBLISHED_AT_OPTION, time(), false);
846
847 // Invalidate file status cache since the published document has changed
848 delete_transient('thinkrank_llms_file_status');
849
850 $this->purge_llms_txt_caches();
851 }
852
853 /**
854 * Get LLMs.txt file status and information
855 *
856 * @since 1.0.0
857 *
858 * @return array File status information
859 */
860 public function get_llms_txt_status(bool $force_refresh = false): array {
861 // Check cache first (5 minute cache for performance)
862 $cache_key = 'thinkrank_llms_file_status';
863
864 if (!$force_refresh) {
865 $cached_status = get_transient($cache_key);
866 if ($cached_status !== false) {
867 return $cached_status;
868 }
869 }
870
871 $llms_file = ABSPATH . 'llms.txt';
872 $mode = $this->resolve_delivery_mode();
873 $stored = $this->get_published_content();
874
875 $status = [
876 'file_exists' => file_exists($llms_file),
877 // Whether /llms.txt is actually being served, either mode. Prefer
878 // this over file_exists, which is only meaningful in static mode.
879 'published' => file_exists($llms_file) || '' !== trim($stored),
880 'delivery_mode' => $mode,
881 'file_path' => 'dynamic' === $mode ? '' : $llms_file,
882 'file_url' => home_url('/llms.txt'),
883 'writable' => $this->is_directory_writable(dirname($llms_file)),
884 'last_modified' => null,
885 'file_size' => null,
886 'content_preview' => ''
887 ];
888
889 $content = null;
890
891 if ($status['file_exists']) {
892 $status['last_modified'] = filemtime($llms_file);
893 $status['file_size'] = filesize($llms_file);
894
895 $read_result = $this->safe_file_read($llms_file);
896 if ($read_result['success']) {
897 $content = $read_result['content'];
898 } else {
899 $status['content_preview'] = 'Error: ' . $read_result['error'];
900 $status['read_error'] = $read_result['error'];
901 }
902 } elseif ('' !== trim($stored)) {
903 $published_at = (int) get_option(self::PUBLISHED_AT_OPTION, 0);
904 $status['last_modified'] = $published_at > 0 ? $published_at : null;
905 $status['file_size'] = strlen($stored);
906 $content = $stored;
907 }
908
909 if (null !== $content) {
910 // Get content preview (first 200 characters) with size safety
911 $status['content_preview'] = substr($content, 0, 200);
912 if (strlen($content) > 200) {
913 $status['content_preview'] .= '...';
914 }
915 }
916
917 // Cache the result for 5 minutes to improve performance
918 set_transient($cache_key, $status, 5 * MINUTE_IN_SECONDS);
919
920 return $status;
921 }
922
923 /**
924 * Validate LLMs.txt content
925 *
926 * @since 1.0.0
927 *
928 * @param string $content LLMs.txt content to validate
929 * @return array Validation results
930 */
931 public function validate_llms_txt_content(string $content): array {
932 $validation = [
933 'valid' => true,
934 'errors' => [],
935 'warnings' => [],
936 'suggestions' => [],
937 'score' => 100
938 ];
939
940 // Check if content is empty
941 if (empty(trim($content))) {
942 $validation['errors'][] = 'LLMs.txt content cannot be empty';
943 $validation['valid'] = false;
944 $validation['score'] = 0;
945 return $validation;
946 }
947
948 // Check content length
949 $content_length = strlen($content);
950 if ($content_length < 100) {
951 $validation['warnings'][] = 'LLMs.txt content is very short, consider adding more details';
952 $validation['score'] -= 20;
953 } elseif ($content_length > 10000) {
954 $validation['warnings'][] = 'LLMs.txt content is very long, consider condensing key information';
955 $validation['score'] -= 10;
956 }
957
958 // Check for required sections
959 $required_sections = ['Project Overview', 'Key Features', 'Context for AI Assistants'];
960 foreach ($required_sections as $section) {
961 if (stripos($content, $section) === false) {
962 $validation['warnings'][] = "Missing recommended section: {$section}";
963 $validation['score'] -= 15;
964 }
965 }
966
967 // Check for proper structure
968 if (!preg_match('/^#\s+/', $content)) {
969 $validation['suggestions'][] = 'Consider starting with a main heading (# Project Name)';
970 $validation['score'] -= 5;
971 }
972
973 // Ensure score doesn't go below 0
974 $validation['score'] = max(0, $validation['score']);
975
976 return $validation;
977 }
978
979 /**
980 * Validate user input for LLMs.txt generation
981 *
982 * @since 1.0.0
983 *
984 * @param array $user_input User-provided data
985 * @return array Validation results
986 */
987 private function validate_user_input(array $user_input): array {
988 $validation = [
989 'valid' => true,
990 'errors' => [],
991 'warnings' => [],
992 'suggestions' => [],
993 'score' => 100
994 ];
995
996 // Check required fields
997 $required_fields = [
998 'website_description' => 'Website Description',
999 'key_features' => 'Key Features',
1000 'target_audience' => 'Target Audience'
1001 ];
1002
1003 foreach ($required_fields as $field => $label) {
1004 if (empty($user_input[$field])) {
1005 $validation['errors'][] = "{$label} is required for quality LLMs.txt generation";
1006 $validation['valid'] = false;
1007 $validation['score'] -= 25;
1008 } else {
1009 $validation['suggestions'][] = "{$label} is properly configured";
1010 }
1011 }
1012
1013 // Validate link formats in structured sections
1014 $this->validate_link_sections($user_input, $validation);
1015
1016 // Check content quality
1017 $this->validate_content_quality($user_input, $validation);
1018
1019 // Check optional enhancements
1020 $this->validate_optional_enhancements($user_input, $validation);
1021
1022 // Validate website description
1023 if (!empty($user_input['website_description'])) {
1024 $desc_length = strlen($user_input['website_description']);
1025 if ($desc_length < 50) {
1026 $validation['warnings'][] = 'Website description is quite short, consider adding more details';
1027 $validation['score'] -= 10;
1028 } elseif ($desc_length > 1000) {
1029 $validation['warnings'][] = 'Website description is very long, consider condensing key points';
1030 $validation['score'] -= 5;
1031 }
1032 }
1033
1034 // Validate business type
1035 if (!empty($user_input['business_type']) && !isset($this->business_types[$user_input['business_type']])) {
1036 $validation['warnings'][] = 'Unknown business type specified';
1037 $validation['score'] -= 5;
1038 }
1039
1040 // Ensure score doesn't go below 0
1041 $validation['score'] = max(0, $validation['score']);
1042
1043 return $validation;
1044 }
1045
1046 /**
1047 * Validate LLMs.txt input (public method for API)
1048 *
1049 * @since 1.0.0
1050 *
1051 * @param array $user_input User-provided data
1052 * @return array Validation results
1053 */
1054 public function validate_llms_txt_input(array $user_input): array {
1055 return $this->validate_user_input($user_input);
1056 }
1057
1058 /**
1059 * Override parent sanitize_settings to preserve line breaks in link fields
1060 *
1061 * @since 1.0.0
1062 *
1063 * @param array $settings Settings to sanitize
1064 * @param string $context_type Context the save is for.
1065 * @return array Sanitized settings
1066 */
1067 protected function sanitize_settings(array $settings, string $context_type = 'site'): array {
1068 $sanitized = [];
1069 $known = $this->get_known_setting_keys($context_type);
1070
1071 // Fields that should preserve line breaks
1072 $preserve_linebreaks = [
1073 'documentation_links',
1074 'technical_links',
1075 'optional_links',
1076 'custom_sections',
1077 'key_features',
1078 'website_description',
1079 'technical_stack',
1080 'development_approach',
1081 'setup_instructions',
1082 'ai_context_custom'
1083 ];
1084
1085 foreach ($settings as $key => $value) {
1086 $sanitized_key = sanitize_key($key);
1087
1088 // Never store the REST envelope back as settings (see
1089 // Abstract_Seo_Manager::RESERVED_ENVELOPE_KEYS).
1090 if (in_array($sanitized_key, self::RESERVED_ENVELOPE_KEYS, true)) {
1091 continue;
1092 }
1093
1094 // And nothing this manager does not declare (#452).
1095 if (!$this->is_known_setting_key($sanitized_key, $known)) {
1096 continue;
1097 }
1098
1099 // Constrain the delivery mode to the known enum so an unexpected
1100 // value falls back to auto-detection rather than being stored.
1101 if ('delivery_mode' === $sanitized_key) {
1102 $mode = is_string($value) ? sanitize_key($value) : '';
1103 $sanitized[$sanitized_key] = in_array($mode, self::DELIVERY_MODES, true) ? $mode : 'auto';
1104 continue;
1105 }
1106
1107 if (is_string($value)) {
1108 if (in_array($key, $preserve_linebreaks, true)) {
1109 // Use our custom sanitization that preserves line breaks
1110 if (in_array($key, ['documentation_links', 'technical_links', 'optional_links', 'custom_sections'], true)) {
1111 $sanitized[$sanitized_key] = $this->sanitize_llms_content($value);
1112 } else {
1113 // For textarea fields, use sanitize_textarea_field which preserves line breaks
1114 $sanitized[$sanitized_key] = sanitize_textarea_field($value);
1115 }
1116 } else {
1117 // For regular text fields, use sanitize_text_field
1118 $sanitized[$sanitized_key] = sanitize_text_field($value);
1119 }
1120 } elseif (is_array($value)) {
1121 $sanitized[$sanitized_key] = $this->sanitize_array_recursive($value);
1122 } elseif (is_numeric($value)) {
1123 $sanitized[$sanitized_key] = (float) $value;
1124 } elseif (is_bool($value)) {
1125 $sanitized[$sanitized_key] = (bool) $value;
1126 } else {
1127 $sanitized[$sanitized_key] = sanitize_text_field((string) $value);
1128 }
1129 }
1130
1131 return $sanitized;
1132 }
1133
1134 /**
1135 * Recursively sanitize array values (preserving line breaks where needed)
1136 *
1137 * @since 1.0.0
1138 *
1139 * @param array $input Array to sanitize
1140 * @return array Sanitized array
1141 */
1142 private function sanitize_array_recursive(array $input): array {
1143 $sanitized = [];
1144
1145 foreach ($input as $key => $value) {
1146 $sanitized_key = sanitize_key($key);
1147
1148 if (is_string($value)) {
1149 $sanitized[$sanitized_key] = sanitize_textarea_field($value);
1150 } elseif (is_array($value)) {
1151 $sanitized[$sanitized_key] = $this->sanitize_array_recursive($value);
1152 } elseif (is_numeric($value)) {
1153 $sanitized[$sanitized_key] = (float) $value;
1154 } elseif (is_bool($value)) {
1155 $sanitized[$sanitized_key] = (bool) $value;
1156 } else {
1157 $sanitized[$sanitized_key] = sanitize_text_field((string) $value);
1158 }
1159 }
1160
1161 return $sanitized;
1162 }
1163
1164 /**
1165 * Validate link formats in structured sections
1166 *
1167 * @since 1.0.0
1168 *
1169 * @param array $user_input User input data
1170 * @param array &$validation Validation results (passed by reference)
1171 */
1172 private function validate_link_sections(array $user_input, array &$validation): void {
1173 $link_sections = [
1174 'documentation_links' => 'Documentation Links',
1175 'technical_links' => 'Technical Links',
1176 'optional_links' => 'Optional Links'
1177 ];
1178
1179 foreach ($link_sections as $field => $label) {
1180 if (!empty($user_input[$field])) {
1181 $links = explode("\n", $user_input[$field]);
1182 $valid_links = 0;
1183 $total_links = 0;
1184
1185 foreach ($links as $line) {
1186 $line = trim($line);
1187 if (empty($line) || !str_starts_with($line, '-')) {
1188 continue;
1189 }
1190
1191 $total_links++;
1192
1193 // Check for proper markdown link format: - [Title](URL): Description
1194 if (preg_match('/^-\s*\[([^\]]+)\]\(([^)]+)\):\s*(.+)$/', $line, $matches)) {
1195 $title = trim($matches[1]);
1196 $url = trim($matches[2]);
1197 $description = trim($matches[3]);
1198
1199 if (!empty($title) && !empty($url) && !empty($description)) {
1200 if (filter_var($url, FILTER_VALIDATE_URL)) {
1201 $valid_links++;
1202 } else {
1203 $validation['warnings'][] = "Invalid URL in {$label}: {$url}";
1204 $validation['score'] -= 5;
1205 }
1206 } else {
1207 $validation['warnings'][] = "Incomplete link format in {$label}: missing title, URL, or description";
1208 $validation['score'] -= 5;
1209 }
1210 } else {
1211 $validation['warnings'][] = "Invalid link format in {$label}. Use: - [Title](URL): Description";
1212 $validation['score'] -= 5;
1213 }
1214 }
1215
1216 if ($total_links > 0) {
1217 if ($valid_links === $total_links) {
1218 $validation['suggestions'][] = "✓ All {$label} are properly formatted";
1219 } else {
1220 $validation['warnings'][] = "{$label}: {$valid_links}/{$total_links} links are properly formatted";
1221 }
1222 }
1223 }
1224 }
1225 }
1226
1227 /**
1228 * Validate content quality
1229 *
1230 * @since 1.0.0
1231 *
1232 * @param array $user_input User input data
1233 * @param array &$validation Validation results (passed by reference)
1234 */
1235 private function validate_content_quality(array $user_input, array &$validation): void {
1236 // Check website description quality
1237 if (!empty($user_input['website_description'])) {
1238 $desc_length = strlen($user_input['website_description']);
1239 if ($desc_length < 50) {
1240 $validation['warnings'][] = 'Website description is quite short. Consider adding more detail for better AI understanding';
1241 $validation['score'] -= 10;
1242 } elseif ($desc_length > 500) {
1243 $validation['warnings'][] = 'Website description is very long. Consider making it more concise';
1244 $validation['score'] -= 5;
1245 } else {
1246 $validation['suggestions'][] = '✓ Website description length is optimal';
1247 }
1248 }
1249
1250 // Check key features quality
1251 if (!empty($user_input['key_features'])) {
1252 $features = explode("\n", $user_input['key_features']);
1253 $feature_count = count(array_filter($features, 'trim'));
1254
1255 if ($feature_count < 3) {
1256 $validation['warnings'][] = 'Consider adding more key features (3-8 recommended) for comprehensive AI understanding';
1257 $validation['score'] -= 10;
1258 } elseif ($feature_count > 10) {
1259 $validation['warnings'][] = 'Many key features listed. Consider focusing on the most important ones';
1260 $validation['score'] -= 5;
1261 } else {
1262 $validation['suggestions'][] = "✓ Good number of key features ({$feature_count})";
1263 }
1264 }
1265 }
1266
1267 /**
1268 * Validate optional enhancements
1269 *
1270 * @since 1.0.0
1271 *
1272 * @param array $user_input User input data
1273 * @param array &$validation Validation results (passed by reference)
1274 */
1275 private function validate_optional_enhancements(array $user_input, array &$validation): void {
1276 $enhancement_score = 0;
1277
1278 // Check for technical stack
1279 if (!empty($user_input['technical_stack'])) {
1280 $validation['suggestions'][] = '✓ Technical stack information provided';
1281 $enhancement_score += 5;
1282 } else {
1283 $validation['suggestions'][] = 'Consider adding technical stack information for developer context';
1284 }
1285
1286 // Check for development approach
1287 if (!empty($user_input['development_approach'])) {
1288 $validation['suggestions'][] = '✓ Development approach documented';
1289 $enhancement_score += 5;
1290 } else {
1291 $validation['suggestions'][] = 'Consider documenting development approach for better AI assistance';
1292 }
1293
1294 // Check for setup instructions
1295 if (!empty($user_input['setup_instructions'])) {
1296 $validation['suggestions'][] = '✓ Setup instructions provided';
1297 $enhancement_score += 5;
1298 } else {
1299 $validation['suggestions'][] = 'Consider adding setup instructions for new developers';
1300 }
1301
1302 // Check for custom sections
1303 if (!empty($user_input['custom_sections'])) {
1304 $validation['suggestions'][] = '✓ Custom sections enhance documentation';
1305 $enhancement_score += 5;
1306 }
1307
1308 // Bonus points for comprehensive documentation
1309 if ($enhancement_score >= 15) {
1310 $validation['suggestions'][] = '✓ Comprehensive LLMs.txt documentation - excellent for AI assistance!';
1311 }
1312 }
1313
1314 /**
1315 * Build content sections from user input
1316 *
1317 * @since 1.0.0
1318 *
1319 * @param array $user_input User-provided data
1320 * @param array $settings Current settings
1321 * @return array Built content sections
1322 */
1323 private function build_content_sections(array $user_input, array $settings): array {
1324 $sections = [];
1325
1326 // Blockquote summary (required by spec)
1327 $sections['summary'] = [
1328 'title' => '', // No title for blockquote
1329 'content' => $this->build_summary_blockquote($user_input, $settings)
1330 ];
1331
1332 // Additional details (optional descriptive content)
1333 if (!empty($user_input['website_description'])) {
1334 $sections['details'] = [
1335 'title' => '', // No title for details
1336 'content' => $this->build_additional_details($user_input, $settings)
1337 ];
1338 }
1339
1340 // Development Approach section (if provided)
1341 if (!empty($user_input['development_approach'])) {
1342 $sections['development_approach'] = [
1343 'title' => 'Development Approach',
1344 'content' => sanitize_textarea_field($user_input['development_approach'])
1345 ];
1346 }
1347
1348 // Setup Instructions section (if provided)
1349 if (!empty($user_input['setup_instructions'])) {
1350 $sections['setup_instructions'] = [
1351 'title' => 'Setup Instructions',
1352 'content' => sanitize_textarea_field($user_input['setup_instructions'])
1353 ];
1354 }
1355
1356 // User-controlled structured sections (always include with defaults if empty)
1357 $documentation_content = !empty($user_input['documentation_links'])
1358 ? $this->sanitize_llms_content($user_input['documentation_links'])
1359 : $this->get_default_documentation_links();
1360
1361 $sections['documentation'] = [
1362 'title' => 'Documentation',
1363 'content' => $documentation_content
1364 ];
1365
1366 // Technical section (only if user provided content or technical details exist)
1367 if (!empty($user_input['technical_links']) || !empty($user_input['technical_stack']) || !empty($user_input['development_approach'])) {
1368 $technical_content = !empty($user_input['technical_links'])
1369 ? $this->sanitize_llms_content($user_input['technical_links'])
1370 : $this->get_default_technical_links($user_input);
1371
1372 $sections['technical'] = [
1373 'title' => 'Technical Details',
1374 'content' => $technical_content
1375 ];
1376 }
1377
1378 // Optional section (only if user provided content)
1379 if (!empty($user_input['optional_links'])) {
1380 $sections['optional'] = [
1381 'title' => 'Optional',
1382 'content' => $this->sanitize_llms_content($user_input['optional_links'])
1383 ];
1384 }
1385
1386 // Custom sections (user-defined markdown)
1387 if (!empty($user_input['custom_sections'])) {
1388 $sections['custom'] = [
1389 'title' => '', // No title since user provides their own H2 headers
1390 'content' => $this->sanitize_llms_content($user_input['custom_sections'])
1391 ];
1392 }
1393
1394 /**
1395 * Filter the llms.txt content sections before assembly.
1396 *
1397 * Each entry is ['title' => string, 'content' => string]; an empty
1398 * title emits the content without an H2. Pro appends a "Markdown for
1399 * AI" section here when that feature is enabled. Section content is
1400 * the callback's responsibility to sanitize.
1401 *
1402 * @since 1.32.0
1403 *
1404 * @param array $sections Sections keyed by slug.
1405 * @param array $user_input Validated user input for the generator.
1406 */
1407 return apply_filters('thinkrank_llms_txt_sections', $sections, $user_input);
1408 }
1409
1410 /**
1411 * Sanitize LLMs.txt content while preserving line breaks
1412 *
1413 * @since 1.0.0
1414 *
1415 * @param string $content Raw content to sanitize
1416 * @return string Sanitized content with preserved line breaks
1417 */
1418 public function sanitize_llms_content(string $content): string {
1419 // Remove any potential script tags and dangerous content
1420 $content = wp_kses($content, [
1421 'a' => ['href' => [], 'title' => []],
1422 'strong' => [],
1423 'em' => [],
1424 'code' => [],
1425 'pre' => []
1426 ]);
1427
1428 // wp_kses only guards HTML href attributes, not markdown link syntax
1429 // [text](url). Neutralize dangerous schemes (javascript:/data:/vbscript:)
1430 // in markdown link targets so they don't survive into the published file
1431 // for downstream consumers that render it as markdown/HTML.
1432 $content = preg_replace_callback('/\]\(([^)]*)\)/', static function ($m) {
1433 if (preg_match('#^\s*(?:javascript|data|vbscript):#i', $m[1])) {
1434 return '](#)';
1435 }
1436 return $m[0];
1437 }, $content);
1438
1439 // Normalize line endings and preserve line breaks
1440 $content = str_replace(["\r\n", "\r"], "\n", $content);
1441
1442 // Remove excessive whitespace but preserve intentional line breaks
1443 $content = preg_replace('/[ \t]+/', ' ', $content); // Multiple spaces/tabs to single space
1444 $content = preg_replace('/\n\s*\n\s*\n+/', "\n\n", $content); // Multiple empty lines to double
1445
1446 return trim($content);
1447 }
1448
1449 /**
1450 * Safely read file content with size limits
1451 *
1452 * @since 1.0.0
1453 *
1454 * @param string $file_path Path to file to read
1455 * @param int|null $max_size Maximum file size to read (null for class default)
1456 * @return array Result with success status, content, and any errors
1457 */
1458 private function safe_file_read(string $file_path, ?int $max_size = null): array {
1459 $result = [
1460 'success' => false,
1461 'content' => '',
1462 'error' => '',
1463 'file_size' => 0
1464 ];
1465
1466 if (!file_exists($file_path)) {
1467 $result['error'] = 'File does not exist';
1468 return $result;
1469 }
1470
1471 $file_size = filesize($file_path);
1472 $result['file_size'] = $file_size;
1473
1474 $max_allowed = $max_size ?? self::MAX_FILE_SIZE;
1475
1476 if ($file_size > $max_allowed) {
1477 $result['error'] = sprintf(
1478 'File size (%s) exceeds maximum allowed size (%s)',
1479 size_format($file_size),
1480 size_format($max_allowed)
1481 );
1482 return $result;
1483 }
1484
1485 if (!$this->init_filesystem()) {
1486 $result['error'] = 'Could not initialize WordPress filesystem';
1487 return $result;
1488 }
1489
1490 $content = $this->filesystem->get_contents($file_path);
1491 if (false === $content) {
1492 $result['error'] = 'Failed to read file content';
1493 return $result;
1494 }
1495
1496 $result['success'] = true;
1497 $result['content'] = $content;
1498 return $result;
1499 }
1500 private function build_llms_txt_content(array $sections, string $site_name = ''): string {
1501 // Sanitize inside the manager rather than trusting callers — the MCP
1502 // abilities pass site_name through unsanitized.
1503 $site_name = sanitize_text_field($site_name ?: get_bloginfo('name'));
1504 $content = "# {$site_name}\n\n";
1505
1506 foreach ($sections as $section_key => $section_data) {
1507 // Only add H2 header if title is not empty
1508 if (!empty($section_data['title'])) {
1509 $content .= "## {$section_data['title']}\n\n";
1510 }
1511 $content .= $section_data['content'] . "\n\n";
1512 }
1513
1514 // Add generation timestamp
1515 $content .= "---\n";
1516 $content .= "Generated by ThinkRank SEO Plugin on " . gmdate('Y-m-d H:i:s') . " UTC\n";
1517
1518 return $content;
1519 }
1520
1521 /**
1522 * Validate SEO settings (implements interface)
1523 *
1524 * @since 1.0.0
1525 *
1526 * @param array $settings Settings array to validate
1527 * @return array Validation results
1528 */
1529 public function validate_settings(array $settings): array {
1530 $validation = [
1531 'valid' => true,
1532 'errors' => [],
1533 'warnings' => [],
1534 'suggestions' => [],
1535 'score' => 100
1536 ];
1537
1538 // Validate enabled setting
1539 if (!isset($settings['enabled'])) {
1540 $validation['errors'][] = 'Enabled setting is required';
1541 $validation['valid'] = false;
1542 $validation['score'] -= 25;
1543 }
1544
1545 // Validate website description
1546 if (isset($settings['website_description'])) {
1547 if (empty($settings['website_description'])) {
1548 $validation['warnings'][] = 'Website description is empty, consider adding a description';
1549 $validation['score'] -= 15;
1550 } elseif (strlen($settings['website_description']) < 50) {
1551 $validation['suggestions'][] = 'Website description is quite short, consider adding more details';
1552 $validation['score'] -= 5;
1553 }
1554 }
1555
1556 // Validate key features
1557 if (isset($settings['key_features'])) {
1558 if (empty($settings['key_features'])) {
1559 $validation['warnings'][] = 'Key features are empty, consider listing main website features';
1560 $validation['score'] -= 15;
1561 }
1562 }
1563
1564 // Validate target audience
1565 if (isset($settings['target_audience'])) {
1566 if (empty($settings['target_audience'])) {
1567 $validation['suggestions'][] = 'Target audience is not specified, consider defining your audience';
1568 $validation['score'] -= 5;
1569 }
1570 }
1571
1572 // Check file permissions if enabled. Only the static delivery mode needs
1573 // a writable root — dynamic delivery keeps the document in the database.
1574 $mode = $this->resolve_delivery_mode(
1575 isset($settings['delivery_mode']) ? (string) $settings['delivery_mode'] : null
1576 );
1577 if (!empty($settings['enabled']) && 'static' === $mode) {
1578 if (!$this->is_directory_writable(ABSPATH)) {
1579 $validation['warnings'][] = 'WordPress root directory is not writable, llms.txt cannot be automatically managed. Switch delivery to "Served by WordPress" to publish without writing a file.';
1580 $validation['score'] -= 10;
1581 }
1582 }
1583
1584 // Ensure score doesn't go below 0
1585 $validation['score'] = max(0, $validation['score']);
1586
1587 return $validation;
1588 }
1589
1590 /**
1591 * Get output data for frontend rendering (implements interface)
1592 *
1593 * @since 1.0.0
1594 *
1595 * @param string $context_type The context type
1596 * @param int|null $context_id Optional. Context ID
1597 * @return array Output data ready for frontend rendering
1598 */
1599 public function get_output_data(string $context_type, ?int $context_id): array {
1600 $settings = $this->get_settings($context_type, $context_id);
1601
1602 $output = [
1603 'llms_txt_content' => '',
1604 'file_status' => [],
1605 'metadata' => [],
1606 'enabled' => $settings['enabled'] ?? true
1607 ];
1608
1609 if (!$output['enabled']) {
1610 return $output;
1611 }
1612
1613 // Get file status
1614 $output['file_status'] = $this->get_llms_txt_status();
1615
1616 // If a file is published, get its current content safely; otherwise fall
1617 // back to the stored document that dynamic delivery serves.
1618 if ($output['file_status']['file_exists']) {
1619 $llms_file = ABSPATH . 'llms.txt';
1620 $read_result = $this->safe_file_read($llms_file);
1621 if ($read_result['success']) {
1622 $output['llms_txt_content'] = $read_result['content'];
1623 } else {
1624 $output['llms_txt_content'] = '';
1625 $output['file_read_error'] = $read_result['error'];
1626 }
1627 } else {
1628 $output['llms_txt_content'] = $this->get_published_content();
1629 }
1630
1631 // Add metadata
1632 $output['metadata'] = [
1633 'last_generated' => $settings['last_generated'] ?? null,
1634 'generator_version' => THINKRANK_VERSION ?? '1.0.0',
1635 'website_url' => home_url()
1636 ];
1637
1638 return $output;
1639 }
1640
1641 /**
1642 * Get default settings for a context type (implements interface)
1643 *
1644 * @since 1.0.0
1645 *
1646 * @param string $context_type The context type to get defaults for
1647 * @return array Default settings array
1648 */
1649 public function get_default_settings(string $context_type): array {
1650 $defaults = [
1651 'enabled' => true,
1652 'site_name' => get_bloginfo('name'),
1653 'website_description' => get_bloginfo('description'),
1654 'key_features' => '',
1655 'target_audience' => 'general',
1656 'business_type' => 'website',
1657 'technical_stack' => 'WordPress',
1658 'development_approach' => '',
1659 'setup_instructions' => '',
1660 'ai_context_custom' => '',
1661 'auto_generate' => false,
1662 'delivery_mode' => 'auto',
1663 'last_generated' => null,
1664 // Structured sections for llms.txt spec compliance
1665 'documentation_links' => '',
1666 'technical_links' => '',
1667 'optional_links' => '',
1668 'custom_sections' => ''
1669 ];
1670
1671 // Context-specific defaults
1672 switch ($context_type) {
1673 case 'site':
1674 // Site-wide defaults are already set above
1675 break;
1676 default:
1677 // Use site defaults for other contexts
1678 break;
1679 }
1680
1681 return $defaults;
1682 }
1683
1684 /**
1685 * Get settings schema definition (implements interface)
1686 *
1687 * @since 1.0.0
1688 *
1689 * @param string $context_type The context type to get schema for
1690 * @return array Settings schema definition
1691 */
1692 public function get_settings_schema(string $context_type): array {
1693 return [
1694 'enabled' => [
1695 'type' => 'boolean',
1696 'title' => 'Enable LLMs.txt',
1697 'description' => 'Enable LLMs.txt file generation and management',
1698 'default' => true
1699 ],
1700 'site_name' => [
1701 'type' => 'string',
1702 'title' => 'Website Title',
1703 'description' => 'The name of your website as it will appear in the LLMs.txt file',
1704 'default' => get_bloginfo('name'),
1705 'maxLength' => 60
1706 ],
1707 'website_description' => [
1708 'type' => 'string',
1709 'title' => 'Website Description',
1710 'description' => 'Comprehensive description of your website and its purpose',
1711 'default' => get_bloginfo('description'),
1712 'maxLength' => 1000
1713 ],
1714 'key_features' => [
1715 'type' => 'string',
1716 'title' => 'Key Features',
1717 'description' => 'Main features and functionality of your website',
1718 'default' => '',
1719 'maxLength' => 500
1720 ],
1721 'target_audience' => [
1722 'type' => 'string',
1723 'title' => 'Target Audience',
1724 'description' => 'Primary audience for your website',
1725 'default' => 'general',
1726 'maxLength' => 200
1727 ],
1728 'business_type' => [
1729 'type' => 'string',
1730 'title' => 'Business Type',
1731 'description' => 'Type of website or business',
1732 'enum' => array_keys($this->business_types),
1733 'default' => 'website'
1734 ],
1735 'technical_stack' => [
1736 'type' => 'string',
1737 'title' => 'Technical Stack',
1738 'description' => 'Technologies and frameworks used',
1739 'default' => 'WordPress',
1740 'maxLength' => 300
1741 ],
1742 'development_approach' => [
1743 'type' => 'string',
1744 'title' => 'Development Approach',
1745 'description' => 'Development methodology and practices',
1746 'default' => '',
1747 'maxLength' => 400
1748 ],
1749 'setup_instructions' => [
1750 'type' => 'string',
1751 'title' => 'Setup Instructions',
1752 'description' => 'Instructions for setting up or working with the project',
1753 'default' => '',
1754 'maxLength' => 500
1755 ],
1756 'ai_context_custom' => [
1757 'type' => 'string',
1758 'title' => 'Additional AI Context',
1759 'description' => 'Custom context information for AI assistants',
1760 'default' => '',
1761 'maxLength' => 400
1762 ],
1763 'auto_generate' => [
1764 'type' => 'boolean',
1765 'title' => 'Auto-generate',
1766 'description' => 'Automatically regenerate llms.txt when settings change',
1767 'default' => false
1768 ],
1769 'delivery_mode' => [
1770 'type' => 'string',
1771 'title' => 'Delivery Method',
1772 'description' => 'How /llms.txt is served: "static" writes a physical file the web server answers, "dynamic" keeps the document in WordPress and serves it from PHP as UTF-8, "auto" picks static on Apache/LiteSpeed and dynamic elsewhere.',
1773 'enum' => self::DELIVERY_MODES,
1774 'default' => 'auto'
1775 ],
1776 'last_generated' => [
1777 'type' => 'string',
1778 'title' => 'Last Generated',
1779 'description' => 'Timestamp of last generation',
1780 'format' => 'date-time',
1781 'readonly' => true
1782 ],
1783 'documentation_links' => [
1784 'type' => 'string',
1785 'title' => 'Documentation Links',
1786 'description' => 'Links to documentation, guides, and important pages',
1787 'default' => '',
1788 'maxLength' => 2000
1789 ],
1790 'technical_links' => [
1791 'type' => 'string',
1792 'title' => 'Technical Links',
1793 'description' => 'Links to technical resources, code repositories, and development info',
1794 'default' => '',
1795 'maxLength' => 2000
1796 ],
1797 'optional_links' => [
1798 'type' => 'string',
1799 'title' => 'Optional Links',
1800 'description' => 'Secondary resources that can be skipped for shorter context',
1801 'default' => '',
1802 'maxLength' => 2000
1803 ],
1804 'custom_sections' => [
1805 'type' => 'string',
1806 'title' => 'Custom Sections',
1807 'description' => 'Additional custom sections in markdown format',
1808 'default' => '',
1809 'maxLength' => 3000
1810 ]
1811 ];
1812 }
1813 private function build_summary_blockquote(array $user_input, array $settings): string {
1814 $description = sanitize_textarea_field($user_input['website_description'] ?? '');
1815
1816 if (empty($description)) {
1817 $site_name = sanitize_text_field($user_input['site_name'] ?? $settings['site_name'] ?? get_bloginfo('name'));
1818 $business_type = $user_input['business_type'] ?? 'website';
1819 $description = "{$site_name} is a {$this->business_types[$business_type]} providing valuable resources and information.";
1820 }
1821
1822 // Format as blockquote (required by spec)
1823 return "> " . $description . "\n\n";
1824 }
1825
1826 /**
1827 * Build additional details section
1828 *
1829 * @since 1.0.0
1830 *
1831 * @param array $user_input User input data
1832 * @param array $settings Current settings
1833 * @return string Additional details content
1834 */
1835 private function build_additional_details(array $user_input, array $settings): string {
1836 $content = '';
1837 $target_audience = sanitize_text_field($user_input['target_audience'] ?? '');
1838 $key_features = sanitize_textarea_field($user_input['key_features'] ?? '');
1839
1840 if (!empty($target_audience)) {
1841 $content .= "**Target Audience:** {$target_audience}\n\n";
1842 }
1843
1844 if (!empty($key_features)) {
1845 $content .= "**Key Features:**\n";
1846 // The UI field is a multi-line textarea and validation counts by
1847 // newline, so split on newlines (and still tolerate commas) rather
1848 // than commas only — otherwise newline-separated input collapses
1849 // into one broken bullet.
1850 $features = preg_split('/[\r\n,]+/', $key_features);
1851 foreach ($features as $feature) {
1852 $feature = trim($feature);
1853 if (!empty($feature)) {
1854 $content .= "- " . $feature . "\n";
1855 }
1856 }
1857 $content .= "\n";
1858 }
1859
1860 return $content;
1861 }
1862 private function get_default_documentation_links(): string {
1863 $website_url = home_url();
1864 $content = '';
1865
1866 // Add basic WordPress links
1867 $content .= "- [Website Home]({$website_url}): Main website homepage\n";
1868 $content .= "- [Sitemap]({$website_url}/sitemap.xml): Complete site structure\n";
1869
1870 return $content;
1871 }
1872
1873 /**
1874 * Get default technical links based on user input
1875 *
1876 * @since 1.0.0
1877 *
1878 * @param array $user_input User input data
1879 * @return string Default technical links
1880 */
1881 private function get_default_technical_links(array $user_input): string {
1882 $website_url = home_url();
1883 $content = '';
1884
1885 if (!empty($user_input['technical_stack'])) {
1886 $stack = sanitize_text_field($user_input['technical_stack']);
1887 $content .= "- [Technical Stack]({$website_url}): Built with {$stack}\n";
1888 }
1889
1890 if (!empty($user_input['development_approach'])) {
1891 $approach_summary = wp_trim_words($user_input['development_approach'], 10);
1892 $content .= "- [Development Guidelines]({$website_url}): {$approach_summary}\n";
1893 }
1894
1895 // Add robots.txt reference
1896 $content .= "- [Robots.txt]({$website_url}/robots.txt): Site crawling guidelines\n";
1897
1898 return $content;
1899 }
1900 }
1901