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

2,225 lines 81.1 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 // substr()/strlen() count BYTES, so this cut a multibyte character
1237 // in half and shipped an invalid UTF-8 sequence in the preview (#687).
1238 $status['content_preview'] = \ThinkRank\Core\Seo_Text::trim_to_length($content, 200);
1239 }
1240
1241 // Cache the result for 5 minutes to improve performance
1242 set_transient($cache_key, $status, 5 * MINUTE_IN_SECONDS);
1243
1244 return $status;
1245 }
1246
1247 /**
1248 * Validate LLMs.txt content
1249 *
1250 * @since 1.0.0
1251 *
1252 * @param string $content LLMs.txt content to validate
1253 * @return array Validation results
1254 */
1255 public function validate_llms_txt_content(string $content): array {
1256 $validation = [
1257 'valid' => true,
1258 'errors' => [],
1259 'warnings' => [],
1260 'suggestions' => [],
1261 'score' => 100
1262 ];
1263
1264 // Check if content is empty
1265 if (empty(trim($content))) {
1266 $validation['errors'][] = 'LLMs.txt content cannot be empty';
1267 $validation['valid'] = false;
1268 $validation['score'] = 0;
1269 return $validation;
1270 }
1271
1272 // Check content length
1273 $content_length = strlen($content);
1274 if ($content_length < 100) {
1275 $validation['warnings'][] = 'LLMs.txt content is very short, consider adding more details';
1276 $validation['score'] -= 20;
1277 } elseif ($content_length > 10000) {
1278 $validation['warnings'][] = 'LLMs.txt content is very long, consider condensing key information';
1279 $validation['score'] -= 10;
1280 }
1281
1282 // Check for required sections
1283 $required_sections = ['Project Overview', 'Key Features', 'Context for AI Assistants'];
1284 foreach ($required_sections as $section) {
1285 if (stripos($content, $section) === false) {
1286 $validation['warnings'][] = "Missing recommended section: {$section}";
1287 $validation['score'] -= 15;
1288 }
1289 }
1290
1291 // Check for proper structure
1292 if (!preg_match('/^#\s+/', $content)) {
1293 $validation['suggestions'][] = 'Consider starting with a main heading (# Project Name)';
1294 $validation['score'] -= 5;
1295 }
1296
1297 // Ensure score doesn't go below 0
1298 $validation['score'] = max(0, $validation['score']);
1299
1300 return $validation;
1301 }
1302
1303 /**
1304 * Validate user input for LLMs.txt generation
1305 *
1306 * @since 1.0.0
1307 *
1308 * @param array $user_input User-provided data
1309 * @return array Validation results
1310 */
1311 private function validate_user_input(array $user_input): array {
1312 $validation = [
1313 'valid' => true,
1314 'errors' => [],
1315 'warnings' => [],
1316 'suggestions' => [],
1317 'score' => 100
1318 ];
1319
1320 // Check required fields
1321 $required_fields = [
1322 'website_description' => 'Website Description',
1323 'key_features' => 'Key Features',
1324 'target_audience' => 'Target Audience'
1325 ];
1326
1327 foreach ($required_fields as $field => $label) {
1328 if (empty($user_input[$field])) {
1329 $validation['errors'][] = "{$label} is required for quality LLMs.txt generation";
1330 $validation['valid'] = false;
1331 $validation['score'] -= 25;
1332 } else {
1333 $validation['suggestions'][] = "{$label} is properly configured";
1334 }
1335 }
1336
1337 // Validate link formats in structured sections
1338 $this->validate_link_sections($user_input, $validation);
1339
1340 // Check content quality
1341 $this->validate_content_quality($user_input, $validation);
1342
1343 // Check optional enhancements
1344 $this->validate_optional_enhancements($user_input, $validation);
1345
1346 // Validate website description
1347 if (!empty($user_input['website_description'])) {
1348 $desc_length = strlen($user_input['website_description']);
1349 if ($desc_length < 50) {
1350 $validation['warnings'][] = 'Website description is quite short, consider adding more details';
1351 $validation['score'] -= 10;
1352 } elseif ($desc_length > 1000) {
1353 $validation['warnings'][] = 'Website description is very long, consider condensing key points';
1354 $validation['score'] -= 5;
1355 }
1356 }
1357
1358 // Validate business type
1359 if (!empty($user_input['business_type']) && !isset($this->business_types[$user_input['business_type']])) {
1360 $validation['warnings'][] = 'Unknown business type specified';
1361 $validation['score'] -= 5;
1362 }
1363
1364 // Ensure score doesn't go below 0
1365 $validation['score'] = max(0, $validation['score']);
1366
1367 return $validation;
1368 }
1369
1370 /**
1371 * Validate LLMs.txt input (public method for API)
1372 *
1373 * @since 1.0.0
1374 *
1375 * @param array $user_input User-provided data
1376 * @return array Validation results
1377 */
1378 public function validate_llms_txt_input(array $user_input): array {
1379 return $this->validate_user_input($user_input);
1380 }
1381
1382 /**
1383 * Override parent sanitize_settings to preserve line breaks in link fields
1384 *
1385 * @since 1.0.0
1386 *
1387 * @param array $settings Settings to sanitize
1388 * @param string $context_type Context the save is for.
1389 * @return array Sanitized settings
1390 */
1391 protected function sanitize_settings(array $settings, string $context_type = 'site'): array {
1392 $sanitized = [];
1393 $known = $this->get_known_setting_keys($context_type);
1394
1395 // Fields that should preserve line breaks
1396 $preserve_linebreaks = [
1397 'documentation_links',
1398 'technical_links',
1399 'optional_links',
1400 'custom_sections',
1401 'key_features',
1402 'website_description',
1403 'technical_stack',
1404 'development_approach',
1405 'setup_instructions',
1406 'ai_context_custom'
1407 ];
1408
1409 foreach ($settings as $key => $value) {
1410 $sanitized_key = sanitize_key($key);
1411
1412 // Never store the REST envelope back as settings (see
1413 // Abstract_Seo_Manager::RESERVED_ENVELOPE_KEYS).
1414 if (in_array($sanitized_key, self::RESERVED_ENVELOPE_KEYS, true)) {
1415 continue;
1416 }
1417
1418 // And nothing this manager does not declare (#452).
1419 if (!$this->is_known_setting_key($sanitized_key, $known)) {
1420 continue;
1421 }
1422
1423 // Constrain the delivery mode to the known enum so an unexpected
1424 // value falls back to auto-detection rather than being stored.
1425 if ('delivery_mode' === $sanitized_key) {
1426 $mode = is_string($value) ? sanitize_key($value) : '';
1427 $sanitized[$sanitized_key] = in_array($mode, self::DELIVERY_MODES, true) ? $mode : 'auto';
1428 continue;
1429 }
1430
1431 if (is_string($value)) {
1432 if (in_array($key, $preserve_linebreaks, true)) {
1433 // Use our custom sanitization that preserves line breaks
1434 if (in_array($key, ['documentation_links', 'technical_links', 'optional_links', 'custom_sections'], true)) {
1435 $sanitized[$sanitized_key] = $this->sanitize_llms_content($value);
1436 } else {
1437 // For textarea fields, use sanitize_textarea_field which preserves line breaks
1438 $sanitized[$sanitized_key] = sanitize_textarea_field($value);
1439 }
1440 } else {
1441 // For regular text fields, use sanitize_text_field
1442 $sanitized[$sanitized_key] = sanitize_text_field($value);
1443 }
1444 } elseif (is_array($value)) {
1445 $sanitized[$sanitized_key] = $this->sanitize_array_recursive($value);
1446 } elseif (is_numeric($value)) {
1447 $sanitized[$sanitized_key] = (float) $value;
1448 } elseif (is_bool($value)) {
1449 $sanitized[$sanitized_key] = (bool) $value;
1450 } else {
1451 $sanitized[$sanitized_key] = sanitize_text_field((string) $value);
1452 }
1453 }
1454
1455 return $sanitized;
1456 }
1457
1458 /**
1459 * Recursively sanitize array values (preserving line breaks where needed)
1460 *
1461 * @since 1.0.0
1462 *
1463 * @param array $input Array to sanitize
1464 * @return array Sanitized array
1465 */
1466 private function sanitize_array_recursive(array $input): array {
1467 $sanitized = [];
1468
1469 foreach ($input as $key => $value) {
1470 $sanitized_key = sanitize_key($key);
1471
1472 if (is_string($value)) {
1473 $sanitized[$sanitized_key] = sanitize_textarea_field($value);
1474 } elseif (is_array($value)) {
1475 $sanitized[$sanitized_key] = $this->sanitize_array_recursive($value);
1476 } elseif (is_numeric($value)) {
1477 $sanitized[$sanitized_key] = (float) $value;
1478 } elseif (is_bool($value)) {
1479 $sanitized[$sanitized_key] = (bool) $value;
1480 } else {
1481 $sanitized[$sanitized_key] = sanitize_text_field((string) $value);
1482 }
1483 }
1484
1485 return $sanitized;
1486 }
1487
1488 /**
1489 * Validate link formats in structured sections
1490 *
1491 * @since 1.0.0
1492 *
1493 * @param array $user_input User input data
1494 * @param array &$validation Validation results (passed by reference)
1495 */
1496 private function validate_link_sections(array $user_input, array &$validation): void {
1497 $link_sections = [
1498 'documentation_links' => 'Documentation Links',
1499 'technical_links' => 'Technical Links',
1500 'optional_links' => 'Optional Links'
1501 ];
1502
1503 foreach ($link_sections as $field => $label) {
1504 if (!empty($user_input[$field])) {
1505 $links = explode("\n", $user_input[$field]);
1506 $valid_links = 0;
1507 $total_links = 0;
1508
1509 foreach ($links as $line) {
1510 $line = trim($line);
1511 if (empty($line) || !str_starts_with($line, '-')) {
1512 continue;
1513 }
1514
1515 $total_links++;
1516
1517 // Check for proper markdown link format: - [Title](URL): Description
1518 if (preg_match('/^-\s*\[([^\]]+)\]\(([^)]+)\):\s*(.+)$/', $line, $matches)) {
1519 $title = trim($matches[1]);
1520 $url = trim($matches[2]);
1521 $description = trim($matches[3]);
1522
1523 if (!empty($title) && !empty($url) && !empty($description)) {
1524 if (filter_var($url, FILTER_VALIDATE_URL)) {
1525 $valid_links++;
1526 } else {
1527 $validation['warnings'][] = "Invalid URL in {$label}: {$url}";
1528 $validation['score'] -= 5;
1529 }
1530 } else {
1531 $validation['warnings'][] = "Incomplete link format in {$label}: missing title, URL, or description";
1532 $validation['score'] -= 5;
1533 }
1534 } else {
1535 $validation['warnings'][] = "Invalid link format in {$label}. Use: - [Title](URL): Description";
1536 $validation['score'] -= 5;
1537 }
1538 }
1539
1540 if ($total_links > 0) {
1541 if ($valid_links === $total_links) {
1542 $validation['suggestions'][] = "✓ All {$label} are properly formatted";
1543 } else {
1544 $validation['warnings'][] = "{$label}: {$valid_links}/{$total_links} links are properly formatted";
1545 }
1546 }
1547 }
1548 }
1549 }
1550
1551 /**
1552 * Validate content quality
1553 *
1554 * @since 1.0.0
1555 *
1556 * @param array $user_input User input data
1557 * @param array &$validation Validation results (passed by reference)
1558 */
1559 private function validate_content_quality(array $user_input, array &$validation): void {
1560 // Check website description quality
1561 if (!empty($user_input['website_description'])) {
1562 $desc_length = strlen($user_input['website_description']);
1563 if ($desc_length < 50) {
1564 $validation['warnings'][] = 'Website description is quite short. Consider adding more detail for better AI understanding';
1565 $validation['score'] -= 10;
1566 } elseif ($desc_length > 500) {
1567 $validation['warnings'][] = 'Website description is very long. Consider making it more concise';
1568 $validation['score'] -= 5;
1569 } else {
1570 $validation['suggestions'][] = '✓ Website description length is optimal';
1571 }
1572 }
1573
1574 // Check key features quality
1575 if (!empty($user_input['key_features'])) {
1576 $features = explode("\n", $user_input['key_features']);
1577 $feature_count = count(array_filter($features, 'trim'));
1578
1579 if ($feature_count < 3) {
1580 $validation['warnings'][] = 'Consider adding more key features (3-8 recommended) for comprehensive AI understanding';
1581 $validation['score'] -= 10;
1582 } elseif ($feature_count > 10) {
1583 $validation['warnings'][] = 'Many key features listed. Consider focusing on the most important ones';
1584 $validation['score'] -= 5;
1585 } else {
1586 $validation['suggestions'][] = "✓ Good number of key features ({$feature_count})";
1587 }
1588 }
1589 }
1590
1591 /**
1592 * Validate optional enhancements
1593 *
1594 * @since 1.0.0
1595 *
1596 * @param array $user_input User input data
1597 * @param array &$validation Validation results (passed by reference)
1598 */
1599 private function validate_optional_enhancements(array $user_input, array &$validation): void {
1600 $enhancement_score = 0;
1601
1602 // Check for technical stack
1603 if (!empty($user_input['technical_stack'])) {
1604 $validation['suggestions'][] = '✓ Technical stack information provided';
1605 $enhancement_score += 5;
1606 } else {
1607 $validation['suggestions'][] = 'Consider adding technical stack information for developer context';
1608 }
1609
1610 // Check for development approach
1611 if (!empty($user_input['development_approach'])) {
1612 $validation['suggestions'][] = '✓ Development approach documented';
1613 $enhancement_score += 5;
1614 } else {
1615 $validation['suggestions'][] = 'Consider documenting development approach for better AI assistance';
1616 }
1617
1618 // Check for setup instructions
1619 if (!empty($user_input['setup_instructions'])) {
1620 $validation['suggestions'][] = '✓ Setup instructions provided';
1621 $enhancement_score += 5;
1622 } else {
1623 $validation['suggestions'][] = 'Consider adding setup instructions for new developers';
1624 }
1625
1626 // Check for custom sections
1627 if (!empty($user_input['custom_sections'])) {
1628 $validation['suggestions'][] = '✓ Custom sections enhance documentation';
1629 $enhancement_score += 5;
1630 }
1631
1632 // Bonus points for comprehensive documentation
1633 if ($enhancement_score >= 15) {
1634 $validation['suggestions'][] = '✓ Comprehensive LLMs.txt documentation - excellent for AI assistance!';
1635 }
1636 }
1637
1638 /**
1639 * Build content sections from user input
1640 *
1641 * @since 1.0.0
1642 *
1643 * @param array $user_input User-provided data
1644 * @param array $settings Current settings
1645 * @return array Built content sections
1646 */
1647 private function build_content_sections(array $user_input, array $settings): array {
1648 $sections = [];
1649
1650 // Blockquote summary (required by spec)
1651 $sections['summary'] = [
1652 'title' => '', // No title for blockquote
1653 'content' => $this->build_summary_blockquote($user_input, $settings)
1654 ];
1655
1656 // Additional details (optional descriptive content)
1657 if (!empty($user_input['website_description'])) {
1658 $sections['details'] = [
1659 'title' => '', // No title for details
1660 'content' => $this->build_additional_details($user_input, $settings)
1661 ];
1662 }
1663
1664 // Development Approach section (if provided)
1665 if (!empty($user_input['development_approach'])) {
1666 $sections['development_approach'] = [
1667 'title' => 'Development Approach',
1668 'content' => sanitize_textarea_field($user_input['development_approach'])
1669 ];
1670 }
1671
1672 // Setup Instructions section (if provided)
1673 if (!empty($user_input['setup_instructions'])) {
1674 $sections['setup_instructions'] = [
1675 'title' => 'Setup Instructions',
1676 'content' => sanitize_textarea_field($user_input['setup_instructions'])
1677 ];
1678 }
1679
1680 // User-controlled structured sections (always include with defaults if empty)
1681 $documentation_content = !empty($user_input['documentation_links'])
1682 ? $this->sanitize_llms_content($user_input['documentation_links'])
1683 : $this->get_default_documentation_links();
1684
1685 $sections['documentation'] = [
1686 'title' => 'Documentation',
1687 'content' => $documentation_content
1688 ];
1689
1690 // Technical section (only if user provided content or technical details exist)
1691 if (!empty($user_input['technical_links']) || !empty($user_input['technical_stack']) || !empty($user_input['development_approach'])) {
1692 $technical_content = !empty($user_input['technical_links'])
1693 ? $this->sanitize_llms_content($user_input['technical_links'])
1694 : $this->get_default_technical_links($user_input);
1695
1696 $sections['technical'] = [
1697 'title' => 'Technical Details',
1698 'content' => $technical_content
1699 ];
1700 }
1701
1702 // Optional section (only if user provided content)
1703 if (!empty($user_input['optional_links'])) {
1704 $sections['optional'] = [
1705 'title' => 'Optional',
1706 'content' => $this->sanitize_llms_content($user_input['optional_links'])
1707 ];
1708 }
1709
1710 // Custom sections (user-defined markdown)
1711 if (!empty($user_input['custom_sections'])) {
1712 $sections['custom'] = [
1713 'title' => '', // No title since user provides their own H2 headers
1714 'content' => $this->sanitize_llms_content($user_input['custom_sections'])
1715 ];
1716 }
1717
1718 /**
1719 * Filter the llms.txt content sections before assembly.
1720 *
1721 * Each entry is ['title' => string, 'content' => string]; an empty
1722 * title emits the content without an H2. Pro appends a "Markdown for
1723 * AI" section here when that feature is enabled. Section content is
1724 * the callback's responsibility to sanitize.
1725 *
1726 * @since 1.32.0
1727 *
1728 * @param array $sections Sections keyed by slug.
1729 * @param array $user_input Validated user input for the generator.
1730 */
1731 return apply_filters('thinkrank_llms_txt_sections', $sections, $user_input);
1732 }
1733
1734 /**
1735 * Sanitize LLMs.txt content while preserving line breaks
1736 *
1737 * @since 1.0.0
1738 *
1739 * @param string $content Raw content to sanitize
1740 * @return string Sanitized content with preserved line breaks
1741 */
1742 public function sanitize_llms_content(string $content): string {
1743 // Remove any potential script tags and dangerous content
1744 $content = wp_kses($content, [
1745 'a' => ['href' => [], 'title' => []],
1746 'strong' => [],
1747 'em' => [],
1748 'code' => [],
1749 'pre' => []
1750 ]);
1751
1752 // wp_kses only guards HTML href attributes, not markdown link syntax
1753 // [text](url). Neutralize dangerous schemes (javascript:/data:/vbscript:)
1754 // in markdown link targets so they don't survive into the published file
1755 // for downstream consumers that render it as markdown/HTML.
1756 $content = preg_replace_callback('/\]\(([^)]*)\)/', static function ($m) {
1757 if (preg_match('#^\s*(?:javascript|data|vbscript):#i', $m[1])) {
1758 return '](#)';
1759 }
1760 return $m[0];
1761 }, $content);
1762
1763 // Normalize line endings and preserve line breaks
1764 $content = str_replace(["\r\n", "\r"], "\n", $content);
1765
1766 // Remove excessive whitespace but preserve intentional line breaks
1767 $content = preg_replace('/[ \t]+/', ' ', $content); // Multiple spaces/tabs to single space
1768 $content = preg_replace('/\n\s*\n\s*\n+/', "\n\n", $content); // Multiple empty lines to double
1769
1770 return trim($content);
1771 }
1772
1773 /**
1774 * Safely read file content with size limits
1775 *
1776 * @since 1.0.0
1777 *
1778 * @param string $file_path Path to file to read
1779 * @param int|null $max_size Maximum file size to read (null for class default)
1780 * @return array Result with success status, content, and any errors
1781 */
1782 private function safe_file_read(string $file_path, ?int $max_size = null): array {
1783 $result = [
1784 'success' => false,
1785 'content' => '',
1786 'error' => '',
1787 'file_size' => 0
1788 ];
1789
1790 if (!file_exists($file_path)) {
1791 $result['error'] = 'File does not exist';
1792 return $result;
1793 }
1794
1795 $file_size = filesize($file_path);
1796 $result['file_size'] = $file_size;
1797
1798 $max_allowed = $max_size ?? self::MAX_FILE_SIZE;
1799
1800 if ($file_size > $max_allowed) {
1801 $result['error'] = sprintf(
1802 'File size (%s) exceeds maximum allowed size (%s)',
1803 size_format($file_size),
1804 size_format($max_allowed)
1805 );
1806 return $result;
1807 }
1808
1809 if (!$this->init_filesystem()) {
1810 $result['error'] = 'Could not initialize WordPress filesystem';
1811 return $result;
1812 }
1813
1814 $content = $this->filesystem->get_contents($file_path);
1815 if (false === $content) {
1816 $result['error'] = 'Failed to read file content';
1817 return $result;
1818 }
1819
1820 $result['success'] = true;
1821 $result['content'] = $content;
1822 return $result;
1823 }
1824 private function build_llms_txt_content(array $sections, string $site_name = ''): string {
1825 // Sanitize inside the manager rather than trusting callers — the MCP
1826 // abilities pass site_name through unsanitized.
1827 $site_name = sanitize_text_field($site_name ?: get_bloginfo('name'));
1828 $content = "# {$site_name}\n\n";
1829
1830 foreach ($sections as $section_key => $section_data) {
1831 // Only add H2 header if title is not empty
1832 if (!empty($section_data['title'])) {
1833 $content .= "## {$section_data['title']}\n\n";
1834 }
1835 $content .= $section_data['content'] . "\n\n";
1836 }
1837
1838 // Add generation timestamp
1839 $content .= "---\n";
1840 $content .= "Generated by ThinkRank SEO Plugin on " . gmdate('Y-m-d H:i:s') . " UTC\n";
1841
1842 return $content;
1843 }
1844
1845 /**
1846 * Validate SEO settings (implements interface)
1847 *
1848 * @since 1.0.0
1849 *
1850 * @param array $settings Settings array to validate
1851 * @return array Validation results
1852 */
1853 public function validate_settings(array $settings): array {
1854 $validation = [
1855 'valid' => true,
1856 'errors' => [],
1857 'warnings' => [],
1858 'suggestions' => [],
1859 'score' => 100
1860 ];
1861
1862 // Validate enabled setting
1863 if (!isset($settings['enabled'])) {
1864 $validation['errors'][] = 'Enabled setting is required';
1865 $validation['valid'] = false;
1866 $validation['score'] -= 25;
1867 }
1868
1869 // Validate website description
1870 if (isset($settings['website_description'])) {
1871 if (empty($settings['website_description'])) {
1872 $validation['warnings'][] = 'Website description is empty, consider adding a description';
1873 $validation['score'] -= 15;
1874 } elseif (strlen($settings['website_description']) < 50) {
1875 $validation['suggestions'][] = 'Website description is quite short, consider adding more details';
1876 $validation['score'] -= 5;
1877 }
1878 }
1879
1880 // Validate key features
1881 if (isset($settings['key_features'])) {
1882 if (empty($settings['key_features'])) {
1883 $validation['warnings'][] = 'Key features are empty, consider listing main website features';
1884 $validation['score'] -= 15;
1885 }
1886 }
1887
1888 // Validate target audience
1889 if (isset($settings['target_audience'])) {
1890 if (empty($settings['target_audience'])) {
1891 $validation['suggestions'][] = 'Target audience is not specified, consider defining your audience';
1892 $validation['score'] -= 5;
1893 }
1894 }
1895
1896 // Check file permissions if enabled. Only the static delivery mode needs
1897 // a writable root — dynamic delivery keeps the document in the database.
1898 $mode = $this->resolve_delivery_mode(
1899 isset($settings['delivery_mode']) ? (string) $settings['delivery_mode'] : null
1900 );
1901 if (!empty($settings['enabled']) && 'static' === $mode) {
1902 if (!$this->is_directory_writable(ABSPATH)) {
1903 $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.';
1904 $validation['score'] -= 10;
1905 }
1906 }
1907
1908 // Ensure score doesn't go below 0
1909 $validation['score'] = max(0, $validation['score']);
1910
1911 return $validation;
1912 }
1913
1914 /**
1915 * Get output data for frontend rendering (implements interface)
1916 *
1917 * @since 1.0.0
1918 *
1919 * @param string $context_type The context type
1920 * @param int|null $context_id Optional. Context ID
1921 * @return array Output data ready for frontend rendering
1922 */
1923 public function get_output_data(string $context_type, ?int $context_id): array {
1924 $settings = $this->get_settings($context_type, $context_id);
1925
1926 $output = [
1927 'llms_txt_content' => '',
1928 'file_status' => [],
1929 'metadata' => [],
1930 'enabled' => $settings['enabled'] ?? true
1931 ];
1932
1933 if (!$output['enabled']) {
1934 return $output;
1935 }
1936
1937 // Get file status
1938 $output['file_status'] = $this->get_llms_txt_status();
1939
1940 // If a file is published, get its current content safely; otherwise fall
1941 // back to the stored document that dynamic delivery serves.
1942 if ($output['file_status']['file_exists']) {
1943 $llms_file = ABSPATH . 'llms.txt';
1944 $read_result = $this->safe_file_read($llms_file);
1945 if ($read_result['success']) {
1946 $output['llms_txt_content'] = $read_result['content'];
1947 } else {
1948 $output['llms_txt_content'] = '';
1949 $output['file_read_error'] = $read_result['error'];
1950 }
1951 } else {
1952 $output['llms_txt_content'] = $this->get_published_content();
1953 }
1954
1955 // Add metadata
1956 $output['metadata'] = [
1957 'last_generated' => $settings['last_generated'] ?? null,
1958 'generator_version' => THINKRANK_VERSION ?? '1.0.0',
1959 'website_url' => home_url()
1960 ];
1961
1962 return $output;
1963 }
1964
1965 /**
1966 * Get default settings for a context type (implements interface)
1967 *
1968 * @since 1.0.0
1969 *
1970 * @param string $context_type The context type to get defaults for
1971 * @return array Default settings array
1972 */
1973 public function get_default_settings(string $context_type): array {
1974 $defaults = [
1975 'enabled' => true,
1976 'site_name' => get_bloginfo('name'),
1977 'website_description' => get_bloginfo('description'),
1978 'key_features' => '',
1979 'target_audience' => 'general',
1980 'business_type' => 'website',
1981 'technical_stack' => 'WordPress',
1982 'development_approach' => '',
1983 'setup_instructions' => '',
1984 'ai_context_custom' => '',
1985 'auto_generate' => false,
1986 'delivery_mode' => 'auto',
1987 'last_generated' => null,
1988 // Structured sections for llms.txt spec compliance
1989 'documentation_links' => '',
1990 'technical_links' => '',
1991 'optional_links' => '',
1992 'custom_sections' => ''
1993 ];
1994
1995 // Context-specific defaults
1996 switch ($context_type) {
1997 case 'site':
1998 // Site-wide defaults are already set above
1999 break;
2000 default:
2001 // Use site defaults for other contexts
2002 break;
2003 }
2004
2005 return $defaults;
2006 }
2007
2008 /**
2009 * Get settings schema definition (implements interface)
2010 *
2011 * @since 1.0.0
2012 *
2013 * @param string $context_type The context type to get schema for
2014 * @return array Settings schema definition
2015 */
2016 public function get_settings_schema(string $context_type): array {
2017 return [
2018 'enabled' => [
2019 'type' => 'boolean',
2020 'title' => 'Enable LLMs.txt',
2021 'description' => 'Enable LLMs.txt file generation and management',
2022 'default' => true
2023 ],
2024 'site_name' => [
2025 'type' => 'string',
2026 'title' => 'Website Title',
2027 'description' => 'The name of your website as it will appear in the LLMs.txt file',
2028 'default' => get_bloginfo('name'),
2029 'maxLength' => 60
2030 ],
2031 'website_description' => [
2032 'type' => 'string',
2033 'title' => 'Website Description',
2034 'description' => 'Comprehensive description of your website and its purpose',
2035 'default' => get_bloginfo('description'),
2036 'maxLength' => 1000
2037 ],
2038 'key_features' => [
2039 'type' => 'string',
2040 'title' => 'Key Features',
2041 'description' => 'Main features and functionality of your website',
2042 'default' => '',
2043 'maxLength' => 500
2044 ],
2045 'target_audience' => [
2046 'type' => 'string',
2047 'title' => 'Target Audience',
2048 'description' => 'Primary audience for your website',
2049 'default' => 'general',
2050 'maxLength' => 200
2051 ],
2052 'business_type' => [
2053 'type' => 'string',
2054 'title' => 'Business Type',
2055 'description' => 'Type of website or business',
2056 'enum' => array_keys($this->business_types),
2057 'default' => 'website'
2058 ],
2059 'technical_stack' => [
2060 'type' => 'string',
2061 'title' => 'Technical Stack',
2062 'description' => 'Technologies and frameworks used',
2063 'default' => 'WordPress',
2064 'maxLength' => 300
2065 ],
2066 'development_approach' => [
2067 'type' => 'string',
2068 'title' => 'Development Approach',
2069 'description' => 'Development methodology and practices',
2070 'default' => '',
2071 'maxLength' => 400
2072 ],
2073 'setup_instructions' => [
2074 'type' => 'string',
2075 'title' => 'Setup Instructions',
2076 'description' => 'Instructions for setting up or working with the project',
2077 'default' => '',
2078 'maxLength' => 500
2079 ],
2080 'ai_context_custom' => [
2081 'type' => 'string',
2082 'title' => 'Additional AI Context',
2083 'description' => 'Custom context information for AI assistants',
2084 'default' => '',
2085 'maxLength' => 400
2086 ],
2087 'auto_generate' => [
2088 'type' => 'boolean',
2089 'title' => 'Auto-generate',
2090 'description' => 'Automatically regenerate llms.txt when settings change',
2091 'default' => false
2092 ],
2093 'delivery_mode' => [
2094 'type' => 'string',
2095 'title' => 'Delivery Method',
2096 '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.',
2097 'enum' => self::DELIVERY_MODES,
2098 'default' => 'auto'
2099 ],
2100 'last_generated' => [
2101 'type' => 'string',
2102 'title' => 'Last Generated',
2103 'description' => 'Timestamp of last generation',
2104 'format' => 'date-time',
2105 'readonly' => true
2106 ],
2107 'documentation_links' => [
2108 'type' => 'string',
2109 'title' => 'Documentation Links',
2110 'description' => 'Links to documentation, guides, and important pages',
2111 'default' => '',
2112 'maxLength' => 2000
2113 ],
2114 'technical_links' => [
2115 'type' => 'string',
2116 'title' => 'Technical Links',
2117 'description' => 'Links to technical resources, code repositories, and development info',
2118 'default' => '',
2119 'maxLength' => 2000
2120 ],
2121 'optional_links' => [
2122 'type' => 'string',
2123 'title' => 'Optional Links',
2124 'description' => 'Secondary resources that can be skipped for shorter context',
2125 'default' => '',
2126 'maxLength' => 2000
2127 ],
2128 'custom_sections' => [
2129 'type' => 'string',
2130 'title' => 'Custom Sections',
2131 'description' => 'Additional custom sections in markdown format',
2132 'default' => '',
2133 'maxLength' => 3000
2134 ]
2135 ];
2136 }
2137 private function build_summary_blockquote(array $user_input, array $settings): string {
2138 $description = sanitize_textarea_field($user_input['website_description'] ?? '');
2139
2140 if (empty($description)) {
2141 $site_name = sanitize_text_field($user_input['site_name'] ?? $settings['site_name'] ?? get_bloginfo('name'));
2142 $business_type = $user_input['business_type'] ?? 'website';
2143 $description = "{$site_name} is a {$this->business_types[$business_type]} providing valuable resources and information.";
2144 }
2145
2146 // Format as blockquote (required by spec)
2147 return "> " . $description . "\n\n";
2148 }
2149
2150 /**
2151 * Build additional details section
2152 *
2153 * @since 1.0.0
2154 *
2155 * @param array $user_input User input data
2156 * @param array $settings Current settings
2157 * @return string Additional details content
2158 */
2159 private function build_additional_details(array $user_input, array $settings): string {
2160 $content = '';
2161 $target_audience = sanitize_text_field($user_input['target_audience'] ?? '');
2162 $key_features = sanitize_textarea_field($user_input['key_features'] ?? '');
2163
2164 if (!empty($target_audience)) {
2165 $content .= "**Target Audience:** {$target_audience}\n\n";
2166 }
2167
2168 if (!empty($key_features)) {
2169 $content .= "**Key Features:**\n";
2170 // The UI field is a multi-line textarea and validation counts by
2171 // newline, so split on newlines (and still tolerate commas) rather
2172 // than commas only — otherwise newline-separated input collapses
2173 // into one broken bullet.
2174 $features = preg_split('/[\r\n,]+/', $key_features);
2175 foreach ($features as $feature) {
2176 $feature = trim($feature);
2177 if (!empty($feature)) {
2178 $content .= "- " . $feature . "\n";
2179 }
2180 }
2181 $content .= "\n";
2182 }
2183
2184 return $content;
2185 }
2186 private function get_default_documentation_links(): string {
2187 $website_url = home_url();
2188 $content = '';
2189
2190 // Add basic WordPress links
2191 $content .= "- [Website Home]({$website_url}): Main website homepage\n";
2192 $content .= "- [Sitemap]({$website_url}/sitemap.xml): Complete site structure\n";
2193
2194 return $content;
2195 }
2196
2197 /**
2198 * Get default technical links based on user input
2199 *
2200 * @since 1.0.0
2201 *
2202 * @param array $user_input User input data
2203 * @return string Default technical links
2204 */
2205 private function get_default_technical_links(array $user_input): string {
2206 $website_url = home_url();
2207 $content = '';
2208
2209 if (!empty($user_input['technical_stack'])) {
2210 $stack = sanitize_text_field($user_input['technical_stack']);
2211 $content .= "- [Technical Stack]({$website_url}): Built with {$stack}\n";
2212 }
2213
2214 if (!empty($user_input['development_approach'])) {
2215 $approach_summary = \ThinkRank\Core\Seo_Text::trim_words($user_input['development_approach'], 10);
2216 $content .= "- [Development Guidelines]({$website_url}): {$approach_summary}\n";
2217 }
2218
2219 // Add robots.txt reference
2220 $content .= "- [Robots.txt]({$website_url}/robots.txt): Site crawling guidelines\n";
2221
2222 return $content;
2223 }
2224 }
2225