PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.11.0 2.10.0 2.9.0 2.8.0 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 All 52 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.10.0, at includes/seo/class-llms-txt-manager.php

2,488 lines 91.6 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 // Prevent direct access
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 // Ensure dependencies are loaded
24 if (!class_exists('ThinkRank\\SEO\\Abstract_SEO_Manager')) {
25 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-abstract-seo-manager.php';
26 }
27
28 if (!interface_exists('ThinkRank\\SEO\\Interfaces\\SEO_Manager_Interface')) {
29 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/interfaces/class-seo-manager-interface.php';
30 }
31
32 /**
33 * LLMs.txt Manager Class
34 *
35 * Manages LLMs.txt file generation, validation, and serving with AI-powered
36 * content creation based on website information and user input.
37 *
38 * @since 1.0.0
39 */
40 class LLMs_Txt_Manager extends Abstract_SEO_Manager {
41
42 /**
43 * WordPress filesystem instance
44 *
45 * @since 1.0.0
46 * @var \WP_Filesystem_Base|null
47 */
48 private $filesystem = null;
49
50 /**
51 * Whether the most recent save_settings() persisted a disable but failed to
52 * remove the published llms.txt file (so it may still be served). Callers
53 * check this via {@see unpublish_failed()} to surface a partial failure.
54 *
55 * @var bool
56 */
57 private bool $last_unpublish_failed = false;
58
59 /**
60 * Message from the last save that switched delivery mode but could not move
61 * the published document, or an empty string when the switch was clean.
62 *
63 * @since 2.1.0
64 * @var string
65 */
66 private string $last_delivery_warning = '';
67
68 /**
69 * Whether the last save's delivery-mode switch failed outright, as opposed
70 * to succeeding with a warning. Both set {@see delivery_switch_warning()},
71 * and only one of them means /llms.txt is still on the old path.
72 *
73 * @since 2.1.0
74 * @var bool
75 */
76 private bool $last_delivery_switch_failed = false;
77
78 /**
79 * LLMs.txt content sections configuration
80 *
81 * @since 1.0.0
82 * @var array
83 */
84 private array $content_sections = [
85 'project_overview' => [
86 'title' => 'Project Overview',
87 'required' => true,
88 'description' => 'High-level description of the website/project purpose',
89 'max_length' => 500
90 ],
91 'key_features' => [
92 'title' => 'Key Features',
93 'required' => true,
94 'description' => 'Main features and functionality of the website, one feature per line. Commas are part of a feature, not separators.',
95 'max_length' => 300
96 ],
97 'architecture' => [
98 'title' => 'Architecture & Components',
99 'required' => false,
100 'description' => 'Technical architecture and key components',
101 'max_length' => 400
102 ],
103 'development_guidelines' => [
104 'title' => 'Development Guidelines',
105 'required' => false,
106 'description' => 'Coding standards and development practices',
107 'max_length' => 300
108 ],
109 'setup_instructions' => [
110 'title' => 'Setup Instructions',
111 'required' => false,
112 'description' => 'How to get the project running',
113 'max_length' => 400
114 ],
115 'ai_context' => [
116 'title' => 'Context for AI Assistants',
117 'required' => true,
118 'description' => 'Specific information to help AI understand the project',
119 'max_length' => 300
120 ]
121 ];
122
123 /**
124 * Maximum file size for LLMs.txt files (1MB)
125 *
126 * @since 1.0.0
127 * @var int
128 */
129 private const MAX_FILE_SIZE = 1048576; // 1MB in bytes
130
131 /**
132 * Marker used for ThinkRank's block in the site's .htaccess.
133 *
134 * The published llms.txt is a physical file, so the web server — not PHP —
135 * serves it and decides the response headers. Apache/LiteSpeed answer .txt
136 * with a bare `Content-Type: text/plain` (no charset), which makes browsers
137 * fall back to their legacy single-byte default and render UTF-8 content as
138 * mojibake ("Aktivitäten" → "Aktivitäten"); `X-Content-Type-Options:
139 * nosniff` removes even the sniffing fallback. This block pins the charset
140 * for that one file. See {@see serve_llms_txt()} for the PHP-served path.
141 *
142 * @var string
143 */
144 private const HTACCESS_MARKER = 'ThinkRank llms.txt';
145
146 /**
147 * Option holding the published llms.txt document.
148 *
149 * The published content lives here regardless of delivery mode, so the
150 * dynamic route has an authoritative source that does not depend on a
151 * physical file, and switching modes never loses the published document.
152 *
153 * @since 2.1.0
154 * @var string
155 */
156 private const CONTENT_OPTION = 'thinkrank_llms_txt_content';
157
158 /**
159 * Option holding the Unix timestamp of the last publish.
160 *
161 * @since 2.1.0
162 * @var string
163 */
164 private const PUBLISHED_AT_OPTION = 'thinkrank_llms_txt_published_at';
165
166 /**
167 * Option recording what the site's public URL really answers /llms.txt with.
168 *
169 * Shaped as ['home' => string, 'result' => 'charset'|'no_charset'|'unknown',
170 * 'checked_at' => int] and keyed on the home URL, so a clone or a migration
171 * re-checks instead of inheriting the verdict of the host it came from.
172 *
173 * @since 2.1.0
174 * @var string
175 */
176 private const DELIVERY_PROBE_OPTION = 'thinkrank_llms_delivery_probe';
177
178 /**
179 * How long an inconclusive delivery check is left alone before retrying.
180 *
181 * A conclusive verdict stands until the document is published again; only
182 * the "could not tell" answer — a blocked loopback, an HTTP-auth'd staging
183 * site — is worth asking about a second time, and not often.
184 *
185 * @since 2.1.0
186 * @var int
187 */
188 private const DELIVERY_PROBE_RETRY = DAY_IN_SECONDS;
189
190 /**
191 * Shown when the server answers the published file without a charset.
192 *
193 * @since 2.1.0
194 * @var string
195 */
196 private const STATIC_CHARSET_WARNING = 'This server answers the published llms.txt without a character set, so accented characters and curly quotes arrive mis-decoded. Set Delivery Method to "Served by WordPress" to publish it as UTF-8.';
197
198 /**
199 * Delivery modes accepted by the `delivery_mode` setting.
200 *
201 * @since 2.1.0
202 * @var string[]
203 */
204 private const DELIVERY_MODES = ['auto', 'static', 'dynamic'];
205
206 /**
207 * One-time marker for {@see LLMs_Txt_Manager::maybe_migrate_legacy_key_features()}.
208 *
209 * Public so the activator can record it on a fresh install, which has no
210 * value saved under the old comma rule and must never be migrated.
211 *
212 * @since 2.10.0
213 * @var string
214 */
215 public const KEY_FEATURES_MIGRATION_OPTION = 'thinkrank_llms_key_features_migration';
216 public const KEY_FEATURES_MIGRATION_VERSION = '1';
217
218 /**
219 * Business type templates for content generation
220 *
221 * @since 1.0.0
222 * @var array
223 */
224 private array $business_types = [
225 'website' => 'website',
226 'blog' => 'Personal or professional blog',
227 'business' => 'Business/corporate website',
228 'ecommerce' => 'E-commerce/online store',
229 'portfolio' => 'Portfolio/showcase website',
230 'nonprofit' => 'Non-profit organization',
231 'educational' => 'Educational institution',
232 'news' => 'News/media website',
233 'community' => 'Community/forum website',
234 'saas' => 'Software as a Service',
235 'agency' => 'Agency/service provider',
236 'other' => 'Other type of website'
237 ];
238
239 /**
240 * Constructor
241 *
242 * @since 1.0.0
243 */
244 public function __construct() {
245 parent::__construct('llms_txt');
246 }
247
248 /**
249 * Initialize WordPress filesystem
250 *
251 * @since 1.0.0
252 * @return bool True if filesystem is initialized, false otherwise
253 */
254 private function init_filesystem(): bool {
255 if ($this->filesystem !== null) {
256 return true;
257 }
258
259 global $wp_filesystem;
260
261 if (!function_exists('WP_Filesystem')) {
262 require_once ABSPATH . 'wp-admin/includes/file.php';
263 }
264
265 $credentials = request_filesystem_credentials('', '', false, false, null);
266 if (!WP_Filesystem($credentials)) {
267 return false;
268 }
269
270 $this->filesystem = $wp_filesystem;
271 return true;
272 }
273
274 /**
275 * Check if directory is writable using WP_Filesystem
276 *
277 * @since 1.0.0
278 * @param string $path Directory path to check
279 * @return bool True if writable, false otherwise
280 */
281 private function is_directory_writable(string $path): bool {
282 if (!$this->init_filesystem()) {
283 return false;
284 }
285
286 return $this->filesystem->is_writable($path);
287 }
288
289 /**
290 * Check if file is writable using WP_Filesystem
291 *
292 * @since 1.0.0
293 * @param string $file File path to check
294 * @return bool True if writable, false otherwise
295 */
296 private function is_file_writable(string $file): bool {
297 if (!$this->init_filesystem()) {
298 return false;
299 }
300
301 return $this->filesystem->is_writable($file);
302 }
303 public function generate_llms_txt(array $user_input, array $options = []): array {
304 $llms_data = [
305 'content' => '',
306 'sections' => [],
307 'metadata' => [],
308 'validation' => [],
309 'file_info' => []
310 ];
311
312 // Generating from saved settings (the MCP ability passes an empty
313 // payload) can happen before any admin request has run the upgrade,
314 // so make sure a legacy comma list has been converted first.
315 self::maybe_migrate_legacy_key_features();
316
317 // Get current settings
318 $settings = $this->get_settings('site');
319
320 // Merge saved settings underneath the provided input so that empty or
321 // partial $user_input falls back to the persisted configuration.
322 // Explicitly provided (non-empty) values win; blank ones are filled from
323 // saved settings. This lets callers generate from saved settings by
324 // passing an empty payload (e.g. the generate-llms-txt MCP ability),
325 // matching the documented behavior.
326 $provided = array_filter(
327 $user_input,
328 static function ($value) {
329 if (is_string($value)) {
330 return '' !== trim($value);
331 }
332 return null !== $value && [] !== $value;
333 }
334 );
335 $user_input = array_merge($settings, $provided);
336
337 // Check file status
338 $llms_file = ABSPATH . 'llms.txt';
339 $llms_data['file_info'] = [
340 'file_exists' => file_exists($llms_file),
341 'writable' => $this->is_directory_writable(dirname($llms_file)),
342 'file_path' => $llms_file,
343 'last_modified' => file_exists($llms_file) ? filemtime($llms_file) : null
344 ];
345
346 // Validate user input
347 $validation = $this->validate_user_input($user_input);
348 $llms_data['validation'] = $validation;
349
350 if (!$validation['valid']) {
351 return $llms_data;
352 }
353
354 // Generate content sections
355 $llms_data['sections'] = $this->build_content_sections($user_input, $settings);
356
357 // Build final LLMs.txt content
358 $site_name = $user_input['site_name'] ?? $settings['site_name'] ?? get_bloginfo('name');
359 $llms_data['content'] = $this->build_llms_txt_content($llms_data['sections'], $site_name);
360
361 // Add metadata
362 $llms_data['metadata'] = [
363 'generated_at' => gmdate('c'),
364 'website_url' => home_url(),
365 'generator' => 'ThinkRank SEO Plugin',
366 'content_length' => strlen($llms_data['content']),
367 'sections_count' => count($llms_data['sections'])
368 ];
369
370 return $llms_data;
371 }
372
373 /**
374 * Persist settings, unpublishing the physical file when the feature is
375 * disabled so a disable actually stops serving /llms.txt.
376 *
377 * @param string $context_type Context type.
378 * @param int|null $context_id Context ID.
379 * @param array $settings Settings to save.
380 * @return bool
381 */
382 public function save_settings(string $context_type, ?int $context_id, array $settings): bool {
383 $this->last_unpublish_failed = false;
384 $this->last_delivery_warning = '';
385 $this->last_delivery_switch_failed = false;
386 $previous_mode = $this->resolve_delivery_mode();
387
388 $result = parent::save_settings($context_type, $context_id, $settings);
389
390 // The cached status carries the resolved delivery mode, so it goes stale
391 // the moment settings change — even when nothing needs republishing.
392 delete_transient('thinkrank_llms_file_status');
393
394 // When a save explicitly disables the feature, delete the published file.
395 if ($result && array_key_exists('enabled', $settings) && empty($settings['enabled'])) {
396 if (!$this->delete_llms_txt_file()) {
397 // The settings were persisted, but the physical file could not be
398 // removed, so /llms.txt may still be served. Record it so callers
399 // report a partial failure instead of an unqualified success.
400 $this->last_unpublish_failed = true;
401 }
402
403 return $result;
404 }
405
406 // The published document has to sit where the active mode serves it from,
407 // or the site keeps answering on the old path: a leftover physical file
408 // shadows the dynamic route on every stack, and a database-only document
409 // is invisible to a stack now expecting a file. Reconciled on any save,
410 // not just an explicit mode change, so a site whose auto-detection now
411 // resolves differently — an nginx install upgrading into this fix with a
412 // static file already on disk — heals the next time settings are saved.
413 if ($result && $this->delivery_needs_reconcile($previous_mode)) {
414 $this->republish_for_delivery_mode();
415 }
416
417 return $result;
418 }
419
420 /**
421 * Whether the published document is out of step with the active mode.
422 *
423 * @since 2.1.0
424 *
425 * @param string $previous_mode Mode in force before the save.
426 * @return bool
427 */
428 private function delivery_needs_reconcile(string $previous_mode): bool {
429 $mode = $this->resolve_delivery_mode();
430 $file_exists = file_exists(ABSPATH . 'llms.txt');
431
432 if ('dynamic' === $mode) {
433 // A physical file would be served instead of the PHP route.
434 return $file_exists;
435 }
436
437 // Static: a stored document with no file behind it is unreachable on a
438 // stack that expects one. A mode flip also forces the charset block to
439 // be (re)written for a file that predates it.
440 return (!$file_exists && '' !== trim($this->get_published_content()))
441 || $mode !== $previous_mode;
442 }
443
444 /**
445 * Re-publish the current document under the active delivery mode.
446 *
447 * A no-op when nothing is published yet — this only moves an existing
448 * document, it never publishes on the user's behalf.
449 *
450 * @since 2.1.0
451 *
452 * @return void
453 */
454 private function republish_for_delivery_mode(): void {
455 $content = $this->get_published_content();
456
457 if ('' === trim($content)) {
458 // Published before the stored copy existed: recover it from the file.
459 $llms_file = ABSPATH . 'llms.txt';
460 if (file_exists($llms_file)) {
461 $read_result = $this->safe_file_read($llms_file);
462 if ($read_result['success']) {
463 $content = $read_result['content'];
464 }
465 }
466 }
467
468 if ('' === trim($content)) {
469 return;
470 }
471
472 $write = $this->write_llms_txt_to_file($content);
473
474 // The switch itself failed (an unwritable root on the way to static, a
475 // stuck file on the way to dynamic). The settings are saved, so report
476 // it rather than letting the mode read as applied when it is not.
477 if (empty($write['success'])) {
478 $this->last_delivery_warning = isset($write['message']) && '' !== (string) $write['message']
479 ? (string) $write['message']
480 : 'The delivery method was saved, but the published llms.txt could not be moved to it.';
481 $this->last_delivery_switch_failed = true;
482 return;
483 }
484
485 // The switch worked, but static delivery on this server cannot carry the
486 // charset the document needs. Only an explicitly chosen `static` gets
487 // this far — `auto` moves itself to WordPress delivery instead.
488 if (!empty($write['delivery_warning'])) {
489 $this->last_delivery_warning = (string) $write['delivery_warning'];
490 }
491 }
492
493 /**
494 * Message from the last save whose delivery-mode switch could not be
495 * applied to the already-published document, or '' when there was none.
496 *
497 * @since 2.1.0
498 *
499 * @return string
500 */
501 public function delivery_switch_warning(): string {
502 return $this->last_delivery_warning;
503 }
504
505 /**
506 * Whether the last save's warning was a failed switch rather than a
507 * successful one the server cannot serve correctly.
508 *
509 * @since 2.1.0
510 *
511 * @return bool
512 */
513 public function delivery_switch_failed(): bool {
514 return $this->last_delivery_switch_failed;
515 }
516
517 /**
518 * Whether the last save_settings() disabled the feature but could not remove
519 * the published llms.txt file (which may therefore still be served).
520 *
521 * @return bool
522 */
523 public function unpublish_failed(): bool {
524 return $this->last_unpublish_failed;
525 }
526
527 /**
528 * Resolve the effective delivery mode for /llms.txt.
529 *
530 * `static` publishes a physical ABSPATH/llms.txt and lets the web server
531 * answer it; `dynamic` keeps the document in the database and lets the PHP
532 * route in {@see serve_llms_txt()} answer it. `auto` picks static only on
533 * Apache/LiteSpeed, the stacks that read the .htaccess charset block — on
534 * nginx a physical file is served with a bare `Content-Type: text/plain`
535 * that neither fix path can reach, which renders UTF-8 as mojibake (#419).
536 *
537 * $is_apache is not trusted on its own: WordPress reads it from
538 * $_SERVER['SERVER_SOFTWARE'], which describes the server that runs PHP
539 * rather than the one answering the public request. A reverse proxy hides
540 * the difference — an nginx edge in front of an Apache backend reports
541 * Apache, so `auto` chose the file that nginx then served with no charset,
542 * which is the very defect the setting was added to avoid (#493). A
543 * publish-time self-request settles what the detection cannot see, and its
544 * verdict is what this consults; an explicit setting still wins outright.
545 *
546 * @since 2.1.0
547 *
548 * @param string|null $mode Optional. Raw setting value; read from the saved
549 * settings when null.
550 * @return string Either 'static' or 'dynamic'.
551 */
552 public function resolve_delivery_mode(?string $mode = null): string {
553 if (null === $mode) {
554 $settings = $this->get_settings('site');
555 $mode = (string) ($settings['delivery_mode'] ?? 'auto');
556 }
557
558 if ('static' === $mode || 'dynamic' === $mode) {
559 return $mode;
560 }
561
562 // A root PHP cannot write to has no static path at all: publishing
563 // would simply fail and /llms.txt would 404. The sitemap's `auto`
564 // already resolves this way (#754); llms.txt did not, so on an
565 // Apache/LiteSpeed host with a read-only root — a managed stack such as
566 // Flywheel, where ABSPATH is the locked core folder — `auto` chose
567 // static and then could not deliver it (#756).
568 if (!wp_is_writable(ABSPATH)) {
569 return 'dynamic';
570 }
571
572 // $is_apache also covers LiteSpeed, which reads .htaccess the same way.
573 if (empty($GLOBALS['is_apache'])) {
574 return 'dynamic';
575 }
576
577 // Detection says this stack reads the .htaccess charset block. Believe
578 // it unless a self-request has caught the public URL answering without
579 // a charset, which is what a reverse-proxied stack does (#493).
580 return $this->static_delivery_drops_charset() ? 'dynamic' : 'static';
581 }
582
583 /**
584 * Whether the recorded check caught the public URL dropping the charset.
585 *
586 * @since 2.1.0
587 *
588 * @return bool
589 */
590 private function static_delivery_drops_charset(): bool {
591 return 'no_charset' === ($this->delivery_probe()['result'] ?? '');
592 }
593
594 /**
595 * The delivery check recorded for this site, or [] when there is none.
596 *
597 * @since 2.1.0
598 *
599 * @return array
600 */
601 private function delivery_probe(): array {
602 $probe = get_option(self::DELIVERY_PROBE_OPTION, []);
603
604 if (!is_array($probe) || !isset($probe['result'])) {
605 return [];
606 }
607
608 return ($probe['home'] ?? '') === home_url() ? $probe : [];
609 }
610
611 /**
612 * Ask the site's own public URL what it answers /llms.txt with.
613 *
614 * @since 2.1.0
615 *
616 * @return string 'charset', 'no_charset', or 'unknown' when the response
617 * could not be read and nothing should be concluded from it.
618 */
619 private function probe_static_delivery(): string {
620 if (!function_exists('wp_remote_get')) {
621 return 'unknown';
622 }
623
624 // The cache-buster stops a page cache from answering with a copy stored
625 // before the file was written; a server ignores the query string when it
626 // serves a physical file, so the response still shows the real headers.
627 $url = add_query_arg(
628 'thinkrank-delivery-check',
629 (string) time(),
630 home_url('/llms.txt')
631 );
632
633 $response = wp_remote_get($url, [
634 'timeout' => 5,
635 'redirection' => 2,
636 // A request to our own home URL, from which a single response header
637 // is read. Staging and local installs routinely run on certificates
638 // this host does not trust, and failing there would leave the very
639 // sites most likely to be misconfigured unchecked.
640 'sslverify' => false,
641 'headers' => ['Cache-Control' => 'no-cache'],
642 ]);
643
644 if (is_wp_error($response) || 200 !== (int) wp_remote_retrieve_response_code($response)) {
645 return 'unknown';
646 }
647
648 $content_type = wp_remote_retrieve_header($response, 'content-type');
649
650 // A header sent more than once comes back as an array.
651 if (is_array($content_type)) {
652 $content_type = implode(' ', $content_type);
653 }
654
655 $content_type = trim((string) $content_type);
656
657 // No Content-Type at all is the same problem: the browser is left to
658 // guess the encoding.
659 if ('' === $content_type) {
660 return 'no_charset';
661 }
662
663 return false !== stripos($content_type, 'charset=') ? 'charset' : 'no_charset';
664 }
665
666 /**
667 * Persist the outcome of a delivery check.
668 *
669 * @since 2.1.0
670 *
671 * @param string $verdict One of 'charset', 'no_charset', 'unknown'.
672 * @return void
673 */
674 private function record_delivery_probe(string $verdict): void {
675 update_option(self::DELIVERY_PROBE_OPTION, [
676 'home' => home_url(),
677 'result' => $verdict,
678 'checked_at' => time(),
679 ], false);
680 }
681
682 /**
683 * Confirm the published file is really served with a charset, and act on it.
684 *
685 * Static delivery leans on an .htaccess directive, so it is only ever as
686 * good as the guess that the server reads .htaccess. This checks the guess
687 * against the response the public URL actually returns: a site left on
688 * `auto` is moved to WordPress delivery when the charset is missing — the
689 * file has to go with it, or it would shadow the PHP route that carries the
690 * charset — while a site that asked for `static` keeps its file and gets a
691 * warning, because an explicit choice is not overruled.
692 *
693 * @since 2.1.0
694 *
695 * @param array $result Publish result to annotate.
696 * @return array The annotated result.
697 */
698 private function verify_static_delivery(array $result): array {
699 $verdict = $this->probe_static_delivery();
700
701 $this->record_delivery_probe($verdict);
702
703 if ('no_charset' !== $verdict) {
704 return $result;
705 }
706
707 $settings = $this->get_settings('site');
708 $explicit = 'static' === (string) ($settings['delivery_mode'] ?? 'auto');
709
710 // Auto: resolve_delivery_mode() answers 'dynamic' from here on, so the
711 // file it would otherwise leave behind has to be removed. The document
712 // is already stored, so nothing is lost by deleting it.
713 if (!$explicit && $this->delete_static_file()) {
714 delete_transient('thinkrank_llms_file_status');
715 $this->purge_llms_txt_caches();
716
717 $result['delivery_mode'] = 'dynamic';
718 $result['charset_pinned'] = true;
719 $result['message'] = 'LLMs.txt published. This server answers a static file without a character set, so WordPress serves it as UTF-8 instead.';
720 $result['permissions']['file_exists'] = false;
721 $result['permissions']['file_writable'] = null;
722
723 return $result;
724 }
725
726 $result['charset_pinned'] = false;
727 $result['delivery_warning'] = self::STATIC_CHARSET_WARNING;
728 $result['message'] = trim((string) $result['message'] . ' ' . self::STATIC_CHARSET_WARNING);
729
730 return $result;
731 }
732
733 /**
734 * Whether the delivery check may run on this request.
735 *
736 * It makes an HTTP request of its own, so it never runs on a front-end
737 * page view — only where an administrator, the REST API, WP-CLI or cron is
738 * already waiting on a status read.
739 *
740 * @since 2.1.0
741 *
742 * @return bool
743 */
744 private function delivery_probe_is_due(): bool {
745 $interactive = is_admin()
746 || (defined('REST_REQUEST') && REST_REQUEST)
747 || (defined('WP_CLI') && WP_CLI)
748 || (function_exists('wp_doing_cron') && wp_doing_cron());
749
750 if (!$interactive) {
751 return false;
752 }
753
754 $probe = $this->delivery_probe();
755
756 if ([] === $probe) {
757 return true;
758 }
759
760 if ('unknown' !== $probe['result']) {
761 return false;
762 }
763
764 return (time() - (int) ($probe['checked_at'] ?? 0)) > self::DELIVERY_PROBE_RETRY;
765 }
766
767 /**
768 * The published llms.txt document, or an empty string when unpublished.
769 *
770 * @since 2.1.0
771 *
772 * @return string
773 */
774 public function get_published_content(): string {
775 $content = get_option(self::CONTENT_OPTION, '');
776
777 return is_string($content) ? $content : '';
778 }
779
780 /**
781 * Whether /llms.txt is currently being served, in either delivery mode.
782 *
783 * `static` publishes a file at ABSPATH; `dynamic` keeps the document in
784 * an option and answers from serve_llms_txt(). Callers that only need
785 * this yes/no must use it in preference to get_llms_txt_status(), which
786 * resolves the delivery mode, may fire a loopback delivery probe, asks
787 * the filesystem API whether ABSPATH is writable, reads the document and
788 * writes a transient — far too much work for a boolean, and not
789 * something a dashboard summary should be triggering.
790 *
791 * @since 2.2.1
792 *
793 * @return bool
794 */
795 public function is_published(): bool {
796 return file_exists(ABSPATH . 'llms.txt')
797 || '' !== trim($this->get_published_content());
798 }
799
800 /**
801 * Ask the common page/CDN cache layers to drop their copy of /llms.txt.
802 *
803 * A cached response outlives a republish, so without this a mode switch or
804 * a content change keeps serving the old document (and, on the static path,
805 * the old headers). Every call is guarded — a site running none of these
806 * simply gets the action hook, which integrations can use.
807 *
808 * @since 2.1.0
809 *
810 * @return void
811 */
812 private function purge_llms_txt_caches(): void {
813 $url = home_url('/llms.txt');
814
815 /**
816 * Fires after the published llms.txt changes, so cache layers ThinkRank
817 * does not know about can drop their copy.
818 *
819 * @since 2.1.0
820 *
821 * @param string $url Public URL of the llms.txt document.
822 */
823 do_action('thinkrank_llms_txt_updated', $url);
824
825 // LiteSpeed Cache and Nginx Helper both listen on their own actions.
826 // These are third-party hook names we fire, not ours to prefix.
827 do_action('litespeed_purge_url', $url); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
828 do_action('rt_nginx_helper_purge_all'); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
829
830 if (function_exists('rocket_clean_files')) {
831 rocket_clean_files([$url]);
832 }
833 if (function_exists('w3tc_flush_url')) {
834 w3tc_flush_url($url);
835 }
836 if (function_exists('wpsc_delete_url_cache')) {
837 wpsc_delete_url_cache($url);
838 }
839 }
840
841 /**
842 * Unpublish llms.txt: drop the stored document and any physical file.
843 *
844 * Both delivery modes are cleared, not just the active one, so a site that
845 * published under one mode and switched to the other is left with nothing
846 * still being served.
847 *
848 * @return bool True once nothing is left to serve.
849 */
850 public function delete_llms_txt_file(): bool {
851 delete_option(self::CONTENT_OPTION);
852 delete_option(self::PUBLISHED_AT_OPTION);
853
854 return $this->unpublish_static_file();
855 }
856
857 /**
858 * Stop serving llms.txt, but keep the document.
859 *
860 * Deactivation needs this half: the physical file must go — it shadows the
861 * next plugin's routes and advertises a plugin that is switched off — but
862 * the user's prose has to survive so reactivation can republish it.
863 * {@see \ThinkRank\Core\Activator::restore_webroot_artifacts()} does that.
864 *
865 * Deactivation previously called {@see delete_llms_txt_file()}, which drops
866 * the stored document too, so a deactivate/reactivate round-trip silently
867 * lost whatever the user had written.
868 *
869 * @since 2.1.0
870 *
871 * @return bool True once nothing is left on disk.
872 */
873 public function unpublish_static_file(): bool {
874 delete_transient('thinkrank_llms_file_status');
875
876 $removed = $this->delete_static_file();
877 $this->purge_llms_txt_caches();
878
879 return $removed;
880 }
881
882 /**
883 * Remove the physical ABSPATH/llms.txt and its .htaccess charset block.
884 *
885 * @since 2.1.0
886 *
887 * @return bool True if the file is absent or was removed.
888 */
889 private function delete_static_file(): bool {
890 $llms_file = ABSPATH . 'llms.txt';
891 if (!file_exists($llms_file)) {
892 $this->remove_htaccess_charset();
893 return true;
894 }
895 if (!$this->init_filesystem()) {
896 return false;
897 }
898
899 $deleted = (bool) $this->filesystem->delete($llms_file);
900 if ($deleted) {
901 // Leave no orphaned rule behind once the file is gone.
902 $this->remove_htaccess_charset();
903 }
904
905 return $deleted;
906 }
907
908 /**
909 * Pin the served charset of the physical llms.txt to UTF-8 via .htaccess.
910 *
911 * Scoped to the single file with <Files>, and wrapped in <IfModule> so a
912 * server without mod_mime ignores it instead of returning a 500. Nginx does
913 * not read .htaccess — there the PHP route in {@see serve_llms_txt()} is
914 * what carries the charset, provided no physical file shadows it.
915 *
916 * @since 1.32.0
917 *
918 * @return bool True when the block is in place.
919 */
920 private function sync_htaccess_charset(): bool {
921 // $is_apache also covers LiteSpeed, which reads .htaccess the same way.
922 if (empty($GLOBALS['is_apache'])) {
923 return false;
924 }
925
926 $htaccess = ABSPATH . '.htaccess';
927
928 if (file_exists($htaccess)) {
929 if (!$this->is_file_writable($htaccess)) {
930 return false;
931 }
932 } elseif (!$this->is_directory_writable(ABSPATH)) {
933 return false;
934 }
935
936 if (!function_exists('insert_with_markers')) {
937 require_once ABSPATH . 'wp-admin/includes/misc.php';
938 }
939
940 return (bool) insert_with_markers($htaccess, self::HTACCESS_MARKER, [
941 '<IfModule mod_mime.c>',
942 '<Files "llms.txt">',
943 "ForceType 'text/plain; charset=UTF-8'",
944 '</Files>',
945 '</IfModule>',
946 ]);
947 }
948
949 /**
950 * Remove ThinkRank's charset block from .htaccess.
951 *
952 * Strips the block outright rather than calling insert_with_markers() with
953 * an empty insertion — that leaves the BEGIN/END markers behind as litter.
954 *
955 * @since 1.32.0
956 *
957 * @return void
958 */
959 private function remove_htaccess_charset(): void {
960 $htaccess = ABSPATH . '.htaccess';
961
962 if (!file_exists($htaccess) || !$this->is_file_writable($htaccess)) {
963 return;
964 }
965
966 if (!$this->init_filesystem()) {
967 return;
968 }
969
970 $contents = $this->filesystem->get_contents($htaccess);
971 if (!is_string($contents) || false === strpos($contents, '# BEGIN ' . self::HTACCESS_MARKER)) {
972 return;
973 }
974
975 $marker = preg_quote(self::HTACCESS_MARKER, '/');
976 $cleaned = preg_replace(
977 '/\R*# BEGIN ' . $marker . '.*?# END ' . $marker . '[ \t]*\R?/s',
978 '',
979 $contents
980 );
981
982 if (!is_string($cleaned)) {
983 return;
984 }
985
986 // A file left holding nothing but our (now removed) block was ours to
987 // begin with — a pre-existing .htaccess would still have content.
988 if ('' === trim($cleaned)) {
989 $this->filesystem->delete($htaccess);
990 return;
991 }
992
993 // Keep the file newline-terminated after the block is cut out.
994 $this->filesystem->put_contents($htaccess, rtrim($cleaned, "\r\n") . "\n", FS_CHMOD_FILE);
995 }
996
997 /**
998 * Serve /llms.txt from PHP with an explicit UTF-8 charset.
999 *
1000 * Only reached when the request actually gets to WordPress — i.e. when no
1001 * physical llms.txt shadows the route, or on a stack that routes every
1002 * request through index.php. Prefers the published file's exact bytes and
1003 * falls back to regenerating from the saved settings, so the response is
1004 * the same document either way, just with headers PHP controls.
1005 *
1006 * Called by \ThinkRank\Frontend\SEO_Manager on template_redirect.
1007 *
1008 * @since 1.32.0
1009 *
1010 * @return void
1011 */
1012 public function serve_llms_txt(): void {
1013 $settings = $this->get_settings('site');
1014
1015 // Never resurrect the file for a site that turned the feature off.
1016 if (empty($settings['enabled'])) {
1017 return;
1018 }
1019
1020 $content = '';
1021 $llms_file = ABSPATH . 'llms.txt';
1022
1023 // In static mode a physical file is what the server would normally hand
1024 // back, so prefer its exact bytes; in dynamic mode there is no file and
1025 // the stored document is the authoritative copy.
1026 if ('static' === $this->resolve_delivery_mode() && file_exists($llms_file)) {
1027 $read_result = $this->safe_file_read($llms_file);
1028 if ($read_result['success']) {
1029 $content = $read_result['content'];
1030 }
1031 }
1032
1033 if ('' === trim($content)) {
1034 $content = $this->get_published_content();
1035 }
1036
1037 if ('' === trim($content)) {
1038 $generated = $this->generate_llms_txt([]);
1039 $content = (string) ($generated['content'] ?? '');
1040 }
1041
1042 // Nothing configured yet: leave the 404 alone rather than serving a stub.
1043 if ('' === trim($content)) {
1044 return;
1045 }
1046
1047 status_header(200);
1048 header('Content-Type: text/plain; charset=utf-8');
1049
1050 // Plain-text file body — already sanitized on save by
1051 // sanitize_llms_content(); escaping it here would corrupt the markdown.
1052 echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1053 exit;
1054 }
1055
1056 /**
1057 * Write LLMs.txt content to filesystem
1058 *
1059 * @since 1.0.0
1060 *
1061 * @param string $content LLMs.txt content to write
1062 * @return array Write operation result
1063 */
1064 public function write_llms_txt_to_file(string $content): array {
1065 $result = [
1066 'success' => false,
1067 'message' => '',
1068 'file_path' => '',
1069 'permissions' => []
1070 ];
1071
1072 // Refuse to publish when the feature is disabled. The React UI hides the
1073 // publish button, but the REST endpoint and the MCP publish ability call
1074 // this directly, so enforce the toggle here at the single write choke point.
1075 $settings = $this->get_settings('site');
1076 if (empty($settings['enabled'])) {
1077 $result['message'] = 'LLMs.txt is disabled. Enable it before publishing.';
1078 return $result;
1079 }
1080
1081 $mode = $this->resolve_delivery_mode();
1082 $result['delivery_mode'] = $mode;
1083
1084 $llms_file = ABSPATH . 'llms.txt';
1085 $result['file_path'] = $llms_file;
1086
1087 // Dynamic delivery: the document lives in the database and /llms.txt is
1088 // answered by serve_llms_txt(), which sets `charset=utf-8` itself. A
1089 // physical file would shadow that route on every stack, so any leftover
1090 // from a previous static publish has to go.
1091 if ('dynamic' === $mode) {
1092 if (!$this->delete_static_file()) {
1093 $result['message'] = 'A physical llms.txt is still present and could not be removed. It would be served instead of the dynamic route.';
1094 return $result;
1095 }
1096
1097 $this->store_published_content($content);
1098
1099 $result['success'] = true;
1100 $result['message'] = 'LLMs.txt published. It is served by WordPress as UTF-8 text.';
1101 $result['bytes_written'] = strlen($content);
1102 $result['charset_pinned'] = true;
1103 $result['permissions'] = [
1104 'directory_writable' => $this->is_directory_writable(ABSPATH),
1105 'file_exists' => false,
1106 'file_writable' => null,
1107 ];
1108
1109 return $result;
1110 }
1111
1112 // Security: Validate file path to prevent path traversal attacks
1113 $real_llms_file = realpath(dirname($llms_file)) . DIRECTORY_SEPARATOR . basename($llms_file);
1114 $allowed_dir = realpath(ABSPATH);
1115
1116 if (!$allowed_dir || strpos(dirname($real_llms_file), $allowed_dir) !== 0) {
1117 $result['message'] = 'Invalid file path detected for security reasons.';
1118 return $result;
1119 }
1120
1121 // Check directory permissions
1122 $result['permissions'] = [
1123 'directory_writable' => $this->is_directory_writable(ABSPATH),
1124 'file_exists' => file_exists($llms_file),
1125 'file_writable' => file_exists($llms_file) ? $this->is_file_writable($llms_file) : null
1126 ];
1127
1128 // Check if we can write to the directory
1129 if (!$result['permissions']['directory_writable']) {
1130 $result['message'] = 'WordPress root directory is not writable. Please check file permissions.';
1131 return $result;
1132 }
1133
1134 // Check if existing file is writable (if it exists)
1135 if ($result['permissions']['file_exists'] && !$result['permissions']['file_writable']) {
1136 $result['message'] = 'Existing llms.txt file is not writable. Please check file permissions.';
1137 return $result;
1138 }
1139
1140 // Write new content using WP_Filesystem
1141 if (!$this->init_filesystem()) {
1142 $result['message'] = 'Could not initialize WordPress filesystem.';
1143 return $result;
1144 }
1145
1146 if (!$this->filesystem->put_contents($llms_file, $content, FS_CHMOD_FILE)) {
1147 $result['message'] = 'Failed to write llms.txt file.';
1148 return $result;
1149 }
1150
1151 $result['success'] = true;
1152 $result['message'] = 'LLMs.txt file written successfully.';
1153 $result['bytes_written'] = strlen($content);
1154
1155 // Pin the served charset to UTF-8. Best effort: a site without a
1156 // writable .htaccess (or not on Apache/LiteSpeed) still gets a
1157 // correctly written file, so this must never fail the publish.
1158 $result['charset_pinned'] = $this->sync_htaccess_charset();
1159
1160 // Keep the stored copy in step with the file so a later switch to
1161 // dynamic delivery serves the same document.
1162 $this->store_published_content($content);
1163
1164 // Detection said this server reads the .htaccess block. Check what the
1165 // public URL really answers with before leaving the file in place — on a
1166 // reverse-proxied stack the detection describes the wrong server (#493).
1167 return $this->verify_static_delivery($result);
1168 }
1169
1170 /**
1171 * Persist the published document and bust the caches that mirror it.
1172 *
1173 * @since 2.1.0
1174 *
1175 * @param string $content Published llms.txt content.
1176 * @return void
1177 */
1178 private function store_published_content(string $content): void {
1179 update_option(self::CONTENT_OPTION, $content, false);
1180 update_option(self::PUBLISHED_AT_OPTION, time(), false);
1181
1182 // Invalidate file status cache since the published document has changed
1183 delete_transient('thinkrank_llms_file_status');
1184
1185 $this->purge_llms_txt_caches();
1186 }
1187
1188 /**
1189 * Get LLMs.txt file status and information
1190 *
1191 * @since 1.0.0
1192 *
1193 * @return array File status information
1194 */
1195 public function get_llms_txt_status(bool $force_refresh = false): array {
1196 // Check cache first (5 minute cache for performance)
1197 $cache_key = 'thinkrank_llms_file_status';
1198
1199 if (!$force_refresh) {
1200 $cached_status = get_transient($cache_key);
1201 if ($cached_status !== false) {
1202 return $cached_status;
1203 }
1204 }
1205
1206 $llms_file = ABSPATH . 'llms.txt';
1207 $mode = $this->resolve_delivery_mode();
1208
1209 // A site that published before this check existed — or whose server has
1210 // changed under it — has never had its delivery confirmed. Do it here so
1211 // an already-broken install heals without waiting for a republish; the
1212 // recorded verdict and the status cache keep it to a couple of requests
1213 // a day at most.
1214 if ('static' === $mode && file_exists($llms_file) && $this->delivery_probe_is_due()) {
1215 $this->verify_static_delivery(['message' => '']);
1216 $mode = $this->resolve_delivery_mode();
1217 }
1218
1219 $stored = $this->get_published_content();
1220
1221 $status = [
1222 'file_exists' => file_exists($llms_file),
1223 // Whether /llms.txt is actually being served, either mode. Prefer
1224 // this over file_exists, which is only meaningful in static mode.
1225 'published' => $this->is_published(),
1226 'delivery_mode' => $mode,
1227 'file_path' => 'dynamic' === $mode ? '' : $llms_file,
1228 'file_url' => home_url('/llms.txt'),
1229 'writable' => $this->is_directory_writable(dirname($llms_file)),
1230 // Non-empty only when the site is on static delivery that the server
1231 // is known to answer without a charset — i.e. an explicit `static`
1232 // the plugin will not overrule, which is the user's to fix.
1233 'delivery_warning' => 'static' === $mode && $this->static_delivery_drops_charset()
1234 ? self::STATIC_CHARSET_WARNING
1235 : '',
1236 'last_modified' => null,
1237 'file_size' => null,
1238 'content_preview' => ''
1239 ];
1240
1241 $content = null;
1242
1243 if ($status['file_exists']) {
1244 $status['last_modified'] = filemtime($llms_file);
1245 $status['file_size'] = filesize($llms_file);
1246
1247 $read_result = $this->safe_file_read($llms_file);
1248 if ($read_result['success']) {
1249 $content = $read_result['content'];
1250 } else {
1251 $status['content_preview'] = 'Error: ' . $read_result['error'];
1252 $status['read_error'] = $read_result['error'];
1253 }
1254 } elseif ('' !== trim($stored)) {
1255 $published_at = (int) get_option(self::PUBLISHED_AT_OPTION, 0);
1256 $status['last_modified'] = $published_at > 0 ? $published_at : null;
1257 $status['file_size'] = strlen($stored);
1258 $content = $stored;
1259 }
1260
1261 if (null !== $content) {
1262 // Get content preview (first 200 characters) with size safety
1263 // substr()/strlen() count BYTES, so this cut a multibyte character
1264 // in half and shipped an invalid UTF-8 sequence in the preview (#687).
1265 $status['content_preview'] = \ThinkRank\Core\Seo_Text::trim_to_length($content, 200);
1266 }
1267
1268 // Cache the result for 5 minutes to improve performance
1269 set_transient($cache_key, $status, 5 * MINUTE_IN_SECONDS);
1270
1271 return $status;
1272 }
1273
1274 /**
1275 * Validate LLMs.txt content
1276 *
1277 * @since 1.0.0
1278 *
1279 * @param string $content LLMs.txt content to validate
1280 * @return array Validation results
1281 */
1282 public function validate_llms_txt_content(string $content): array {
1283 $validation = [
1284 'valid' => true,
1285 'errors' => [],
1286 'warnings' => [],
1287 'suggestions' => [],
1288 'score' => 100
1289 ];
1290
1291 // Check if content is empty
1292 if (empty(trim($content))) {
1293 $validation['errors'][] = 'LLMs.txt content cannot be empty';
1294 $validation['valid'] = false;
1295 $validation['score'] = 0;
1296 return $validation;
1297 }
1298
1299 // Check content length
1300 $content_length = strlen($content);
1301 if ($content_length < 100) {
1302 $validation['warnings'][] = 'LLMs.txt content is very short, consider adding more details';
1303 $validation['score'] -= 20;
1304 } elseif ($content_length > 10000) {
1305 $validation['warnings'][] = 'LLMs.txt content is very long, consider condensing key information';
1306 $validation['score'] -= 10;
1307 }
1308
1309 // Check for required sections
1310 $required_sections = ['Project Overview', 'Key Features', 'Context for AI Assistants'];
1311 foreach ($required_sections as $section) {
1312 if (stripos($content, $section) === false) {
1313 $validation['warnings'][] = "Missing recommended section: {$section}";
1314 $validation['score'] -= 15;
1315 }
1316 }
1317
1318 // Check for proper structure
1319 if (!preg_match('/^#\s+/', $content)) {
1320 $validation['suggestions'][] = 'Consider starting with a main heading (# Project Name)';
1321 $validation['score'] -= 5;
1322 }
1323
1324 // Ensure score doesn't go below 0
1325 $validation['score'] = max(0, $validation['score']);
1326
1327 return $validation;
1328 }
1329
1330 /**
1331 * Validate user input for LLMs.txt generation
1332 *
1333 * @since 1.0.0
1334 *
1335 * @param array $user_input User-provided data
1336 * @return array Validation results
1337 */
1338 private function validate_user_input(array $user_input): array {
1339 $validation = [
1340 'valid' => true,
1341 'errors' => [],
1342 'warnings' => [],
1343 'suggestions' => [],
1344 'score' => 100
1345 ];
1346
1347 // Check required fields
1348 $required_fields = [
1349 'website_description' => 'Website Description',
1350 'key_features' => 'Key Features',
1351 'target_audience' => 'Target Audience'
1352 ];
1353
1354 foreach ($required_fields as $field => $label) {
1355 if (empty($user_input[$field])) {
1356 $validation['errors'][] = "{$label} is required for quality LLMs.txt generation";
1357 $validation['valid'] = false;
1358 $validation['score'] -= 25;
1359 } else {
1360 $validation['suggestions'][] = "✓ {$label} is properly configured";
1361 }
1362 }
1363
1364 // Validate link formats in structured sections
1365 $this->validate_link_sections($user_input, $validation);
1366
1367 // Check content quality
1368 $this->validate_content_quality($user_input, $validation);
1369
1370 // Check optional enhancements
1371 $this->validate_optional_enhancements($user_input, $validation);
1372
1373 // Validate website description
1374 if (!empty($user_input['website_description'])) {
1375 $desc_length = strlen($user_input['website_description']);
1376 if ($desc_length < 50) {
1377 $validation['warnings'][] = 'Website description is quite short, consider adding more details';
1378 $validation['score'] -= 10;
1379 } elseif ($desc_length > 1000) {
1380 $validation['warnings'][] = 'Website description is very long, consider condensing key points';
1381 $validation['score'] -= 5;
1382 }
1383 }
1384
1385 // Validate business type
1386 if (!empty($user_input['business_type']) && !isset($this->business_types[$user_input['business_type']])) {
1387 $validation['warnings'][] = 'Unknown business type specified';
1388 $validation['score'] -= 5;
1389 }
1390
1391 // Ensure score doesn't go below 0
1392 $validation['score'] = max(0, $validation['score']);
1393
1394 return $validation;
1395 }
1396
1397 /**
1398 * Validate LLMs.txt input (public method for API)
1399 *
1400 * @since 1.0.0
1401 *
1402 * @param array $user_input User-provided data
1403 * @return array Validation results
1404 */
1405 public function validate_llms_txt_input(array $user_input): array {
1406 return $this->validate_user_input($user_input);
1407 }
1408
1409 /**
1410 * Override parent sanitize_settings to preserve line breaks in link fields
1411 *
1412 * @since 1.0.0
1413 *
1414 * @param array $settings Settings to sanitize
1415 * @param string $context_type Context the save is for.
1416 * @return array Sanitized settings
1417 */
1418 protected function sanitize_settings(array $settings, string $context_type = 'site'): array {
1419 $sanitized = [];
1420 $known = $this->get_known_setting_keys($context_type);
1421
1422 // Fields that should preserve line breaks
1423 $preserve_linebreaks = [
1424 'documentation_links',
1425 'technical_links',
1426 'optional_links',
1427 'custom_sections',
1428 'key_features',
1429 'website_description',
1430 'technical_stack',
1431 'development_approach',
1432 'setup_instructions',
1433 'ai_context_custom'
1434 ];
1435
1436 foreach ($settings as $key => $value) {
1437 $sanitized_key = sanitize_key($key);
1438
1439 // Never store the REST envelope back as settings (see
1440 // Abstract_Seo_Manager::RESERVED_ENVELOPE_KEYS).
1441 if (in_array($sanitized_key, self::RESERVED_ENVELOPE_KEYS, true)) {
1442 continue;
1443 }
1444
1445 // And nothing this manager does not declare (#452).
1446 if (!$this->is_known_setting_key($sanitized_key, $known)) {
1447 continue;
1448 }
1449
1450 // Constrain the delivery mode to the known enum so an unexpected
1451 // value falls back to auto-detection rather than being stored.
1452 if ('delivery_mode' === $sanitized_key) {
1453 $mode = is_string($value) ? sanitize_key($value) : '';
1454 $sanitized[$sanitized_key] = in_array($mode, self::DELIVERY_MODES, true) ? $mode : 'auto';
1455 continue;
1456 }
1457
1458 if (is_string($value)) {
1459 if (in_array($key, $preserve_linebreaks, true)) {
1460 // Use our custom sanitization that preserves line breaks
1461 if (in_array($key, ['documentation_links', 'technical_links', 'optional_links', 'custom_sections'], true)) {
1462 $sanitized[$sanitized_key] = $this->sanitize_llms_content($value);
1463 } else {
1464 // For textarea fields, use sanitize_textarea_field which preserves line breaks
1465 $sanitized[$sanitized_key] = sanitize_textarea_field($value);
1466 }
1467 } else {
1468 // For regular text fields, use sanitize_text_field
1469 $sanitized[$sanitized_key] = sanitize_text_field($value);
1470 }
1471 } elseif (is_array($value)) {
1472 $sanitized[$sanitized_key] = $this->sanitize_array_recursive($value);
1473 } elseif (is_numeric($value)) {
1474 $sanitized[$sanitized_key] = (float) $value;
1475 } elseif (is_bool($value)) {
1476 $sanitized[$sanitized_key] = (bool) $value;
1477 } else {
1478 $sanitized[$sanitized_key] = sanitize_text_field((string) $value);
1479 }
1480 }
1481
1482 return $sanitized;
1483 }
1484
1485 /**
1486 * Recursively sanitize array values (preserving line breaks where needed)
1487 *
1488 * @since 1.0.0
1489 *
1490 * @param array $input Array to sanitize
1491 * @return array Sanitized array
1492 */
1493 private function sanitize_array_recursive(array $input): array {
1494 $sanitized = [];
1495
1496 foreach ($input as $key => $value) {
1497 $sanitized_key = sanitize_key($key);
1498
1499 if (is_string($value)) {
1500 $sanitized[$sanitized_key] = sanitize_textarea_field($value);
1501 } elseif (is_array($value)) {
1502 $sanitized[$sanitized_key] = $this->sanitize_array_recursive($value);
1503 } elseif (is_numeric($value)) {
1504 $sanitized[$sanitized_key] = (float) $value;
1505 } elseif (is_bool($value)) {
1506 $sanitized[$sanitized_key] = (bool) $value;
1507 } else {
1508 $sanitized[$sanitized_key] = sanitize_text_field((string) $value);
1509 }
1510 }
1511
1512 return $sanitized;
1513 }
1514
1515 /**
1516 * Validate link formats in structured sections
1517 *
1518 * @since 1.0.0
1519 *
1520 * @param array $user_input User input data
1521 * @param array &$validation Validation results (passed by reference)
1522 */
1523 private function validate_link_sections(array $user_input, array &$validation): void {
1524 $link_sections = [
1525 'documentation_links' => 'Documentation Links',
1526 'technical_links' => 'Technical Links',
1527 'optional_links' => 'Optional Links'
1528 ];
1529
1530 foreach ($link_sections as $field => $label) {
1531 if (!empty($user_input[$field])) {
1532 $links = explode("\n", $user_input[$field]);
1533 $valid_links = 0;
1534 $total_links = 0;
1535
1536 foreach ($links as $line) {
1537 $line = trim($line);
1538 if (empty($line) || !str_starts_with($line, '-')) {
1539 continue;
1540 }
1541
1542 $total_links++;
1543
1544 // Check for proper markdown link format: - [Title](URL): Description
1545 if (preg_match('/^-\s*\[([^\]]+)\]\(([^)]+)\):\s*(.+)$/', $line, $matches)) {
1546 $title = trim($matches[1]);
1547 $url = trim($matches[2]);
1548 $description = trim($matches[3]);
1549
1550 if (!empty($title) && !empty($url) && !empty($description)) {
1551 if (filter_var($url, FILTER_VALIDATE_URL)) {
1552 $valid_links++;
1553 } else {
1554 $validation['warnings'][] = "Invalid URL in {$label}: {$url}";
1555 $validation['score'] -= 5;
1556 }
1557 } else {
1558 $validation['warnings'][] = "Incomplete link format in {$label}: missing title, URL, or description";
1559 $validation['score'] -= 5;
1560 }
1561 } else {
1562 $validation['warnings'][] = "Invalid link format in {$label}. Use: - [Title](URL): Description";
1563 $validation['score'] -= 5;
1564 }
1565 }
1566
1567 if ($total_links > 0) {
1568 if ($valid_links === $total_links) {
1569 $validation['suggestions'][] = "✓ All {$label} are properly formatted";
1570 } else {
1571 $validation['warnings'][] = "{$label}: {$valid_links}/{$total_links} links are properly formatted";
1572 }
1573 }
1574 }
1575 }
1576 }
1577
1578 /**
1579 * Validate content quality
1580 *
1581 * @since 1.0.0
1582 *
1583 * @param array $user_input User input data
1584 * @param array &$validation Validation results (passed by reference)
1585 */
1586 private function validate_content_quality(array $user_input, array &$validation): void {
1587 // Check website description quality
1588 if (!empty($user_input['website_description'])) {
1589 $desc_length = strlen($user_input['website_description']);
1590 if ($desc_length < 50) {
1591 $validation['warnings'][] = 'Website description is quite short. Consider adding more detail for better AI understanding';
1592 $validation['score'] -= 10;
1593 } elseif ($desc_length > 500) {
1594 $validation['warnings'][] = 'Website description is very long. Consider making it more concise';
1595 $validation['score'] -= 5;
1596 } else {
1597 $validation['suggestions'][] = '✓ Website description length is optimal';
1598 }
1599 }
1600
1601 // Check key features quality
1602 if (!empty($user_input['key_features'])) {
1603 // The same splitter the generated file uses, so the count reported
1604 // here and the bullets written out can never disagree (#765).
1605 $feature_count = count(self::split_key_features((string) $user_input['key_features']));
1606
1607 // A single line containing commas is ambiguous: it is either a
1608 // legacy comma-separated list or one feature with a comma in it.
1609 // Rather than guess and risk publishing "and Etsy" as a feature,
1610 // say so and let the author decide.
1611 if (self::looks_like_comma_list((string) $user_input['key_features'])) {
1612 $validation['suggestions'][] = 'Put each key feature on its own line. Commas are treated as part of a feature, not as separators.';
1613 }
1614
1615 if ($feature_count < 3) {
1616 $validation['warnings'][] = 'Consider adding more key features (3-8 recommended) for comprehensive AI understanding';
1617 $validation['score'] -= 10;
1618 } elseif ($feature_count > 10) {
1619 $validation['warnings'][] = 'Many key features listed. Consider focusing on the most important ones';
1620 $validation['score'] -= 5;
1621 } else {
1622 $validation['suggestions'][] = "✓ Good number of key features ({$feature_count})";
1623 }
1624 }
1625 }
1626
1627 /**
1628 * Validate optional enhancements
1629 *
1630 * @since 1.0.0
1631 *
1632 * @param array $user_input User input data
1633 * @param array &$validation Validation results (passed by reference)
1634 */
1635 private function validate_optional_enhancements(array $user_input, array &$validation): void {
1636 $enhancement_score = 0;
1637
1638 // Check for technical stack
1639 if (!empty($user_input['technical_stack'])) {
1640 $validation['suggestions'][] = '✓ Technical stack information provided';
1641 $enhancement_score += 5;
1642 } else {
1643 $validation['suggestions'][] = 'Consider adding technical stack information for developer context';
1644 }
1645
1646 // Check for development approach
1647 if (!empty($user_input['development_approach'])) {
1648 $validation['suggestions'][] = '✓ Development approach documented';
1649 $enhancement_score += 5;
1650 } else {
1651 $validation['suggestions'][] = 'Consider documenting development approach for better AI assistance';
1652 }
1653
1654 // Check for setup instructions
1655 if (!empty($user_input['setup_instructions'])) {
1656 $validation['suggestions'][] = '✓ Setup instructions provided';
1657 $enhancement_score += 5;
1658 } else {
1659 $validation['suggestions'][] = 'Consider adding setup instructions for new developers';
1660 }
1661
1662 // Check for custom sections
1663 if (!empty($user_input['custom_sections'])) {
1664 $validation['suggestions'][] = '✓ Custom sections enhance documentation';
1665 $enhancement_score += 5;
1666 }
1667
1668 // Bonus points for comprehensive documentation
1669 if ($enhancement_score >= 15) {
1670 $validation['suggestions'][] = '✓ Comprehensive LLMs.txt documentation - excellent for AI assistance!';
1671 }
1672 }
1673
1674 /**
1675 * Build content sections from user input
1676 *
1677 * @since 1.0.0
1678 *
1679 * @param array $user_input User-provided data
1680 * @param array $settings Current settings
1681 * @return array Built content sections
1682 */
1683 private function build_content_sections(array $user_input, array $settings): array {
1684 $sections = [];
1685
1686 // Blockquote summary (required by spec)
1687 $sections['summary'] = [
1688 'title' => '', // No title for blockquote
1689 'content' => $this->build_summary_blockquote($user_input, $settings)
1690 ];
1691
1692 // Additional details (optional descriptive content)
1693 if (!empty($user_input['website_description'])) {
1694 $sections['details'] = [
1695 'title' => '', // No title for details
1696 'content' => $this->build_additional_details($user_input, $settings)
1697 ];
1698 }
1699
1700 // Development Approach section (if provided)
1701 if (!empty($user_input['development_approach'])) {
1702 $sections['development_approach'] = [
1703 'title' => 'Development Approach',
1704 'content' => sanitize_textarea_field($user_input['development_approach'])
1705 ];
1706 }
1707
1708 // Setup Instructions section (if provided)
1709 if (!empty($user_input['setup_instructions'])) {
1710 $sections['setup_instructions'] = [
1711 'title' => 'Setup Instructions',
1712 'content' => sanitize_textarea_field($user_input['setup_instructions'])
1713 ];
1714 }
1715
1716 // User-controlled structured sections (always include with defaults if empty)
1717 $documentation_content = !empty($user_input['documentation_links'])
1718 ? $this->sanitize_llms_content($user_input['documentation_links'])
1719 : $this->get_default_documentation_links();
1720
1721 $sections['documentation'] = [
1722 'title' => 'Documentation',
1723 'content' => $documentation_content
1724 ];
1725
1726 // Technical section (only if user provided content or technical details exist)
1727 if (!empty($user_input['technical_links']) || !empty($user_input['technical_stack']) || !empty($user_input['development_approach'])) {
1728 $technical_content = !empty($user_input['technical_links'])
1729 ? $this->sanitize_llms_content($user_input['technical_links'])
1730 : $this->get_default_technical_links($user_input);
1731
1732 $sections['technical'] = [
1733 'title' => 'Technical Details',
1734 'content' => $technical_content
1735 ];
1736 }
1737
1738 // Optional section (only if user provided content)
1739 if (!empty($user_input['optional_links'])) {
1740 $sections['optional'] = [
1741 'title' => 'Optional',
1742 'content' => $this->sanitize_llms_content($user_input['optional_links'])
1743 ];
1744 }
1745
1746 // Custom sections (user-defined markdown)
1747 if (!empty($user_input['custom_sections'])) {
1748 $sections['custom'] = [
1749 'title' => '', // No title since user provides their own H2 headers
1750 'content' => $this->sanitize_llms_content($user_input['custom_sections'])
1751 ];
1752 }
1753
1754 /**
1755 * Filter the llms.txt content sections before assembly.
1756 *
1757 * Each entry is ['title' => string, 'content' => string]; an empty
1758 * title emits the content without an H2. Pro appends a "Markdown for
1759 * AI" section here when that feature is enabled. Section content is
1760 * the callback's responsibility to sanitize.
1761 *
1762 * @since 1.32.0
1763 *
1764 * @param array $sections Sections keyed by slug.
1765 * @param array $user_input Validated user input for the generator.
1766 */
1767 return apply_filters('thinkrank_llms_txt_sections', $sections, $user_input);
1768 }
1769
1770 /**
1771 * Sanitize LLMs.txt content while preserving line breaks
1772 *
1773 * @since 1.0.0
1774 *
1775 * @param string $content Raw content to sanitize
1776 * @return string Sanitized content with preserved line breaks
1777 */
1778 public function sanitize_llms_content(string $content): string {
1779 // Remove any potential script tags and dangerous content
1780 $content = wp_kses($content, [
1781 'a' => ['href' => [], 'title' => []],
1782 'strong' => [],
1783 'em' => [],
1784 'code' => [],
1785 'pre' => []
1786 ]);
1787
1788 // wp_kses only guards HTML href attributes, not markdown link syntax
1789 // [text](url). Neutralize dangerous schemes (javascript:/data:/vbscript:)
1790 // in markdown link targets so they don't survive into the published file
1791 // for downstream consumers that render it as markdown/HTML.
1792 $content = preg_replace_callback('/\]\(([^)]*)\)/', static function ($m) {
1793 if (preg_match('#^\s*(?:javascript|data|vbscript):#i', $m[1])) {
1794 return '](#)';
1795 }
1796 return $m[0];
1797 }, $content);
1798
1799 // Normalize line endings and preserve line breaks
1800 $content = str_replace(["\r\n", "\r"], "\n", $content);
1801
1802 // Remove excessive whitespace but preserve intentional line breaks
1803 $content = preg_replace('/[ \t]+/', ' ', $content); // Multiple spaces/tabs to single space
1804 $content = preg_replace('/\n\s*\n\s*\n+/', "\n\n", $content); // Multiple empty lines to double
1805
1806 return trim($content);
1807 }
1808
1809 /**
1810 * Safely read file content with size limits
1811 *
1812 * @since 1.0.0
1813 *
1814 * @param string $file_path Path to file to read
1815 * @param int|null $max_size Maximum file size to read (null for class default)
1816 * @return array Result with success status, content, and any errors
1817 */
1818 private function safe_file_read(string $file_path, ?int $max_size = null): array {
1819 $result = [
1820 'success' => false,
1821 'content' => '',
1822 'error' => '',
1823 'file_size' => 0
1824 ];
1825
1826 if (!file_exists($file_path)) {
1827 $result['error'] = 'File does not exist';
1828 return $result;
1829 }
1830
1831 $file_size = filesize($file_path);
1832 $result['file_size'] = $file_size;
1833
1834 $max_allowed = $max_size ?? self::MAX_FILE_SIZE;
1835
1836 if ($file_size > $max_allowed) {
1837 $result['error'] = sprintf(
1838 'File size (%s) exceeds maximum allowed size (%s)',
1839 size_format($file_size),
1840 size_format($max_allowed)
1841 );
1842 return $result;
1843 }
1844
1845 if (!$this->init_filesystem()) {
1846 $result['error'] = 'Could not initialize WordPress filesystem';
1847 return $result;
1848 }
1849
1850 $content = $this->filesystem->get_contents($file_path);
1851 if (false === $content) {
1852 $result['error'] = 'Failed to read file content';
1853 return $result;
1854 }
1855
1856 $result['success'] = true;
1857 $result['content'] = $content;
1858 return $result;
1859 }
1860 private function build_llms_txt_content(array $sections, string $site_name = ''): string {
1861 // Sanitize inside the manager rather than trusting callers — the MCP
1862 // abilities pass site_name through unsanitized.
1863 $site_name = sanitize_text_field($site_name ?: get_bloginfo('name'));
1864 $content = "# {$site_name}\n\n";
1865
1866 foreach ($sections as $section_key => $section_data) {
1867 // Only add H2 header if title is not empty
1868 if (!empty($section_data['title'])) {
1869 $content .= "## {$section_data['title']}\n\n";
1870 }
1871 $content .= $section_data['content'] . "\n\n";
1872 }
1873
1874 // Add generation timestamp
1875 $content .= "---\n";
1876 $content .= "Generated by ThinkRank SEO Plugin on " . gmdate('Y-m-d H:i:s') . " UTC\n";
1877
1878 return $content;
1879 }
1880
1881 /**
1882 * Validate SEO settings (implements interface)
1883 *
1884 * @since 1.0.0
1885 *
1886 * @param array $settings Settings array to validate
1887 * @return array Validation results
1888 */
1889 public function validate_settings(array $settings): array {
1890 $validation = [
1891 'valid' => true,
1892 'errors' => [],
1893 'warnings' => [],
1894 'suggestions' => [],
1895 'score' => 100
1896 ];
1897
1898 // Validate enabled setting
1899 if (!isset($settings['enabled'])) {
1900 $validation['errors'][] = 'Enabled setting is required';
1901 $validation['valid'] = false;
1902 $validation['score'] -= 25;
1903 }
1904
1905 // Validate website description
1906 if (isset($settings['website_description'])) {
1907 if (empty($settings['website_description'])) {
1908 $validation['warnings'][] = 'Website description is empty, consider adding a description';
1909 $validation['score'] -= 15;
1910 } elseif (strlen($settings['website_description']) < 50) {
1911 $validation['suggestions'][] = 'Website description is quite short, consider adding more details';
1912 $validation['score'] -= 5;
1913 }
1914 }
1915
1916 // Validate key features
1917 if (isset($settings['key_features'])) {
1918 if (empty($settings['key_features'])) {
1919 $validation['warnings'][] = 'Key features are empty, consider listing main website features';
1920 $validation['score'] -= 15;
1921 }
1922 }
1923
1924 // Validate target audience
1925 if (isset($settings['target_audience'])) {
1926 if (empty($settings['target_audience'])) {
1927 $validation['suggestions'][] = 'Target audience is not specified, consider defining your audience';
1928 $validation['score'] -= 5;
1929 }
1930 }
1931
1932 // Check file permissions if enabled. Only the static delivery mode needs
1933 // a writable root — dynamic delivery keeps the document in the database.
1934 $mode = $this->resolve_delivery_mode(
1935 isset($settings['delivery_mode']) ? (string) $settings['delivery_mode'] : null
1936 );
1937 if (!empty($settings['enabled']) && 'static' === $mode) {
1938 if (!$this->is_directory_writable(ABSPATH)) {
1939 $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.';
1940 $validation['score'] -= 10;
1941 }
1942 }
1943
1944 // Ensure score doesn't go below 0
1945 $validation['score'] = max(0, $validation['score']);
1946
1947 return $validation;
1948 }
1949
1950 /**
1951 * Get output data for frontend rendering (implements interface)
1952 *
1953 * @since 1.0.0
1954 *
1955 * @param string $context_type The context type
1956 * @param int|null $context_id Optional. Context ID
1957 * @return array Output data ready for frontend rendering
1958 */
1959 public function get_output_data(string $context_type, ?int $context_id): array {
1960 $settings = $this->get_settings($context_type, $context_id);
1961
1962 $output = [
1963 'llms_txt_content' => '',
1964 'file_status' => [],
1965 'metadata' => [],
1966 'enabled' => $settings['enabled'] ?? true
1967 ];
1968
1969 if (!$output['enabled']) {
1970 return $output;
1971 }
1972
1973 // Get file status
1974 $output['file_status'] = $this->get_llms_txt_status();
1975
1976 // If a file is published, get its current content safely; otherwise fall
1977 // back to the stored document that dynamic delivery serves.
1978 if ($output['file_status']['file_exists']) {
1979 $llms_file = ABSPATH . 'llms.txt';
1980 $read_result = $this->safe_file_read($llms_file);
1981 if ($read_result['success']) {
1982 $output['llms_txt_content'] = $read_result['content'];
1983 } else {
1984 $output['llms_txt_content'] = '';
1985 $output['file_read_error'] = $read_result['error'];
1986 }
1987 } else {
1988 $output['llms_txt_content'] = $this->get_published_content();
1989 }
1990
1991 // Add metadata
1992 $output['metadata'] = [
1993 'last_generated' => $settings['last_generated'] ?? null,
1994 'generator_version' => THINKRANK_VERSION ?? '1.0.0',
1995 'website_url' => home_url()
1996 ];
1997
1998 return $output;
1999 }
2000
2001 /**
2002 * Get default settings for a context type (implements interface)
2003 *
2004 * @since 1.0.0
2005 *
2006 * @param string $context_type The context type to get defaults for
2007 * @return array Default settings array
2008 */
2009 public function get_default_settings(string $context_type): array {
2010 $defaults = [
2011 'enabled' => true,
2012 'site_name' => get_bloginfo('name'),
2013 'website_description' => get_bloginfo('description'),
2014 'key_features' => '',
2015 'target_audience' => 'general',
2016 'business_type' => 'website',
2017 'technical_stack' => 'WordPress',
2018 'development_approach' => '',
2019 'setup_instructions' => '',
2020 'ai_context_custom' => '',
2021 'auto_generate' => false,
2022 'delivery_mode' => 'auto',
2023 'last_generated' => null,
2024 // Structured sections for llms.txt spec compliance
2025 'documentation_links' => '',
2026 'technical_links' => '',
2027 'optional_links' => '',
2028 'custom_sections' => ''
2029 ];
2030
2031 // Context-specific defaults
2032 switch ($context_type) {
2033 case 'site':
2034 // Site-wide defaults are already set above
2035 break;
2036 default:
2037 // Use site defaults for other contexts
2038 break;
2039 }
2040
2041 return $defaults;
2042 }
2043
2044 /**
2045 * Get settings schema definition (implements interface)
2046 *
2047 * @since 1.0.0
2048 *
2049 * @param string $context_type The context type to get schema for
2050 * @return array Settings schema definition
2051 */
2052 public function get_settings_schema(string $context_type): array {
2053 return [
2054 'enabled' => [
2055 'type' => 'boolean',
2056 'title' => 'Enable LLMs.txt',
2057 'description' => 'Enable LLMs.txt file generation and management',
2058 'default' => true
2059 ],
2060 'site_name' => [
2061 'type' => 'string',
2062 'title' => 'Website Title',
2063 'description' => 'The name of your website as it will appear in the LLMs.txt file',
2064 'default' => get_bloginfo('name'),
2065 'maxLength' => 60
2066 ],
2067 'website_description' => [
2068 'type' => 'string',
2069 'title' => 'Website Description',
2070 'description' => 'Comprehensive description of your website and its purpose',
2071 'default' => get_bloginfo('description'),
2072 'maxLength' => 1000
2073 ],
2074 'key_features' => [
2075 'type' => 'string',
2076 'title' => 'Key Features',
2077 'description' => 'Main features and functionality of your website. One feature per line: a comma is treated as part of a feature, not as a separator.',
2078 'default' => '',
2079 'maxLength' => 500
2080 ],
2081 'target_audience' => [
2082 'type' => 'string',
2083 'title' => 'Target Audience',
2084 'description' => 'Primary audience for your website',
2085 'default' => 'general',
2086 'maxLength' => 200
2087 ],
2088 'business_type' => [
2089 'type' => 'string',
2090 'title' => 'Business Type',
2091 'description' => 'Type of website or business',
2092 'enum' => array_keys($this->business_types),
2093 'default' => 'website'
2094 ],
2095 'technical_stack' => [
2096 'type' => 'string',
2097 'title' => 'Technical Stack',
2098 'description' => 'Technologies and frameworks used',
2099 'default' => 'WordPress',
2100 'maxLength' => 300
2101 ],
2102 'development_approach' => [
2103 'type' => 'string',
2104 'title' => 'Development Approach',
2105 'description' => 'Development methodology and practices',
2106 'default' => '',
2107 'maxLength' => 400
2108 ],
2109 'setup_instructions' => [
2110 'type' => 'string',
2111 'title' => 'Setup Instructions',
2112 'description' => 'Instructions for setting up or working with the project',
2113 'default' => '',
2114 'maxLength' => 500
2115 ],
2116 'ai_context_custom' => [
2117 'type' => 'string',
2118 'title' => 'Additional AI Context',
2119 'description' => 'Custom context information for AI assistants',
2120 'default' => '',
2121 'maxLength' => 400
2122 ],
2123 'auto_generate' => [
2124 'type' => 'boolean',
2125 'title' => 'Auto-generate',
2126 'description' => 'Automatically regenerate llms.txt when settings change',
2127 'default' => false
2128 ],
2129 'delivery_mode' => [
2130 'type' => 'string',
2131 'title' => 'Delivery Method',
2132 '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.',
2133 'enum' => self::DELIVERY_MODES,
2134 'default' => 'auto'
2135 ],
2136 'last_generated' => [
2137 'type' => 'string',
2138 'title' => 'Last Generated',
2139 'description' => 'Timestamp of last generation',
2140 'format' => 'date-time',
2141 'readonly' => true
2142 ],
2143 'documentation_links' => [
2144 'type' => 'string',
2145 'title' => 'Documentation Links',
2146 'description' => 'Links to documentation, guides, and important pages',
2147 'default' => '',
2148 'maxLength' => 2000
2149 ],
2150 'technical_links' => [
2151 'type' => 'string',
2152 'title' => 'Technical Links',
2153 'description' => 'Links to technical resources, code repositories, and development info',
2154 'default' => '',
2155 'maxLength' => 2000
2156 ],
2157 'optional_links' => [
2158 'type' => 'string',
2159 'title' => 'Optional Links',
2160 'description' => 'Secondary resources that can be skipped for shorter context',
2161 'default' => '',
2162 'maxLength' => 2000
2163 ],
2164 'custom_sections' => [
2165 'type' => 'string',
2166 'title' => 'Custom Sections',
2167 'description' => 'Additional custom sections in markdown format',
2168 'default' => '',
2169 'maxLength' => 3000
2170 ]
2171 ];
2172 }
2173 private function build_summary_blockquote(array $user_input, array $settings): string {
2174 $description = sanitize_textarea_field($user_input['website_description'] ?? '');
2175
2176 if (empty($description)) {
2177 $site_name = sanitize_text_field($user_input['site_name'] ?? $settings['site_name'] ?? get_bloginfo('name'));
2178 $business_type = $user_input['business_type'] ?? 'website';
2179 $description = "{$site_name} is a {$this->business_types[$business_type]} providing valuable resources and information.";
2180 }
2181
2182 // Format as blockquote (required by spec)
2183 return "> " . $description . "\n\n";
2184 }
2185
2186 /**
2187 * Split the Key Features field into individual features.
2188 *
2189 * One feature per line. Commas used to be delimiters too, which meant a
2190 * single feature that happened to contain one — "Collect reviews from
2191 * Trustpilot, Google, and Etsy" — was published as three bullets, one of
2192 * them reading "and Etsy" (#765). Validation counted by newline only, so
2193 * it reported one feature while the file showed three and never flagged
2194 * the split.
2195 *
2196 * Commas are not a fallback delimiter even when the value has no newlines.
2197 * A comma inside a feature is ordinary prose and far more likely than a
2198 * deliberate comma-separated list, and guessing wrong publishes mangled
2199 * text to the file AI crawlers read. A single-line value with commas is
2200 * kept whole and validate_content_quality() suggests splitting it, which
2201 * tells the user what to do instead of quietly deciding for them.
2202 *
2203 * The one splitter both generation and validation use, so the file and the
2204 * feature count can no longer disagree.
2205 *
2206 * @since 2.10.0
2207 *
2208 * @param string $key_features Raw field value.
2209 * @return string[] Trimmed features, empties removed.
2210 */
2211 public static function split_key_features(string $key_features): array {
2212 $features = preg_split('/[\r\n]+/', $key_features);
2213
2214 if (!is_array($features)) {
2215 return [];
2216 }
2217
2218 $features = array_map('trim', $features);
2219
2220 return array_values(array_filter($features, static fn(string $f): bool => '' !== $f));
2221 }
2222
2223 /**
2224 * Whether a value looks like the old comma-separated list.
2225 *
2226 * One line, and a comma in it. That is either a legacy list saved before
2227 * newlines became the delimiter, or a single feature containing a comma —
2228 * indistinguishable from the outside, which is exactly why this prompts
2229 * rather than splits.
2230 *
2231 * @since 2.10.0
2232 *
2233 * @param string $key_features Raw field value.
2234 * @return bool
2235 */
2236 public static function looks_like_comma_list(string $key_features): bool {
2237 $trimmed = trim($key_features);
2238
2239 if ('' === $trimmed || false !== strpbrk($trimmed, "\r\n")) {
2240 return false;
2241 }
2242
2243 return false !== strpos($trimmed, ',');
2244 }
2245
2246 /**
2247 * Turn a single-line comma list into one feature per line.
2248 *
2249 * Returns null when the value is not something to convert: it already has
2250 * line breaks, has no comma, or reads as one feature containing a series.
2251 *
2252 * Only for text that was written under a comma rule: values saved before
2253 * newlines became the only delimiter (see
2254 * {@see self::maybe_migrate_legacy_key_features()}), and AI replies that
2255 * ignored the one-per-line instruction. Typed input never goes through
2256 * this; split_key_features() still keeps a comma inside a feature (#765).
2257 *
2258 * A series is the one shape the old rule demonstrably mangled: "Collect
2259 * reviews from Trustpilot, Google, and Etsy" became three bullets, the
2260 * last reading "and Etsy". So a value is left whole when a segment after
2261 * the first opens with a conjunction (the Oxford form), or when the final
2262 * segment carries one ("..., Google and Etsy", the form the field's own
2263 * placeholder uses). A plain list that happens to end "X and Y" is left
2264 * whole too; validation still suggests splitting it, and one intact bullet
2265 * is the safer wrong answer than a sentence cut into fragments.
2266 *
2267 * A comma between digits ("1,000 templates") is a thousands separator,
2268 * not a delimiter.
2269 *
2270 * @since 2.10.0
2271 *
2272 * @param string $key_features Raw value.
2273 * @return string|null Newline-separated features, or null to leave as is.
2274 */
2275 public static function comma_list_to_lines(string $key_features): ?string {
2276 if (!self::looks_like_comma_list($key_features)) {
2277 return null;
2278 }
2279
2280 $segments = preg_split('/\s*,(?!\d)\s*/', trim($key_features));
2281
2282 if (!is_array($segments)) {
2283 return null;
2284 }
2285
2286 $segments = array_values(array_filter(
2287 array_map('trim', $segments),
2288 static fn(string $s): bool => '' !== $s
2289 ));
2290
2291 if (count($segments) < 2) {
2292 return null;
2293 }
2294
2295 foreach (array_slice($segments, 1) as $segment) {
2296 if (preg_match('/^(?:(?:and|or|nor|plus)\b|&)/i', $segment)) {
2297 return null;
2298 }
2299 }
2300
2301 if (preg_match('/\s(?:and|or|&)\s/i', (string) end($segments))) {
2302 return null;
2303 }
2304
2305 return implode("\n", $segments);
2306 }
2307
2308 /**
2309 * Coerce an AI reply for Key Features into the one-per-line field value.
2310 *
2311 * The prompt asks for one feature per line, but models still answer with a
2312 * JSON array or a comma-separated line. An array went through
2313 * sanitize_textarea_field() as '' and the field silently kept its old
2314 * value; a comma line was published as a single bullet now that commas are
2315 * not delimiters. Both are normalised to lines here, before sanitising.
2316 *
2317 * @since 2.10.0
2318 *
2319 * @param mixed $value Decoded `key_features` from the reply.
2320 * @return string Sanitised, newline-separated features.
2321 */
2322 public static function normalize_ai_key_features($value): string {
2323 if (is_array($value)) {
2324 $features = [];
2325 foreach ($value as $item) {
2326 if (is_scalar($item)) {
2327 $item = trim((string) $item);
2328 if ('' !== $item) {
2329 $features[] = $item;
2330 }
2331 }
2332 }
2333 $value = implode("\n", $features);
2334 } elseif (!is_scalar($value)) {
2335 return '';
2336 }
2337
2338 $value = (string) $value;
2339 $lines = self::comma_list_to_lines($value);
2340
2341 return sanitize_textarea_field(null === $lines ? $value : $lines);
2342 }
2343
2344 /**
2345 * Convert a Key Features value saved under the old comma rule, once.
2346 *
2347 * Up to 2.9.0 a comma separated features, so a site that saved
2348 * "SEO audits, Schema markup, XML sitemaps" published three bullets. After
2349 * #765 made newlines the only delimiter the same stored value regenerates
2350 * as one bullet holding the whole line, a silent change to the file AI
2351 * crawlers read. Rewriting the stored value as lines keeps that site's
2352 * output what it was, in the form the field now documents.
2353 *
2354 * A migration rather than a runtime fallback on purpose: a fallback would
2355 * keep treating commas as delimiters for every single-line value forever,
2356 * which is the #765 bug. Here only values that were saved while commas
2357 * really were delimiters are touched, exactly once; anything typed after
2358 * this has run follows the new rule. comma_list_to_lines() still leaves a
2359 * series such as the #765 value whole.
2360 *
2361 * Version-gated like Settings::retire_seeded_ai_provider(), and the marker
2362 * is written first so a site that fails the write does not retry on every
2363 * admin request. The activator records it on a fresh install.
2364 *
2365 * @since 2.10.0
2366 *
2367 * @return void
2368 */
2369 public static function maybe_migrate_legacy_key_features(): void {
2370 if (get_option(self::KEY_FEATURES_MIGRATION_OPTION) === self::KEY_FEATURES_MIGRATION_VERSION) {
2371 return;
2372 }
2373
2374 update_option(self::KEY_FEATURES_MIGRATION_OPTION, self::KEY_FEATURES_MIGRATION_VERSION, true);
2375
2376 (new static())->migrate_stored_key_features();
2377 }
2378
2379 /**
2380 * Rewrite the stored Key Features as lines when it is a legacy comma list.
2381 *
2382 * @since 2.10.0
2383 *
2384 * @return bool True when a value was converted and saved.
2385 */
2386 public function migrate_stored_key_features(): bool {
2387 $stored = $this->get_stored_settings('site');
2388
2389 if (!isset($stored['key_features']) || !is_string($stored['key_features'])) {
2390 return false;
2391 }
2392
2393 $lines = self::comma_list_to_lines($stored['key_features']);
2394
2395 if (null === $lines) {
2396 return false;
2397 }
2398
2399 // validate_settings() rejects a payload without `enabled`, so carry the
2400 // stored flag along. Written through the base save, not this class's,
2401 // which would also reconcile the delivery mode: a stored-value rewrite
2402 // must not republish anything.
2403 return $this->write_migrated_key_features([
2404 'enabled' => $stored['enabled'] ?? true,
2405 'key_features' => $lines,
2406 ]);
2407 }
2408
2409 /**
2410 * Persist the converted value. Separate so tests can observe the write.
2411 *
2412 * @since 2.10.0
2413 *
2414 * @param array $settings `enabled` and `key_features`.
2415 * @return bool
2416 */
2417 protected function write_migrated_key_features(array $settings): bool {
2418 return parent::save_settings('site', null, $settings);
2419 }
2420
2421 /**
2422 * Build additional details section
2423 *
2424 * @since 1.0.0
2425 *
2426 * @param array $user_input User input data
2427 * @param array $settings Current settings
2428 * @return string Additional details content
2429 */
2430 private function build_additional_details(array $user_input, array $settings): string {
2431 $content = '';
2432 $target_audience = sanitize_text_field($user_input['target_audience'] ?? '');
2433 $key_features = sanitize_textarea_field($user_input['key_features'] ?? '');
2434
2435 if (!empty($target_audience)) {
2436 $content .= "**Target Audience:** {$target_audience}\n\n";
2437 }
2438
2439 if (!empty($key_features)) {
2440 $content .= "**Key Features:**\n";
2441 foreach (self::split_key_features($key_features) as $feature) {
2442 $content .= "- " . $feature . "\n";
2443 }
2444 $content .= "\n";
2445 }
2446
2447 return $content;
2448 }
2449 private function get_default_documentation_links(): string {
2450 $website_url = home_url();
2451 $content = '';
2452
2453 // Add basic WordPress links
2454 $content .= "- [Website Home]({$website_url}): Main website homepage\n";
2455 $content .= "- [Sitemap]({$website_url}/sitemap.xml): Complete site structure\n";
2456
2457 return $content;
2458 }
2459
2460 /**
2461 * Get default technical links based on user input
2462 *
2463 * @since 1.0.0
2464 *
2465 * @param array $user_input User input data
2466 * @return string Default technical links
2467 */
2468 private function get_default_technical_links(array $user_input): string {
2469 $website_url = home_url();
2470 $content = '';
2471
2472 if (!empty($user_input['technical_stack'])) {
2473 $stack = sanitize_text_field($user_input['technical_stack']);
2474 $content .= "- [Technical Stack]({$website_url}): Built with {$stack}\n";
2475 }
2476
2477 if (!empty($user_input['development_approach'])) {
2478 $approach_summary = \ThinkRank\Core\Seo_Text::trim_words($user_input['development_approach'], 10);
2479 $content .= "- [Development Guidelines]({$website_url}): {$approach_summary}\n";
2480 }
2481
2482 // Add robots.txt reference
2483 $content .= "- [Robots.txt]({$website_url}/robots.txt): Site crawling guidelines\n";
2484
2485 return $content;
2486 }
2487 }
2488