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

2,206 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 // 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 * Ask the common page/CDN cache layers to drop their copy of /llms.txt.
755 *
756 * A cached response outlives a republish, so without this a mode switch or
757 * a content change keeps serving the old document (and, on the static path,
758 * the old headers). Every call is guarded — a site running none of these
759 * simply gets the action hook, which integrations can use.
760 *
761 * @since 2.1.0
762 *
763 * @return void
764 */
765 private function purge_llms_txt_caches(): void {
766 $url = home_url('/llms.txt');
767
768 /**
769 * Fires after the published llms.txt changes, so cache layers ThinkRank
770 * does not know about can drop their copy.
771 *
772 * @since 2.1.0
773 *
774 * @param string $url Public URL of the llms.txt document.
775 */
776 do_action('thinkrank_llms_txt_updated', $url);
777
778 // LiteSpeed Cache and Nginx Helper both listen on their own actions.
779 // These are third-party hook names we fire, not ours to prefix.
780 do_action('litespeed_purge_url', $url); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
781 do_action('rt_nginx_helper_purge_all'); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
782
783 if (function_exists('rocket_clean_files')) {
784 rocket_clean_files([$url]);
785 }
786 if (function_exists('w3tc_flush_url')) {
787 w3tc_flush_url($url);
788 }
789 if (function_exists('wpsc_delete_url_cache')) {
790 wpsc_delete_url_cache($url);
791 }
792 }
793
794 /**
795 * Unpublish llms.txt: drop the stored document and any physical file.
796 *
797 * Both delivery modes are cleared, not just the active one, so a site that
798 * published under one mode and switched to the other is left with nothing
799 * still being served.
800 *
801 * @return bool True once nothing is left to serve.
802 */
803 public function delete_llms_txt_file(): bool {
804 delete_option(self::CONTENT_OPTION);
805 delete_option(self::PUBLISHED_AT_OPTION);
806
807 return $this->unpublish_static_file();
808 }
809
810 /**
811 * Stop serving llms.txt, but keep the document.
812 *
813 * Deactivation needs this half: the physical file must go — it shadows the
814 * next plugin's routes and advertises a plugin that is switched off — but
815 * the user's prose has to survive so reactivation can republish it.
816 * {@see \ThinkRank\Core\Activator::restore_webroot_artifacts()} does that.
817 *
818 * Deactivation previously called {@see delete_llms_txt_file()}, which drops
819 * the stored document too, so a deactivate/reactivate round-trip silently
820 * lost whatever the user had written.
821 *
822 * @since 2.1.0
823 *
824 * @return bool True once nothing is left on disk.
825 */
826 public function unpublish_static_file(): bool {
827 delete_transient('thinkrank_llms_file_status');
828
829 $removed = $this->delete_static_file();
830 $this->purge_llms_txt_caches();
831
832 return $removed;
833 }
834
835 /**
836 * Remove the physical ABSPATH/llms.txt and its .htaccess charset block.
837 *
838 * @since 2.1.0
839 *
840 * @return bool True if the file is absent or was removed.
841 */
842 private function delete_static_file(): bool {
843 $llms_file = ABSPATH . 'llms.txt';
844 if (!file_exists($llms_file)) {
845 $this->remove_htaccess_charset();
846 return true;
847 }
848 if (!$this->init_filesystem()) {
849 return false;
850 }
851
852 $deleted = (bool) $this->filesystem->delete($llms_file);
853 if ($deleted) {
854 // Leave no orphaned rule behind once the file is gone.
855 $this->remove_htaccess_charset();
856 }
857
858 return $deleted;
859 }
860
861 /**
862 * Pin the served charset of the physical llms.txt to UTF-8 via .htaccess.
863 *
864 * Scoped to the single file with <Files>, and wrapped in <IfModule> so a
865 * server without mod_mime ignores it instead of returning a 500. Nginx does
866 * not read .htaccess — there the PHP route in {@see serve_llms_txt()} is
867 * what carries the charset, provided no physical file shadows it.
868 *
869 * @since 1.32.0
870 *
871 * @return bool True when the block is in place.
872 */
873 private function sync_htaccess_charset(): bool {
874 // $is_apache also covers LiteSpeed, which reads .htaccess the same way.
875 if (empty($GLOBALS['is_apache'])) {
876 return false;
877 }
878
879 $htaccess = ABSPATH . '.htaccess';
880
881 if (file_exists($htaccess)) {
882 if (!$this->is_file_writable($htaccess)) {
883 return false;
884 }
885 } elseif (!$this->is_directory_writable(ABSPATH)) {
886 return false;
887 }
888
889 if (!function_exists('insert_with_markers')) {
890 require_once ABSPATH . 'wp-admin/includes/misc.php';
891 }
892
893 return (bool) insert_with_markers($htaccess, self::HTACCESS_MARKER, [
894 '<IfModule mod_mime.c>',
895 '<Files "llms.txt">',
896 "ForceType 'text/plain; charset=UTF-8'",
897 '</Files>',
898 '</IfModule>',
899 ]);
900 }
901
902 /**
903 * Remove ThinkRank's charset block from .htaccess.
904 *
905 * Strips the block outright rather than calling insert_with_markers() with
906 * an empty insertion — that leaves the BEGIN/END markers behind as litter.
907 *
908 * @since 1.32.0
909 *
910 * @return void
911 */
912 private function remove_htaccess_charset(): void {
913 $htaccess = ABSPATH . '.htaccess';
914
915 if (!file_exists($htaccess) || !$this->is_file_writable($htaccess)) {
916 return;
917 }
918
919 if (!$this->init_filesystem()) {
920 return;
921 }
922
923 $contents = $this->filesystem->get_contents($htaccess);
924 if (!is_string($contents) || false === strpos($contents, '# BEGIN ' . self::HTACCESS_MARKER)) {
925 return;
926 }
927
928 $marker = preg_quote(self::HTACCESS_MARKER, '/');
929 $cleaned = preg_replace(
930 '/\R*# BEGIN ' . $marker . '.*?# END ' . $marker . '[ \t]*\R?/s',
931 '',
932 $contents
933 );
934
935 if (!is_string($cleaned)) {
936 return;
937 }
938
939 // A file left holding nothing but our (now removed) block was ours to
940 // begin with — a pre-existing .htaccess would still have content.
941 if ('' === trim($cleaned)) {
942 $this->filesystem->delete($htaccess);
943 return;
944 }
945
946 // Keep the file newline-terminated after the block is cut out.
947 $this->filesystem->put_contents($htaccess, rtrim($cleaned, "\r\n") . "\n", FS_CHMOD_FILE);
948 }
949
950 /**
951 * Serve /llms.txt from PHP with an explicit UTF-8 charset.
952 *
953 * Only reached when the request actually gets to WordPress — i.e. when no
954 * physical llms.txt shadows the route, or on a stack that routes every
955 * request through index.php. Prefers the published file's exact bytes and
956 * falls back to regenerating from the saved settings, so the response is
957 * the same document either way, just with headers PHP controls.
958 *
959 * Called by \ThinkRank\Frontend\SEO_Manager on template_redirect.
960 *
961 * @since 1.32.0
962 *
963 * @return void
964 */
965 public function serve_llms_txt(): void {
966 $settings = $this->get_settings('site');
967
968 // Never resurrect the file for a site that turned the feature off.
969 if (empty($settings['enabled'])) {
970 return;
971 }
972
973 $content = '';
974 $llms_file = ABSPATH . 'llms.txt';
975
976 // In static mode a physical file is what the server would normally hand
977 // back, so prefer its exact bytes; in dynamic mode there is no file and
978 // the stored document is the authoritative copy.
979 if ('static' === $this->resolve_delivery_mode() && file_exists($llms_file)) {
980 $read_result = $this->safe_file_read($llms_file);
981 if ($read_result['success']) {
982 $content = $read_result['content'];
983 }
984 }
985
986 if ('' === trim($content)) {
987 $content = $this->get_published_content();
988 }
989
990 if ('' === trim($content)) {
991 $generated = $this->generate_llms_txt([]);
992 $content = (string) ($generated['content'] ?? '');
993 }
994
995 // Nothing configured yet: leave the 404 alone rather than serving a stub.
996 if ('' === trim($content)) {
997 return;
998 }
999
1000 status_header(200);
1001 header('Content-Type: text/plain; charset=utf-8');
1002
1003 // Plain-text file body — already sanitized on save by
1004 // sanitize_llms_content(); escaping it here would corrupt the markdown.
1005 echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1006 exit;
1007 }
1008
1009 /**
1010 * Write LLMs.txt content to filesystem
1011 *
1012 * @since 1.0.0
1013 *
1014 * @param string $content LLMs.txt content to write
1015 * @return array Write operation result
1016 */
1017 public function write_llms_txt_to_file(string $content): array {
1018 $result = [
1019 'success' => false,
1020 'message' => '',
1021 'file_path' => '',
1022 'permissions' => []
1023 ];
1024
1025 // Refuse to publish when the feature is disabled. The React UI hides the
1026 // publish button, but the REST endpoint and the MCP publish ability call
1027 // this directly, so enforce the toggle here at the single write choke point.
1028 $settings = $this->get_settings('site');
1029 if (empty($settings['enabled'])) {
1030 $result['message'] = 'LLMs.txt is disabled. Enable it before publishing.';
1031 return $result;
1032 }
1033
1034 $mode = $this->resolve_delivery_mode();
1035 $result['delivery_mode'] = $mode;
1036
1037 $llms_file = ABSPATH . 'llms.txt';
1038 $result['file_path'] = $llms_file;
1039
1040 // Dynamic delivery: the document lives in the database and /llms.txt is
1041 // answered by serve_llms_txt(), which sets `charset=utf-8` itself. A
1042 // physical file would shadow that route on every stack, so any leftover
1043 // from a previous static publish has to go.
1044 if ('dynamic' === $mode) {
1045 if (!$this->delete_static_file()) {
1046 $result['message'] = 'A physical llms.txt is still present and could not be removed. It would be served instead of the dynamic route.';
1047 return $result;
1048 }
1049
1050 $this->store_published_content($content);
1051
1052 $result['success'] = true;
1053 $result['message'] = 'LLMs.txt published. It is served by WordPress as UTF-8 text.';
1054 $result['bytes_written'] = strlen($content);
1055 $result['charset_pinned'] = true;
1056 $result['permissions'] = [
1057 'directory_writable' => $this->is_directory_writable(ABSPATH),
1058 'file_exists' => false,
1059 'file_writable' => null,
1060 ];
1061
1062 return $result;
1063 }
1064
1065 // Security: Validate file path to prevent path traversal attacks
1066 $real_llms_file = realpath(dirname($llms_file)) . DIRECTORY_SEPARATOR . basename($llms_file);
1067 $allowed_dir = realpath(ABSPATH);
1068
1069 if (!$allowed_dir || strpos(dirname($real_llms_file), $allowed_dir) !== 0) {
1070 $result['message'] = 'Invalid file path detected for security reasons.';
1071 return $result;
1072 }
1073
1074 // Check directory permissions
1075 $result['permissions'] = [
1076 'directory_writable' => $this->is_directory_writable(ABSPATH),
1077 'file_exists' => file_exists($llms_file),
1078 'file_writable' => file_exists($llms_file) ? $this->is_file_writable($llms_file) : null
1079 ];
1080
1081 // Check if we can write to the directory
1082 if (!$result['permissions']['directory_writable']) {
1083 $result['message'] = 'WordPress root directory is not writable. Please check file permissions.';
1084 return $result;
1085 }
1086
1087 // Check if existing file is writable (if it exists)
1088 if ($result['permissions']['file_exists'] && !$result['permissions']['file_writable']) {
1089 $result['message'] = 'Existing llms.txt file is not writable. Please check file permissions.';
1090 return $result;
1091 }
1092
1093 // Write new content using WP_Filesystem
1094 if (!$this->init_filesystem()) {
1095 $result['message'] = 'Could not initialize WordPress filesystem.';
1096 return $result;
1097 }
1098
1099 if (!$this->filesystem->put_contents($llms_file, $content, FS_CHMOD_FILE)) {
1100 $result['message'] = 'Failed to write llms.txt file.';
1101 return $result;
1102 }
1103
1104 $result['success'] = true;
1105 $result['message'] = 'LLMs.txt file written successfully.';
1106 $result['bytes_written'] = strlen($content);
1107
1108 // Pin the served charset to UTF-8. Best effort: a site without a
1109 // writable .htaccess (or not on Apache/LiteSpeed) still gets a
1110 // correctly written file, so this must never fail the publish.
1111 $result['charset_pinned'] = $this->sync_htaccess_charset();
1112
1113 // Keep the stored copy in step with the file so a later switch to
1114 // dynamic delivery serves the same document.
1115 $this->store_published_content($content);
1116
1117 // Detection said this server reads the .htaccess block. Check what the
1118 // public URL really answers with before leaving the file in place — on a
1119 // reverse-proxied stack the detection describes the wrong server (#493).
1120 return $this->verify_static_delivery($result);
1121 }
1122
1123 /**
1124 * Persist the published document and bust the caches that mirror it.
1125 *
1126 * @since 2.1.0
1127 *
1128 * @param string $content Published llms.txt content.
1129 * @return void
1130 */
1131 private function store_published_content(string $content): void {
1132 update_option(self::CONTENT_OPTION, $content, false);
1133 update_option(self::PUBLISHED_AT_OPTION, time(), false);
1134
1135 // Invalidate file status cache since the published document has changed
1136 delete_transient('thinkrank_llms_file_status');
1137
1138 $this->purge_llms_txt_caches();
1139 }
1140
1141 /**
1142 * Get LLMs.txt file status and information
1143 *
1144 * @since 1.0.0
1145 *
1146 * @return array File status information
1147 */
1148 public function get_llms_txt_status(bool $force_refresh = false): array {
1149 // Check cache first (5 minute cache for performance)
1150 $cache_key = 'thinkrank_llms_file_status';
1151
1152 if (!$force_refresh) {
1153 $cached_status = get_transient($cache_key);
1154 if ($cached_status !== false) {
1155 return $cached_status;
1156 }
1157 }
1158
1159 $llms_file = ABSPATH . 'llms.txt';
1160 $mode = $this->resolve_delivery_mode();
1161
1162 // A site that published before this check existed — or whose server has
1163 // changed under it — has never had its delivery confirmed. Do it here so
1164 // an already-broken install heals without waiting for a republish; the
1165 // recorded verdict and the status cache keep it to a couple of requests
1166 // a day at most.
1167 if ('static' === $mode && file_exists($llms_file) && $this->delivery_probe_is_due()) {
1168 $this->verify_static_delivery(['message' => '']);
1169 $mode = $this->resolve_delivery_mode();
1170 }
1171
1172 $stored = $this->get_published_content();
1173
1174 $status = [
1175 'file_exists' => file_exists($llms_file),
1176 // Whether /llms.txt is actually being served, either mode. Prefer
1177 // this over file_exists, which is only meaningful in static mode.
1178 'published' => file_exists($llms_file) || '' !== trim($stored),
1179 'delivery_mode' => $mode,
1180 'file_path' => 'dynamic' === $mode ? '' : $llms_file,
1181 'file_url' => home_url('/llms.txt'),
1182 'writable' => $this->is_directory_writable(dirname($llms_file)),
1183 // Non-empty only when the site is on static delivery that the server
1184 // is known to answer without a charset — i.e. an explicit `static`
1185 // the plugin will not overrule, which is the user's to fix.
1186 'delivery_warning' => 'static' === $mode && $this->static_delivery_drops_charset()
1187 ? self::STATIC_CHARSET_WARNING
1188 : '',
1189 'last_modified' => null,
1190 'file_size' => null,
1191 'content_preview' => ''
1192 ];
1193
1194 $content = null;
1195
1196 if ($status['file_exists']) {
1197 $status['last_modified'] = filemtime($llms_file);
1198 $status['file_size'] = filesize($llms_file);
1199
1200 $read_result = $this->safe_file_read($llms_file);
1201 if ($read_result['success']) {
1202 $content = $read_result['content'];
1203 } else {
1204 $status['content_preview'] = 'Error: ' . $read_result['error'];
1205 $status['read_error'] = $read_result['error'];
1206 }
1207 } elseif ('' !== trim($stored)) {
1208 $published_at = (int) get_option(self::PUBLISHED_AT_OPTION, 0);
1209 $status['last_modified'] = $published_at > 0 ? $published_at : null;
1210 $status['file_size'] = strlen($stored);
1211 $content = $stored;
1212 }
1213
1214 if (null !== $content) {
1215 // Get content preview (first 200 characters) with size safety
1216 $status['content_preview'] = substr($content, 0, 200);
1217 if (strlen($content) > 200) {
1218 $status['content_preview'] .= '...';
1219 }
1220 }
1221
1222 // Cache the result for 5 minutes to improve performance
1223 set_transient($cache_key, $status, 5 * MINUTE_IN_SECONDS);
1224
1225 return $status;
1226 }
1227
1228 /**
1229 * Validate LLMs.txt content
1230 *
1231 * @since 1.0.0
1232 *
1233 * @param string $content LLMs.txt content to validate
1234 * @return array Validation results
1235 */
1236 public function validate_llms_txt_content(string $content): array {
1237 $validation = [
1238 'valid' => true,
1239 'errors' => [],
1240 'warnings' => [],
1241 'suggestions' => [],
1242 'score' => 100
1243 ];
1244
1245 // Check if content is empty
1246 if (empty(trim($content))) {
1247 $validation['errors'][] = 'LLMs.txt content cannot be empty';
1248 $validation['valid'] = false;
1249 $validation['score'] = 0;
1250 return $validation;
1251 }
1252
1253 // Check content length
1254 $content_length = strlen($content);
1255 if ($content_length < 100) {
1256 $validation['warnings'][] = 'LLMs.txt content is very short, consider adding more details';
1257 $validation['score'] -= 20;
1258 } elseif ($content_length > 10000) {
1259 $validation['warnings'][] = 'LLMs.txt content is very long, consider condensing key information';
1260 $validation['score'] -= 10;
1261 }
1262
1263 // Check for required sections
1264 $required_sections = ['Project Overview', 'Key Features', 'Context for AI Assistants'];
1265 foreach ($required_sections as $section) {
1266 if (stripos($content, $section) === false) {
1267 $validation['warnings'][] = "Missing recommended section: {$section}";
1268 $validation['score'] -= 15;
1269 }
1270 }
1271
1272 // Check for proper structure
1273 if (!preg_match('/^#\s+/', $content)) {
1274 $validation['suggestions'][] = 'Consider starting with a main heading (# Project Name)';
1275 $validation['score'] -= 5;
1276 }
1277
1278 // Ensure score doesn't go below 0
1279 $validation['score'] = max(0, $validation['score']);
1280
1281 return $validation;
1282 }
1283
1284 /**
1285 * Validate user input for LLMs.txt generation
1286 *
1287 * @since 1.0.0
1288 *
1289 * @param array $user_input User-provided data
1290 * @return array Validation results
1291 */
1292 private function validate_user_input(array $user_input): array {
1293 $validation = [
1294 'valid' => true,
1295 'errors' => [],
1296 'warnings' => [],
1297 'suggestions' => [],
1298 'score' => 100
1299 ];
1300
1301 // Check required fields
1302 $required_fields = [
1303 'website_description' => 'Website Description',
1304 'key_features' => 'Key Features',
1305 'target_audience' => 'Target Audience'
1306 ];
1307
1308 foreach ($required_fields as $field => $label) {
1309 if (empty($user_input[$field])) {
1310 $validation['errors'][] = "{$label} is required for quality LLMs.txt generation";
1311 $validation['valid'] = false;
1312 $validation['score'] -= 25;
1313 } else {
1314 $validation['suggestions'][] = "{$label} is properly configured";
1315 }
1316 }
1317
1318 // Validate link formats in structured sections
1319 $this->validate_link_sections($user_input, $validation);
1320
1321 // Check content quality
1322 $this->validate_content_quality($user_input, $validation);
1323
1324 // Check optional enhancements
1325 $this->validate_optional_enhancements($user_input, $validation);
1326
1327 // Validate website description
1328 if (!empty($user_input['website_description'])) {
1329 $desc_length = strlen($user_input['website_description']);
1330 if ($desc_length < 50) {
1331 $validation['warnings'][] = 'Website description is quite short, consider adding more details';
1332 $validation['score'] -= 10;
1333 } elseif ($desc_length > 1000) {
1334 $validation['warnings'][] = 'Website description is very long, consider condensing key points';
1335 $validation['score'] -= 5;
1336 }
1337 }
1338
1339 // Validate business type
1340 if (!empty($user_input['business_type']) && !isset($this->business_types[$user_input['business_type']])) {
1341 $validation['warnings'][] = 'Unknown business type specified';
1342 $validation['score'] -= 5;
1343 }
1344
1345 // Ensure score doesn't go below 0
1346 $validation['score'] = max(0, $validation['score']);
1347
1348 return $validation;
1349 }
1350
1351 /**
1352 * Validate LLMs.txt input (public method for API)
1353 *
1354 * @since 1.0.0
1355 *
1356 * @param array $user_input User-provided data
1357 * @return array Validation results
1358 */
1359 public function validate_llms_txt_input(array $user_input): array {
1360 return $this->validate_user_input($user_input);
1361 }
1362
1363 /**
1364 * Override parent sanitize_settings to preserve line breaks in link fields
1365 *
1366 * @since 1.0.0
1367 *
1368 * @param array $settings Settings to sanitize
1369 * @param string $context_type Context the save is for.
1370 * @return array Sanitized settings
1371 */
1372 protected function sanitize_settings(array $settings, string $context_type = 'site'): array {
1373 $sanitized = [];
1374 $known = $this->get_known_setting_keys($context_type);
1375
1376 // Fields that should preserve line breaks
1377 $preserve_linebreaks = [
1378 'documentation_links',
1379 'technical_links',
1380 'optional_links',
1381 'custom_sections',
1382 'key_features',
1383 'website_description',
1384 'technical_stack',
1385 'development_approach',
1386 'setup_instructions',
1387 'ai_context_custom'
1388 ];
1389
1390 foreach ($settings as $key => $value) {
1391 $sanitized_key = sanitize_key($key);
1392
1393 // Never store the REST envelope back as settings (see
1394 // Abstract_Seo_Manager::RESERVED_ENVELOPE_KEYS).
1395 if (in_array($sanitized_key, self::RESERVED_ENVELOPE_KEYS, true)) {
1396 continue;
1397 }
1398
1399 // And nothing this manager does not declare (#452).
1400 if (!$this->is_known_setting_key($sanitized_key, $known)) {
1401 continue;
1402 }
1403
1404 // Constrain the delivery mode to the known enum so an unexpected
1405 // value falls back to auto-detection rather than being stored.
1406 if ('delivery_mode' === $sanitized_key) {
1407 $mode = is_string($value) ? sanitize_key($value) : '';
1408 $sanitized[$sanitized_key] = in_array($mode, self::DELIVERY_MODES, true) ? $mode : 'auto';
1409 continue;
1410 }
1411
1412 if (is_string($value)) {
1413 if (in_array($key, $preserve_linebreaks, true)) {
1414 // Use our custom sanitization that preserves line breaks
1415 if (in_array($key, ['documentation_links', 'technical_links', 'optional_links', 'custom_sections'], true)) {
1416 $sanitized[$sanitized_key] = $this->sanitize_llms_content($value);
1417 } else {
1418 // For textarea fields, use sanitize_textarea_field which preserves line breaks
1419 $sanitized[$sanitized_key] = sanitize_textarea_field($value);
1420 }
1421 } else {
1422 // For regular text fields, use sanitize_text_field
1423 $sanitized[$sanitized_key] = sanitize_text_field($value);
1424 }
1425 } elseif (is_array($value)) {
1426 $sanitized[$sanitized_key] = $this->sanitize_array_recursive($value);
1427 } elseif (is_numeric($value)) {
1428 $sanitized[$sanitized_key] = (float) $value;
1429 } elseif (is_bool($value)) {
1430 $sanitized[$sanitized_key] = (bool) $value;
1431 } else {
1432 $sanitized[$sanitized_key] = sanitize_text_field((string) $value);
1433 }
1434 }
1435
1436 return $sanitized;
1437 }
1438
1439 /**
1440 * Recursively sanitize array values (preserving line breaks where needed)
1441 *
1442 * @since 1.0.0
1443 *
1444 * @param array $input Array to sanitize
1445 * @return array Sanitized array
1446 */
1447 private function sanitize_array_recursive(array $input): array {
1448 $sanitized = [];
1449
1450 foreach ($input as $key => $value) {
1451 $sanitized_key = sanitize_key($key);
1452
1453 if (is_string($value)) {
1454 $sanitized[$sanitized_key] = sanitize_textarea_field($value);
1455 } elseif (is_array($value)) {
1456 $sanitized[$sanitized_key] = $this->sanitize_array_recursive($value);
1457 } elseif (is_numeric($value)) {
1458 $sanitized[$sanitized_key] = (float) $value;
1459 } elseif (is_bool($value)) {
1460 $sanitized[$sanitized_key] = (bool) $value;
1461 } else {
1462 $sanitized[$sanitized_key] = sanitize_text_field((string) $value);
1463 }
1464 }
1465
1466 return $sanitized;
1467 }
1468
1469 /**
1470 * Validate link formats in structured sections
1471 *
1472 * @since 1.0.0
1473 *
1474 * @param array $user_input User input data
1475 * @param array &$validation Validation results (passed by reference)
1476 */
1477 private function validate_link_sections(array $user_input, array &$validation): void {
1478 $link_sections = [
1479 'documentation_links' => 'Documentation Links',
1480 'technical_links' => 'Technical Links',
1481 'optional_links' => 'Optional Links'
1482 ];
1483
1484 foreach ($link_sections as $field => $label) {
1485 if (!empty($user_input[$field])) {
1486 $links = explode("\n", $user_input[$field]);
1487 $valid_links = 0;
1488 $total_links = 0;
1489
1490 foreach ($links as $line) {
1491 $line = trim($line);
1492 if (empty($line) || !str_starts_with($line, '-')) {
1493 continue;
1494 }
1495
1496 $total_links++;
1497
1498 // Check for proper markdown link format: - [Title](URL): Description
1499 if (preg_match('/^-\s*\[([^\]]+)\]\(([^)]+)\):\s*(.+)$/', $line, $matches)) {
1500 $title = trim($matches[1]);
1501 $url = trim($matches[2]);
1502 $description = trim($matches[3]);
1503
1504 if (!empty($title) && !empty($url) && !empty($description)) {
1505 if (filter_var($url, FILTER_VALIDATE_URL)) {
1506 $valid_links++;
1507 } else {
1508 $validation['warnings'][] = "Invalid URL in {$label}: {$url}";
1509 $validation['score'] -= 5;
1510 }
1511 } else {
1512 $validation['warnings'][] = "Incomplete link format in {$label}: missing title, URL, or description";
1513 $validation['score'] -= 5;
1514 }
1515 } else {
1516 $validation['warnings'][] = "Invalid link format in {$label}. Use: - [Title](URL): Description";
1517 $validation['score'] -= 5;
1518 }
1519 }
1520
1521 if ($total_links > 0) {
1522 if ($valid_links === $total_links) {
1523 $validation['suggestions'][] = "✓ All {$label} are properly formatted";
1524 } else {
1525 $validation['warnings'][] = "{$label}: {$valid_links}/{$total_links} links are properly formatted";
1526 }
1527 }
1528 }
1529 }
1530 }
1531
1532 /**
1533 * Validate content quality
1534 *
1535 * @since 1.0.0
1536 *
1537 * @param array $user_input User input data
1538 * @param array &$validation Validation results (passed by reference)
1539 */
1540 private function validate_content_quality(array $user_input, array &$validation): void {
1541 // Check website description quality
1542 if (!empty($user_input['website_description'])) {
1543 $desc_length = strlen($user_input['website_description']);
1544 if ($desc_length < 50) {
1545 $validation['warnings'][] = 'Website description is quite short. Consider adding more detail for better AI understanding';
1546 $validation['score'] -= 10;
1547 } elseif ($desc_length > 500) {
1548 $validation['warnings'][] = 'Website description is very long. Consider making it more concise';
1549 $validation['score'] -= 5;
1550 } else {
1551 $validation['suggestions'][] = '✓ Website description length is optimal';
1552 }
1553 }
1554
1555 // Check key features quality
1556 if (!empty($user_input['key_features'])) {
1557 $features = explode("\n", $user_input['key_features']);
1558 $feature_count = count(array_filter($features, 'trim'));
1559
1560 if ($feature_count < 3) {
1561 $validation['warnings'][] = 'Consider adding more key features (3-8 recommended) for comprehensive AI understanding';
1562 $validation['score'] -= 10;
1563 } elseif ($feature_count > 10) {
1564 $validation['warnings'][] = 'Many key features listed. Consider focusing on the most important ones';
1565 $validation['score'] -= 5;
1566 } else {
1567 $validation['suggestions'][] = "✓ Good number of key features ({$feature_count})";
1568 }
1569 }
1570 }
1571
1572 /**
1573 * Validate optional enhancements
1574 *
1575 * @since 1.0.0
1576 *
1577 * @param array $user_input User input data
1578 * @param array &$validation Validation results (passed by reference)
1579 */
1580 private function validate_optional_enhancements(array $user_input, array &$validation): void {
1581 $enhancement_score = 0;
1582
1583 // Check for technical stack
1584 if (!empty($user_input['technical_stack'])) {
1585 $validation['suggestions'][] = '✓ Technical stack information provided';
1586 $enhancement_score += 5;
1587 } else {
1588 $validation['suggestions'][] = 'Consider adding technical stack information for developer context';
1589 }
1590
1591 // Check for development approach
1592 if (!empty($user_input['development_approach'])) {
1593 $validation['suggestions'][] = '✓ Development approach documented';
1594 $enhancement_score += 5;
1595 } else {
1596 $validation['suggestions'][] = 'Consider documenting development approach for better AI assistance';
1597 }
1598
1599 // Check for setup instructions
1600 if (!empty($user_input['setup_instructions'])) {
1601 $validation['suggestions'][] = '✓ Setup instructions provided';
1602 $enhancement_score += 5;
1603 } else {
1604 $validation['suggestions'][] = 'Consider adding setup instructions for new developers';
1605 }
1606
1607 // Check for custom sections
1608 if (!empty($user_input['custom_sections'])) {
1609 $validation['suggestions'][] = '✓ Custom sections enhance documentation';
1610 $enhancement_score += 5;
1611 }
1612
1613 // Bonus points for comprehensive documentation
1614 if ($enhancement_score >= 15) {
1615 $validation['suggestions'][] = '✓ Comprehensive LLMs.txt documentation - excellent for AI assistance!';
1616 }
1617 }
1618
1619 /**
1620 * Build content sections from user input
1621 *
1622 * @since 1.0.0
1623 *
1624 * @param array $user_input User-provided data
1625 * @param array $settings Current settings
1626 * @return array Built content sections
1627 */
1628 private function build_content_sections(array $user_input, array $settings): array {
1629 $sections = [];
1630
1631 // Blockquote summary (required by spec)
1632 $sections['summary'] = [
1633 'title' => '', // No title for blockquote
1634 'content' => $this->build_summary_blockquote($user_input, $settings)
1635 ];
1636
1637 // Additional details (optional descriptive content)
1638 if (!empty($user_input['website_description'])) {
1639 $sections['details'] = [
1640 'title' => '', // No title for details
1641 'content' => $this->build_additional_details($user_input, $settings)
1642 ];
1643 }
1644
1645 // Development Approach section (if provided)
1646 if (!empty($user_input['development_approach'])) {
1647 $sections['development_approach'] = [
1648 'title' => 'Development Approach',
1649 'content' => sanitize_textarea_field($user_input['development_approach'])
1650 ];
1651 }
1652
1653 // Setup Instructions section (if provided)
1654 if (!empty($user_input['setup_instructions'])) {
1655 $sections['setup_instructions'] = [
1656 'title' => 'Setup Instructions',
1657 'content' => sanitize_textarea_field($user_input['setup_instructions'])
1658 ];
1659 }
1660
1661 // User-controlled structured sections (always include with defaults if empty)
1662 $documentation_content = !empty($user_input['documentation_links'])
1663 ? $this->sanitize_llms_content($user_input['documentation_links'])
1664 : $this->get_default_documentation_links();
1665
1666 $sections['documentation'] = [
1667 'title' => 'Documentation',
1668 'content' => $documentation_content
1669 ];
1670
1671 // Technical section (only if user provided content or technical details exist)
1672 if (!empty($user_input['technical_links']) || !empty($user_input['technical_stack']) || !empty($user_input['development_approach'])) {
1673 $technical_content = !empty($user_input['technical_links'])
1674 ? $this->sanitize_llms_content($user_input['technical_links'])
1675 : $this->get_default_technical_links($user_input);
1676
1677 $sections['technical'] = [
1678 'title' => 'Technical Details',
1679 'content' => $technical_content
1680 ];
1681 }
1682
1683 // Optional section (only if user provided content)
1684 if (!empty($user_input['optional_links'])) {
1685 $sections['optional'] = [
1686 'title' => 'Optional',
1687 'content' => $this->sanitize_llms_content($user_input['optional_links'])
1688 ];
1689 }
1690
1691 // Custom sections (user-defined markdown)
1692 if (!empty($user_input['custom_sections'])) {
1693 $sections['custom'] = [
1694 'title' => '', // No title since user provides their own H2 headers
1695 'content' => $this->sanitize_llms_content($user_input['custom_sections'])
1696 ];
1697 }
1698
1699 /**
1700 * Filter the llms.txt content sections before assembly.
1701 *
1702 * Each entry is ['title' => string, 'content' => string]; an empty
1703 * title emits the content without an H2. Pro appends a "Markdown for
1704 * AI" section here when that feature is enabled. Section content is
1705 * the callback's responsibility to sanitize.
1706 *
1707 * @since 1.32.0
1708 *
1709 * @param array $sections Sections keyed by slug.
1710 * @param array $user_input Validated user input for the generator.
1711 */
1712 return apply_filters('thinkrank_llms_txt_sections', $sections, $user_input);
1713 }
1714
1715 /**
1716 * Sanitize LLMs.txt content while preserving line breaks
1717 *
1718 * @since 1.0.0
1719 *
1720 * @param string $content Raw content to sanitize
1721 * @return string Sanitized content with preserved line breaks
1722 */
1723 public function sanitize_llms_content(string $content): string {
1724 // Remove any potential script tags and dangerous content
1725 $content = wp_kses($content, [
1726 'a' => ['href' => [], 'title' => []],
1727 'strong' => [],
1728 'em' => [],
1729 'code' => [],
1730 'pre' => []
1731 ]);
1732
1733 // wp_kses only guards HTML href attributes, not markdown link syntax
1734 // [text](url). Neutralize dangerous schemes (javascript:/data:/vbscript:)
1735 // in markdown link targets so they don't survive into the published file
1736 // for downstream consumers that render it as markdown/HTML.
1737 $content = preg_replace_callback('/\]\(([^)]*)\)/', static function ($m) {
1738 if (preg_match('#^\s*(?:javascript|data|vbscript):#i', $m[1])) {
1739 return '](#)';
1740 }
1741 return $m[0];
1742 }, $content);
1743
1744 // Normalize line endings and preserve line breaks
1745 $content = str_replace(["\r\n", "\r"], "\n", $content);
1746
1747 // Remove excessive whitespace but preserve intentional line breaks
1748 $content = preg_replace('/[ \t]+/', ' ', $content); // Multiple spaces/tabs to single space
1749 $content = preg_replace('/\n\s*\n\s*\n+/', "\n\n", $content); // Multiple empty lines to double
1750
1751 return trim($content);
1752 }
1753
1754 /**
1755 * Safely read file content with size limits
1756 *
1757 * @since 1.0.0
1758 *
1759 * @param string $file_path Path to file to read
1760 * @param int|null $max_size Maximum file size to read (null for class default)
1761 * @return array Result with success status, content, and any errors
1762 */
1763 private function safe_file_read(string $file_path, ?int $max_size = null): array {
1764 $result = [
1765 'success' => false,
1766 'content' => '',
1767 'error' => '',
1768 'file_size' => 0
1769 ];
1770
1771 if (!file_exists($file_path)) {
1772 $result['error'] = 'File does not exist';
1773 return $result;
1774 }
1775
1776 $file_size = filesize($file_path);
1777 $result['file_size'] = $file_size;
1778
1779 $max_allowed = $max_size ?? self::MAX_FILE_SIZE;
1780
1781 if ($file_size > $max_allowed) {
1782 $result['error'] = sprintf(
1783 'File size (%s) exceeds maximum allowed size (%s)',
1784 size_format($file_size),
1785 size_format($max_allowed)
1786 );
1787 return $result;
1788 }
1789
1790 if (!$this->init_filesystem()) {
1791 $result['error'] = 'Could not initialize WordPress filesystem';
1792 return $result;
1793 }
1794
1795 $content = $this->filesystem->get_contents($file_path);
1796 if (false === $content) {
1797 $result['error'] = 'Failed to read file content';
1798 return $result;
1799 }
1800
1801 $result['success'] = true;
1802 $result['content'] = $content;
1803 return $result;
1804 }
1805 private function build_llms_txt_content(array $sections, string $site_name = ''): string {
1806 // Sanitize inside the manager rather than trusting callers — the MCP
1807 // abilities pass site_name through unsanitized.
1808 $site_name = sanitize_text_field($site_name ?: get_bloginfo('name'));
1809 $content = "# {$site_name}\n\n";
1810
1811 foreach ($sections as $section_key => $section_data) {
1812 // Only add H2 header if title is not empty
1813 if (!empty($section_data['title'])) {
1814 $content .= "## {$section_data['title']}\n\n";
1815 }
1816 $content .= $section_data['content'] . "\n\n";
1817 }
1818
1819 // Add generation timestamp
1820 $content .= "---\n";
1821 $content .= "Generated by ThinkRank SEO Plugin on " . gmdate('Y-m-d H:i:s') . " UTC\n";
1822
1823 return $content;
1824 }
1825
1826 /**
1827 * Validate SEO settings (implements interface)
1828 *
1829 * @since 1.0.0
1830 *
1831 * @param array $settings Settings array to validate
1832 * @return array Validation results
1833 */
1834 public function validate_settings(array $settings): array {
1835 $validation = [
1836 'valid' => true,
1837 'errors' => [],
1838 'warnings' => [],
1839 'suggestions' => [],
1840 'score' => 100
1841 ];
1842
1843 // Validate enabled setting
1844 if (!isset($settings['enabled'])) {
1845 $validation['errors'][] = 'Enabled setting is required';
1846 $validation['valid'] = false;
1847 $validation['score'] -= 25;
1848 }
1849
1850 // Validate website description
1851 if (isset($settings['website_description'])) {
1852 if (empty($settings['website_description'])) {
1853 $validation['warnings'][] = 'Website description is empty, consider adding a description';
1854 $validation['score'] -= 15;
1855 } elseif (strlen($settings['website_description']) < 50) {
1856 $validation['suggestions'][] = 'Website description is quite short, consider adding more details';
1857 $validation['score'] -= 5;
1858 }
1859 }
1860
1861 // Validate key features
1862 if (isset($settings['key_features'])) {
1863 if (empty($settings['key_features'])) {
1864 $validation['warnings'][] = 'Key features are empty, consider listing main website features';
1865 $validation['score'] -= 15;
1866 }
1867 }
1868
1869 // Validate target audience
1870 if (isset($settings['target_audience'])) {
1871 if (empty($settings['target_audience'])) {
1872 $validation['suggestions'][] = 'Target audience is not specified, consider defining your audience';
1873 $validation['score'] -= 5;
1874 }
1875 }
1876
1877 // Check file permissions if enabled. Only the static delivery mode needs
1878 // a writable root — dynamic delivery keeps the document in the database.
1879 $mode = $this->resolve_delivery_mode(
1880 isset($settings['delivery_mode']) ? (string) $settings['delivery_mode'] : null
1881 );
1882 if (!empty($settings['enabled']) && 'static' === $mode) {
1883 if (!$this->is_directory_writable(ABSPATH)) {
1884 $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.';
1885 $validation['score'] -= 10;
1886 }
1887 }
1888
1889 // Ensure score doesn't go below 0
1890 $validation['score'] = max(0, $validation['score']);
1891
1892 return $validation;
1893 }
1894
1895 /**
1896 * Get output data for frontend rendering (implements interface)
1897 *
1898 * @since 1.0.0
1899 *
1900 * @param string $context_type The context type
1901 * @param int|null $context_id Optional. Context ID
1902 * @return array Output data ready for frontend rendering
1903 */
1904 public function get_output_data(string $context_type, ?int $context_id): array {
1905 $settings = $this->get_settings($context_type, $context_id);
1906
1907 $output = [
1908 'llms_txt_content' => '',
1909 'file_status' => [],
1910 'metadata' => [],
1911 'enabled' => $settings['enabled'] ?? true
1912 ];
1913
1914 if (!$output['enabled']) {
1915 return $output;
1916 }
1917
1918 // Get file status
1919 $output['file_status'] = $this->get_llms_txt_status();
1920
1921 // If a file is published, get its current content safely; otherwise fall
1922 // back to the stored document that dynamic delivery serves.
1923 if ($output['file_status']['file_exists']) {
1924 $llms_file = ABSPATH . 'llms.txt';
1925 $read_result = $this->safe_file_read($llms_file);
1926 if ($read_result['success']) {
1927 $output['llms_txt_content'] = $read_result['content'];
1928 } else {
1929 $output['llms_txt_content'] = '';
1930 $output['file_read_error'] = $read_result['error'];
1931 }
1932 } else {
1933 $output['llms_txt_content'] = $this->get_published_content();
1934 }
1935
1936 // Add metadata
1937 $output['metadata'] = [
1938 'last_generated' => $settings['last_generated'] ?? null,
1939 'generator_version' => THINKRANK_VERSION ?? '1.0.0',
1940 'website_url' => home_url()
1941 ];
1942
1943 return $output;
1944 }
1945
1946 /**
1947 * Get default settings for a context type (implements interface)
1948 *
1949 * @since 1.0.0
1950 *
1951 * @param string $context_type The context type to get defaults for
1952 * @return array Default settings array
1953 */
1954 public function get_default_settings(string $context_type): array {
1955 $defaults = [
1956 'enabled' => true,
1957 'site_name' => get_bloginfo('name'),
1958 'website_description' => get_bloginfo('description'),
1959 'key_features' => '',
1960 'target_audience' => 'general',
1961 'business_type' => 'website',
1962 'technical_stack' => 'WordPress',
1963 'development_approach' => '',
1964 'setup_instructions' => '',
1965 'ai_context_custom' => '',
1966 'auto_generate' => false,
1967 'delivery_mode' => 'auto',
1968 'last_generated' => null,
1969 // Structured sections for llms.txt spec compliance
1970 'documentation_links' => '',
1971 'technical_links' => '',
1972 'optional_links' => '',
1973 'custom_sections' => ''
1974 ];
1975
1976 // Context-specific defaults
1977 switch ($context_type) {
1978 case 'site':
1979 // Site-wide defaults are already set above
1980 break;
1981 default:
1982 // Use site defaults for other contexts
1983 break;
1984 }
1985
1986 return $defaults;
1987 }
1988
1989 /**
1990 * Get settings schema definition (implements interface)
1991 *
1992 * @since 1.0.0
1993 *
1994 * @param string $context_type The context type to get schema for
1995 * @return array Settings schema definition
1996 */
1997 public function get_settings_schema(string $context_type): array {
1998 return [
1999 'enabled' => [
2000 'type' => 'boolean',
2001 'title' => 'Enable LLMs.txt',
2002 'description' => 'Enable LLMs.txt file generation and management',
2003 'default' => true
2004 ],
2005 'site_name' => [
2006 'type' => 'string',
2007 'title' => 'Website Title',
2008 'description' => 'The name of your website as it will appear in the LLMs.txt file',
2009 'default' => get_bloginfo('name'),
2010 'maxLength' => 60
2011 ],
2012 'website_description' => [
2013 'type' => 'string',
2014 'title' => 'Website Description',
2015 'description' => 'Comprehensive description of your website and its purpose',
2016 'default' => get_bloginfo('description'),
2017 'maxLength' => 1000
2018 ],
2019 'key_features' => [
2020 'type' => 'string',
2021 'title' => 'Key Features',
2022 'description' => 'Main features and functionality of your website',
2023 'default' => '',
2024 'maxLength' => 500
2025 ],
2026 'target_audience' => [
2027 'type' => 'string',
2028 'title' => 'Target Audience',
2029 'description' => 'Primary audience for your website',
2030 'default' => 'general',
2031 'maxLength' => 200
2032 ],
2033 'business_type' => [
2034 'type' => 'string',
2035 'title' => 'Business Type',
2036 'description' => 'Type of website or business',
2037 'enum' => array_keys($this->business_types),
2038 'default' => 'website'
2039 ],
2040 'technical_stack' => [
2041 'type' => 'string',
2042 'title' => 'Technical Stack',
2043 'description' => 'Technologies and frameworks used',
2044 'default' => 'WordPress',
2045 'maxLength' => 300
2046 ],
2047 'development_approach' => [
2048 'type' => 'string',
2049 'title' => 'Development Approach',
2050 'description' => 'Development methodology and practices',
2051 'default' => '',
2052 'maxLength' => 400
2053 ],
2054 'setup_instructions' => [
2055 'type' => 'string',
2056 'title' => 'Setup Instructions',
2057 'description' => 'Instructions for setting up or working with the project',
2058 'default' => '',
2059 'maxLength' => 500
2060 ],
2061 'ai_context_custom' => [
2062 'type' => 'string',
2063 'title' => 'Additional AI Context',
2064 'description' => 'Custom context information for AI assistants',
2065 'default' => '',
2066 'maxLength' => 400
2067 ],
2068 'auto_generate' => [
2069 'type' => 'boolean',
2070 'title' => 'Auto-generate',
2071 'description' => 'Automatically regenerate llms.txt when settings change',
2072 'default' => false
2073 ],
2074 'delivery_mode' => [
2075 'type' => 'string',
2076 'title' => 'Delivery Method',
2077 '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.',
2078 'enum' => self::DELIVERY_MODES,
2079 'default' => 'auto'
2080 ],
2081 'last_generated' => [
2082 'type' => 'string',
2083 'title' => 'Last Generated',
2084 'description' => 'Timestamp of last generation',
2085 'format' => 'date-time',
2086 'readonly' => true
2087 ],
2088 'documentation_links' => [
2089 'type' => 'string',
2090 'title' => 'Documentation Links',
2091 'description' => 'Links to documentation, guides, and important pages',
2092 'default' => '',
2093 'maxLength' => 2000
2094 ],
2095 'technical_links' => [
2096 'type' => 'string',
2097 'title' => 'Technical Links',
2098 'description' => 'Links to technical resources, code repositories, and development info',
2099 'default' => '',
2100 'maxLength' => 2000
2101 ],
2102 'optional_links' => [
2103 'type' => 'string',
2104 'title' => 'Optional Links',
2105 'description' => 'Secondary resources that can be skipped for shorter context',
2106 'default' => '',
2107 'maxLength' => 2000
2108 ],
2109 'custom_sections' => [
2110 'type' => 'string',
2111 'title' => 'Custom Sections',
2112 'description' => 'Additional custom sections in markdown format',
2113 'default' => '',
2114 'maxLength' => 3000
2115 ]
2116 ];
2117 }
2118 private function build_summary_blockquote(array $user_input, array $settings): string {
2119 $description = sanitize_textarea_field($user_input['website_description'] ?? '');
2120
2121 if (empty($description)) {
2122 $site_name = sanitize_text_field($user_input['site_name'] ?? $settings['site_name'] ?? get_bloginfo('name'));
2123 $business_type = $user_input['business_type'] ?? 'website';
2124 $description = "{$site_name} is a {$this->business_types[$business_type]} providing valuable resources and information.";
2125 }
2126
2127 // Format as blockquote (required by spec)
2128 return "> " . $description . "\n\n";
2129 }
2130
2131 /**
2132 * Build additional details section
2133 *
2134 * @since 1.0.0
2135 *
2136 * @param array $user_input User input data
2137 * @param array $settings Current settings
2138 * @return string Additional details content
2139 */
2140 private function build_additional_details(array $user_input, array $settings): string {
2141 $content = '';
2142 $target_audience = sanitize_text_field($user_input['target_audience'] ?? '');
2143 $key_features = sanitize_textarea_field($user_input['key_features'] ?? '');
2144
2145 if (!empty($target_audience)) {
2146 $content .= "**Target Audience:** {$target_audience}\n\n";
2147 }
2148
2149 if (!empty($key_features)) {
2150 $content .= "**Key Features:**\n";
2151 // The UI field is a multi-line textarea and validation counts by
2152 // newline, so split on newlines (and still tolerate commas) rather
2153 // than commas only — otherwise newline-separated input collapses
2154 // into one broken bullet.
2155 $features = preg_split('/[\r\n,]+/', $key_features);
2156 foreach ($features as $feature) {
2157 $feature = trim($feature);
2158 if (!empty($feature)) {
2159 $content .= "- " . $feature . "\n";
2160 }
2161 }
2162 $content .= "\n";
2163 }
2164
2165 return $content;
2166 }
2167 private function get_default_documentation_links(): string {
2168 $website_url = home_url();
2169 $content = '';
2170
2171 // Add basic WordPress links
2172 $content .= "- [Website Home]({$website_url}): Main website homepage\n";
2173 $content .= "- [Sitemap]({$website_url}/sitemap.xml): Complete site structure\n";
2174
2175 return $content;
2176 }
2177
2178 /**
2179 * Get default technical links based on user input
2180 *
2181 * @since 1.0.0
2182 *
2183 * @param array $user_input User input data
2184 * @return string Default technical links
2185 */
2186 private function get_default_technical_links(array $user_input): string {
2187 $website_url = home_url();
2188 $content = '';
2189
2190 if (!empty($user_input['technical_stack'])) {
2191 $stack = sanitize_text_field($user_input['technical_stack']);
2192 $content .= "- [Technical Stack]({$website_url}): Built with {$stack}\n";
2193 }
2194
2195 if (!empty($user_input['development_approach'])) {
2196 $approach_summary = wp_trim_words($user_input['development_approach'], 10);
2197 $content .= "- [Development Guidelines]({$website_url}): {$approach_summary}\n";
2198 }
2199
2200 // Add robots.txt reference
2201 $content .= "- [Robots.txt]({$website_url}/robots.txt): Site crawling guidelines\n";
2202
2203 return $content;
2204 }
2205 }
2206