PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.12.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.12.0
2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk All 53 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.12.0, at includes/seo/class-llms-txt-manager.php

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