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

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