PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.3.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.3.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 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.3.0, at includes/seo/class-llms-txt-manager.php

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