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

1,372 lines 48.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * LLMs.txt Manager Class
4 *
5 * Comprehensive LLMs.txt file management with AI-powered content generation,
6 * file writing, validation, and status monitoring. Implements structured
7 * information format for AI assistants and LLMs to better understand websites.
8 *
9 * @package ThinkRank
10 * @subpackage SEO
11 * @since 1.0.0
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\SEO;
17
18 // Ensure dependencies are loaded
19 if (!class_exists('ThinkRank\\SEO\\Abstract_SEO_Manager')) {
20 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-abstract-seo-manager.php';
21 }
22
23 if (!interface_exists('ThinkRank\\SEO\\Interfaces\\SEO_Manager_Interface')) {
24 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/interfaces/class-seo-manager-interface.php';
25 }
26
27 /**
28 * LLMs.txt Manager Class
29 *
30 * Manages LLMs.txt file generation, validation, and serving with AI-powered
31 * content creation based on website information and user input.
32 *
33 * @since 1.0.0
34 */
35 class LLMs_Txt_Manager extends Abstract_SEO_Manager {
36
37 /**
38 * WordPress filesystem instance
39 *
40 * @since 1.0.0
41 * @var \WP_Filesystem_Base|null
42 */
43 private $filesystem = null;
44
45 /**
46 * Whether the most recent save_settings() persisted a disable but failed to
47 * remove the published llms.txt file (so it may still be served). Callers
48 * check this via {@see unpublish_failed()} to surface a partial failure.
49 *
50 * @var bool
51 */
52 private bool $last_unpublish_failed = false;
53
54 /**
55 * LLMs.txt content sections configuration
56 *
57 * @since 1.0.0
58 * @var array
59 */
60 private array $content_sections = [
61 'project_overview' => [
62 'title' => 'Project Overview',
63 'required' => true,
64 'description' => 'High-level description of the website/project purpose',
65 'max_length' => 500
66 ],
67 'key_features' => [
68 'title' => 'Key Features',
69 'required' => true,
70 'description' => 'Main features and functionality of the website',
71 'max_length' => 300
72 ],
73 'architecture' => [
74 'title' => 'Architecture & Components',
75 'required' => false,
76 'description' => 'Technical architecture and key components',
77 'max_length' => 400
78 ],
79 'development_guidelines' => [
80 'title' => 'Development Guidelines',
81 'required' => false,
82 'description' => 'Coding standards and development practices',
83 'max_length' => 300
84 ],
85 'setup_instructions' => [
86 'title' => 'Setup Instructions',
87 'required' => false,
88 'description' => 'How to get the project running',
89 'max_length' => 400
90 ],
91 'ai_context' => [
92 'title' => 'Context for AI Assistants',
93 'required' => true,
94 'description' => 'Specific information to help AI understand the project',
95 'max_length' => 300
96 ]
97 ];
98
99 /**
100 * Maximum file size for LLMs.txt files (1MB)
101 *
102 * @since 1.0.0
103 * @var int
104 */
105 private const MAX_FILE_SIZE = 1048576; // 1MB in bytes
106
107 /**
108 * Business type templates for content generation
109 *
110 * @since 1.0.0
111 * @var array
112 */
113 private array $business_types = [
114 'website' => 'website',
115 'blog' => 'Personal or professional blog',
116 'business' => 'Business/corporate website',
117 'ecommerce' => 'E-commerce/online store',
118 'portfolio' => 'Portfolio/showcase website',
119 'nonprofit' => 'Non-profit organization',
120 'educational' => 'Educational institution',
121 'news' => 'News/media website',
122 'community' => 'Community/forum website',
123 'saas' => 'Software as a Service',
124 'agency' => 'Agency/service provider',
125 'other' => 'Other type of website'
126 ];
127
128 /**
129 * Constructor
130 *
131 * @since 1.0.0
132 */
133 public function __construct() {
134 parent::__construct('llms_txt');
135 }
136
137 /**
138 * Initialize WordPress filesystem
139 *
140 * @since 1.0.0
141 * @return bool True if filesystem is initialized, false otherwise
142 */
143 private function init_filesystem(): bool {
144 if ($this->filesystem !== null) {
145 return true;
146 }
147
148 global $wp_filesystem;
149
150 if (!function_exists('WP_Filesystem')) {
151 require_once ABSPATH . 'wp-admin/includes/file.php';
152 }
153
154 $credentials = request_filesystem_credentials('', '', false, false, null);
155 if (!WP_Filesystem($credentials)) {
156 return false;
157 }
158
159 $this->filesystem = $wp_filesystem;
160 return true;
161 }
162
163 /**
164 * Check if directory is writable using WP_Filesystem
165 *
166 * @since 1.0.0
167 * @param string $path Directory path to check
168 * @return bool True if writable, false otherwise
169 */
170 private function is_directory_writable(string $path): bool {
171 if (!$this->init_filesystem()) {
172 return false;
173 }
174
175 return $this->filesystem->is_writable($path);
176 }
177
178 /**
179 * Check if file is writable using WP_Filesystem
180 *
181 * @since 1.0.0
182 * @param string $file File path to check
183 * @return bool True if writable, false otherwise
184 */
185 private function is_file_writable(string $file): bool {
186 if (!$this->init_filesystem()) {
187 return false;
188 }
189
190 return $this->filesystem->is_writable($file);
191 }
192 public function generate_llms_txt(array $user_input, array $options = []): array {
193 $llms_data = [
194 'content' => '',
195 'sections' => [],
196 'metadata' => [],
197 'validation' => [],
198 'file_info' => []
199 ];
200
201 // Get current settings
202 $settings = $this->get_settings('site');
203
204 // Merge saved settings underneath the provided input so that empty or
205 // partial $user_input falls back to the persisted configuration.
206 // Explicitly provided (non-empty) values win; blank ones are filled from
207 // saved settings. This lets callers generate from saved settings by
208 // passing an empty payload (e.g. the generate-llms-txt MCP ability),
209 // matching the documented behavior.
210 $provided = array_filter(
211 $user_input,
212 static function ($value) {
213 if (is_string($value)) {
214 return '' !== trim($value);
215 }
216 return null !== $value && [] !== $value;
217 }
218 );
219 $user_input = array_merge($settings, $provided);
220
221 // Check file status
222 $llms_file = ABSPATH . 'llms.txt';
223 $llms_data['file_info'] = [
224 'file_exists' => file_exists($llms_file),
225 'writable' => $this->is_directory_writable(dirname($llms_file)),
226 'file_path' => $llms_file,
227 'last_modified' => file_exists($llms_file) ? filemtime($llms_file) : null
228 ];
229
230 // Validate user input
231 $validation = $this->validate_user_input($user_input);
232 $llms_data['validation'] = $validation;
233
234 if (!$validation['valid']) {
235 return $llms_data;
236 }
237
238 // Generate content sections
239 $llms_data['sections'] = $this->build_content_sections($user_input, $settings);
240
241 // Build final LLMs.txt content
242 $site_name = $user_input['site_name'] ?? $settings['site_name'] ?? get_bloginfo('name');
243 $llms_data['content'] = $this->build_llms_txt_content($llms_data['sections'], $site_name);
244
245 // Add metadata
246 $llms_data['metadata'] = [
247 'generated_at' => gmdate('c'),
248 'website_url' => home_url(),
249 'generator' => 'ThinkRank SEO Plugin',
250 'content_length' => strlen($llms_data['content']),
251 'sections_count' => count($llms_data['sections'])
252 ];
253
254 return $llms_data;
255 }
256
257 /**
258 * Persist settings, unpublishing the physical file when the feature is
259 * disabled so a disable actually stops serving /llms.txt.
260 *
261 * @param string $context_type Context type.
262 * @param int|null $context_id Context ID.
263 * @param array $settings Settings to save.
264 * @return bool
265 */
266 public function save_settings(string $context_type, ?int $context_id, array $settings): bool {
267 $this->last_unpublish_failed = false;
268 $result = parent::save_settings($context_type, $context_id, $settings);
269
270 // When a save explicitly disables the feature, delete the published file.
271 if ($result && array_key_exists('enabled', $settings) && empty($settings['enabled'])) {
272 if (!$this->delete_llms_txt_file()) {
273 // The settings were persisted, but the physical file could not be
274 // removed, so /llms.txt may still be served. Record it so callers
275 // report a partial failure instead of an unqualified success.
276 $this->last_unpublish_failed = true;
277 }
278 }
279
280 return $result;
281 }
282
283 /**
284 * Whether the last save_settings() disabled the feature but could not remove
285 * the published llms.txt file (which may therefore still be served).
286 *
287 * @return bool
288 */
289 public function unpublish_failed(): bool {
290 return $this->last_unpublish_failed;
291 }
292
293 /**
294 * Remove the published llms.txt file and bust the status transient.
295 *
296 * @return bool True if the file is absent or was removed.
297 */
298 public function delete_llms_txt_file(): bool {
299 delete_transient('thinkrank_llms_file_status');
300
301 $llms_file = ABSPATH . 'llms.txt';
302 if (!file_exists($llms_file)) {
303 return true;
304 }
305 if (!$this->init_filesystem()) {
306 return false;
307 }
308 return (bool) $this->filesystem->delete($llms_file);
309 }
310
311 /**
312 * Write LLMs.txt content to filesystem
313 *
314 * @since 1.0.0
315 *
316 * @param string $content LLMs.txt content to write
317 * @return array Write operation result
318 */
319 public function write_llms_txt_to_file(string $content): array {
320 $result = [
321 'success' => false,
322 'message' => '',
323 'file_path' => '',
324 'permissions' => []
325 ];
326
327 // Refuse to publish when the feature is disabled. The React UI hides the
328 // publish button, but the REST endpoint and the MCP publish ability call
329 // this directly, so enforce the toggle here at the single write choke point.
330 $settings = $this->get_settings('site');
331 if (empty($settings['enabled'])) {
332 $result['message'] = 'LLMs.txt is disabled. Enable it before publishing.';
333 return $result;
334 }
335
336 $llms_file = ABSPATH . 'llms.txt';
337
338 // Security: Validate file path to prevent path traversal attacks
339 $real_llms_file = realpath(dirname($llms_file)) . DIRECTORY_SEPARATOR . basename($llms_file);
340 $allowed_dir = realpath(ABSPATH);
341
342 if (!$allowed_dir || strpos(dirname($real_llms_file), $allowed_dir) !== 0) {
343 $result['message'] = 'Invalid file path detected for security reasons.';
344 return $result;
345 }
346
347 $result['file_path'] = $llms_file;
348
349 // Check directory permissions
350 $result['permissions'] = [
351 'directory_writable' => $this->is_directory_writable(ABSPATH),
352 'file_exists' => file_exists($llms_file),
353 'file_writable' => file_exists($llms_file) ? $this->is_file_writable($llms_file) : null
354 ];
355
356 // Check if we can write to the directory
357 if (!$result['permissions']['directory_writable']) {
358 $result['message'] = 'WordPress root directory is not writable. Please check file permissions.';
359 return $result;
360 }
361
362 // Check if existing file is writable (if it exists)
363 if ($result['permissions']['file_exists'] && !$result['permissions']['file_writable']) {
364 $result['message'] = 'Existing llms.txt file is not writable. Please check file permissions.';
365 return $result;
366 }
367
368 // Write new content using WP_Filesystem
369 if (!$this->init_filesystem()) {
370 $result['message'] = 'Could not initialize WordPress filesystem.';
371 return $result;
372 }
373
374 $write_success = $this->filesystem->put_contents($llms_file, $content, FS_CHMOD_FILE);
375
376 if ($write_success) {
377 $result['success'] = true;
378 $result['message'] = 'LLMs.txt file written successfully.';
379 $result['bytes_written'] = strlen($content);
380
381 // Invalidate file status cache since file has changed
382 delete_transient('thinkrank_llms_file_status');
383 } else {
384 $result['message'] = 'Failed to write llms.txt file.';
385 }
386
387 return $result;
388 }
389
390 /**
391 * Get LLMs.txt file status and information
392 *
393 * @since 1.0.0
394 *
395 * @return array File status information
396 */
397 public function get_llms_txt_status(bool $force_refresh = false): array {
398 // Check cache first (5 minute cache for performance)
399 $cache_key = 'thinkrank_llms_file_status';
400
401 if (!$force_refresh) {
402 $cached_status = get_transient($cache_key);
403 if ($cached_status !== false) {
404 return $cached_status;
405 }
406 }
407
408 $llms_file = ABSPATH . 'llms.txt';
409
410 $status = [
411 'file_exists' => file_exists($llms_file),
412 'file_path' => $llms_file,
413 'file_url' => home_url('/llms.txt'),
414 'writable' => $this->is_directory_writable(dirname($llms_file)),
415 'last_modified' => null,
416 'file_size' => null,
417 'content_preview' => ''
418 ];
419
420 if ($status['file_exists']) {
421 $status['last_modified'] = filemtime($llms_file);
422 $status['file_size'] = filesize($llms_file);
423
424 // Get content preview (first 200 characters) with size safety
425 $read_result = $this->safe_file_read($llms_file);
426 if ($read_result['success']) {
427 $status['content_preview'] = substr($read_result['content'], 0, 200);
428 if (strlen($read_result['content']) > 200) {
429 $status['content_preview'] .= '...';
430 }
431 } else {
432 $status['content_preview'] = 'Error: ' . $read_result['error'];
433 $status['read_error'] = $read_result['error'];
434 }
435 }
436
437 // Cache the result for 5 minutes to improve performance
438 set_transient($cache_key, $status, 5 * MINUTE_IN_SECONDS);
439
440 return $status;
441 }
442
443 /**
444 * Validate LLMs.txt content
445 *
446 * @since 1.0.0
447 *
448 * @param string $content LLMs.txt content to validate
449 * @return array Validation results
450 */
451 public function validate_llms_txt_content(string $content): array {
452 $validation = [
453 'valid' => true,
454 'errors' => [],
455 'warnings' => [],
456 'suggestions' => [],
457 'score' => 100
458 ];
459
460 // Check if content is empty
461 if (empty(trim($content))) {
462 $validation['errors'][] = 'LLMs.txt content cannot be empty';
463 $validation['valid'] = false;
464 $validation['score'] = 0;
465 return $validation;
466 }
467
468 // Check content length
469 $content_length = strlen($content);
470 if ($content_length < 100) {
471 $validation['warnings'][] = 'LLMs.txt content is very short, consider adding more details';
472 $validation['score'] -= 20;
473 } elseif ($content_length > 10000) {
474 $validation['warnings'][] = 'LLMs.txt content is very long, consider condensing key information';
475 $validation['score'] -= 10;
476 }
477
478 // Check for required sections
479 $required_sections = ['Project Overview', 'Key Features', 'Context for AI Assistants'];
480 foreach ($required_sections as $section) {
481 if (stripos($content, $section) === false) {
482 $validation['warnings'][] = "Missing recommended section: {$section}";
483 $validation['score'] -= 15;
484 }
485 }
486
487 // Check for proper structure
488 if (!preg_match('/^#\s+/', $content)) {
489 $validation['suggestions'][] = 'Consider starting with a main heading (# Project Name)';
490 $validation['score'] -= 5;
491 }
492
493 // Ensure score doesn't go below 0
494 $validation['score'] = max(0, $validation['score']);
495
496 return $validation;
497 }
498
499 /**
500 * Validate user input for LLMs.txt generation
501 *
502 * @since 1.0.0
503 *
504 * @param array $user_input User-provided data
505 * @return array Validation results
506 */
507 private function validate_user_input(array $user_input): array {
508 $validation = [
509 'valid' => true,
510 'errors' => [],
511 'warnings' => [],
512 'suggestions' => [],
513 'score' => 100
514 ];
515
516 // Check required fields
517 $required_fields = [
518 'website_description' => 'Website Description',
519 'key_features' => 'Key Features',
520 'target_audience' => 'Target Audience'
521 ];
522
523 foreach ($required_fields as $field => $label) {
524 if (empty($user_input[$field])) {
525 $validation['errors'][] = "{$label} is required for quality LLMs.txt generation";
526 $validation['valid'] = false;
527 $validation['score'] -= 25;
528 } else {
529 $validation['suggestions'][] = "{$label} is properly configured";
530 }
531 }
532
533 // Validate link formats in structured sections
534 $this->validate_link_sections($user_input, $validation);
535
536 // Check content quality
537 $this->validate_content_quality($user_input, $validation);
538
539 // Check optional enhancements
540 $this->validate_optional_enhancements($user_input, $validation);
541
542 // Validate website description
543 if (!empty($user_input['website_description'])) {
544 $desc_length = strlen($user_input['website_description']);
545 if ($desc_length < 50) {
546 $validation['warnings'][] = 'Website description is quite short, consider adding more details';
547 $validation['score'] -= 10;
548 } elseif ($desc_length > 1000) {
549 $validation['warnings'][] = 'Website description is very long, consider condensing key points';
550 $validation['score'] -= 5;
551 }
552 }
553
554 // Validate business type
555 if (!empty($user_input['business_type']) && !isset($this->business_types[$user_input['business_type']])) {
556 $validation['warnings'][] = 'Unknown business type specified';
557 $validation['score'] -= 5;
558 }
559
560 // Ensure score doesn't go below 0
561 $validation['score'] = max(0, $validation['score']);
562
563 return $validation;
564 }
565
566 /**
567 * Validate LLMs.txt input (public method for API)
568 *
569 * @since 1.0.0
570 *
571 * @param array $user_input User-provided data
572 * @return array Validation results
573 */
574 public function validate_llms_txt_input(array $user_input): array {
575 return $this->validate_user_input($user_input);
576 }
577
578 /**
579 * Override parent sanitize_settings to preserve line breaks in link fields
580 *
581 * @since 1.0.0
582 *
583 * @param array $settings Settings to sanitize
584 * @return array Sanitized settings
585 */
586 protected function sanitize_settings(array $settings): array {
587 $sanitized = [];
588
589 // Fields that should preserve line breaks
590 $preserve_linebreaks = [
591 'documentation_links',
592 'technical_links',
593 'optional_links',
594 'custom_sections',
595 'key_features',
596 'website_description',
597 'technical_stack',
598 'development_approach',
599 'setup_instructions',
600 'ai_context_custom'
601 ];
602
603 foreach ($settings as $key => $value) {
604 $sanitized_key = sanitize_key($key);
605
606 if (is_string($value)) {
607 if (in_array($key, $preserve_linebreaks, true)) {
608 // Use our custom sanitization that preserves line breaks
609 if (in_array($key, ['documentation_links', 'technical_links', 'optional_links', 'custom_sections'], true)) {
610 $sanitized[$sanitized_key] = $this->sanitize_llms_content($value);
611 } else {
612 // For textarea fields, use sanitize_textarea_field which preserves line breaks
613 $sanitized[$sanitized_key] = sanitize_textarea_field($value);
614 }
615 } else {
616 // For regular text fields, use sanitize_text_field
617 $sanitized[$sanitized_key] = sanitize_text_field($value);
618 }
619 } elseif (is_array($value)) {
620 $sanitized[$sanitized_key] = $this->sanitize_array_recursive($value);
621 } elseif (is_numeric($value)) {
622 $sanitized[$sanitized_key] = (float) $value;
623 } elseif (is_bool($value)) {
624 $sanitized[$sanitized_key] = (bool) $value;
625 } else {
626 $sanitized[$sanitized_key] = sanitize_text_field((string) $value);
627 }
628 }
629
630 return $sanitized;
631 }
632
633 /**
634 * Recursively sanitize array values (preserving line breaks where needed)
635 *
636 * @since 1.0.0
637 *
638 * @param array $array Array to sanitize
639 * @return array Sanitized array
640 */
641 private function sanitize_array_recursive(array $array): array {
642 $sanitized = [];
643
644 foreach ($array as $key => $value) {
645 $sanitized_key = sanitize_key($key);
646
647 if (is_string($value)) {
648 $sanitized[$sanitized_key] = sanitize_textarea_field($value);
649 } elseif (is_array($value)) {
650 $sanitized[$sanitized_key] = $this->sanitize_array_recursive($value);
651 } elseif (is_numeric($value)) {
652 $sanitized[$sanitized_key] = (float) $value;
653 } elseif (is_bool($value)) {
654 $sanitized[$sanitized_key] = (bool) $value;
655 } else {
656 $sanitized[$sanitized_key] = sanitize_text_field((string) $value);
657 }
658 }
659
660 return $sanitized;
661 }
662
663 /**
664 * Validate link formats in structured sections
665 *
666 * @since 1.0.0
667 *
668 * @param array $user_input User input data
669 * @param array &$validation Validation results (passed by reference)
670 */
671 private function validate_link_sections(array $user_input, array &$validation): void {
672 $link_sections = [
673 'documentation_links' => 'Documentation Links',
674 'technical_links' => 'Technical Links',
675 'optional_links' => 'Optional Links'
676 ];
677
678 foreach ($link_sections as $field => $label) {
679 if (!empty($user_input[$field])) {
680 $links = explode("\n", $user_input[$field]);
681 $valid_links = 0;
682 $total_links = 0;
683
684 foreach ($links as $line) {
685 $line = trim($line);
686 if (empty($line) || !str_starts_with($line, '-')) {
687 continue;
688 }
689
690 $total_links++;
691
692 // Check for proper markdown link format: - [Title](URL): Description
693 if (preg_match('/^-\s*\[([^\]]+)\]\(([^)]+)\):\s*(.+)$/', $line, $matches)) {
694 $title = trim($matches[1]);
695 $url = trim($matches[2]);
696 $description = trim($matches[3]);
697
698 if (!empty($title) && !empty($url) && !empty($description)) {
699 if (filter_var($url, FILTER_VALIDATE_URL)) {
700 $valid_links++;
701 } else {
702 $validation['warnings'][] = "Invalid URL in {$label}: {$url}";
703 $validation['score'] -= 5;
704 }
705 } else {
706 $validation['warnings'][] = "Incomplete link format in {$label}: missing title, URL, or description";
707 $validation['score'] -= 5;
708 }
709 } else {
710 $validation['warnings'][] = "Invalid link format in {$label}. Use: - [Title](URL): Description";
711 $validation['score'] -= 5;
712 }
713 }
714
715 if ($total_links > 0) {
716 if ($valid_links === $total_links) {
717 $validation['suggestions'][] = "✓ All {$label} are properly formatted";
718 } else {
719 $validation['warnings'][] = "{$label}: {$valid_links}/{$total_links} links are properly formatted";
720 }
721 }
722 }
723 }
724 }
725
726 /**
727 * Validate content quality
728 *
729 * @since 1.0.0
730 *
731 * @param array $user_input User input data
732 * @param array &$validation Validation results (passed by reference)
733 */
734 private function validate_content_quality(array $user_input, array &$validation): void {
735 // Check website description quality
736 if (!empty($user_input['website_description'])) {
737 $desc_length = strlen($user_input['website_description']);
738 if ($desc_length < 50) {
739 $validation['warnings'][] = 'Website description is quite short. Consider adding more detail for better AI understanding';
740 $validation['score'] -= 10;
741 } elseif ($desc_length > 500) {
742 $validation['warnings'][] = 'Website description is very long. Consider making it more concise';
743 $validation['score'] -= 5;
744 } else {
745 $validation['suggestions'][] = '✓ Website description length is optimal';
746 }
747 }
748
749 // Check key features quality
750 if (!empty($user_input['key_features'])) {
751 $features = explode("\n", $user_input['key_features']);
752 $feature_count = count(array_filter($features, 'trim'));
753
754 if ($feature_count < 3) {
755 $validation['warnings'][] = 'Consider adding more key features (3-8 recommended) for comprehensive AI understanding';
756 $validation['score'] -= 10;
757 } elseif ($feature_count > 10) {
758 $validation['warnings'][] = 'Many key features listed. Consider focusing on the most important ones';
759 $validation['score'] -= 5;
760 } else {
761 $validation['suggestions'][] = "✓ Good number of key features ({$feature_count})";
762 }
763 }
764 }
765
766 /**
767 * Validate optional enhancements
768 *
769 * @since 1.0.0
770 *
771 * @param array $user_input User input data
772 * @param array &$validation Validation results (passed by reference)
773 */
774 private function validate_optional_enhancements(array $user_input, array &$validation): void {
775 $enhancement_score = 0;
776
777 // Check for technical stack
778 if (!empty($user_input['technical_stack'])) {
779 $validation['suggestions'][] = '✓ Technical stack information provided';
780 $enhancement_score += 5;
781 } else {
782 $validation['suggestions'][] = 'Consider adding technical stack information for developer context';
783 }
784
785 // Check for development approach
786 if (!empty($user_input['development_approach'])) {
787 $validation['suggestions'][] = '✓ Development approach documented';
788 $enhancement_score += 5;
789 } else {
790 $validation['suggestions'][] = 'Consider documenting development approach for better AI assistance';
791 }
792
793 // Check for setup instructions
794 if (!empty($user_input['setup_instructions'])) {
795 $validation['suggestions'][] = '✓ Setup instructions provided';
796 $enhancement_score += 5;
797 } else {
798 $validation['suggestions'][] = 'Consider adding setup instructions for new developers';
799 }
800
801 // Check for custom sections
802 if (!empty($user_input['custom_sections'])) {
803 $validation['suggestions'][] = '✓ Custom sections enhance documentation';
804 $enhancement_score += 5;
805 }
806
807 // Bonus points for comprehensive documentation
808 if ($enhancement_score >= 15) {
809 $validation['suggestions'][] = '✓ Comprehensive LLMs.txt documentation - excellent for AI assistance!';
810 }
811 }
812
813 /**
814 * Build content sections from user input
815 *
816 * @since 1.0.0
817 *
818 * @param array $user_input User-provided data
819 * @param array $settings Current settings
820 * @return array Built content sections
821 */
822 private function build_content_sections(array $user_input, array $settings): array {
823 $sections = [];
824
825 // Blockquote summary (required by spec)
826 $sections['summary'] = [
827 'title' => '', // No title for blockquote
828 'content' => $this->build_summary_blockquote($user_input, $settings)
829 ];
830
831 // Additional details (optional descriptive content)
832 if (!empty($user_input['website_description'])) {
833 $sections['details'] = [
834 'title' => '', // No title for details
835 'content' => $this->build_additional_details($user_input, $settings)
836 ];
837 }
838
839 // Development Approach section (if provided)
840 if (!empty($user_input['development_approach'])) {
841 $sections['development_approach'] = [
842 'title' => 'Development Approach',
843 'content' => sanitize_textarea_field($user_input['development_approach'])
844 ];
845 }
846
847 // Setup Instructions section (if provided)
848 if (!empty($user_input['setup_instructions'])) {
849 $sections['setup_instructions'] = [
850 'title' => 'Setup Instructions',
851 'content' => sanitize_textarea_field($user_input['setup_instructions'])
852 ];
853 }
854
855 // User-controlled structured sections (always include with defaults if empty)
856 $documentation_content = !empty($user_input['documentation_links'])
857 ? $this->sanitize_llms_content($user_input['documentation_links'])
858 : $this->get_default_documentation_links();
859
860 $sections['documentation'] = [
861 'title' => 'Documentation',
862 'content' => $documentation_content
863 ];
864
865 // Technical section (only if user provided content or technical details exist)
866 if (!empty($user_input['technical_links']) || !empty($user_input['technical_stack']) || !empty($user_input['development_approach'])) {
867 $technical_content = !empty($user_input['technical_links'])
868 ? $this->sanitize_llms_content($user_input['technical_links'])
869 : $this->get_default_technical_links($user_input);
870
871 $sections['technical'] = [
872 'title' => 'Technical Details',
873 'content' => $technical_content
874 ];
875 }
876
877 // Optional section (only if user provided content)
878 if (!empty($user_input['optional_links'])) {
879 $sections['optional'] = [
880 'title' => 'Optional',
881 'content' => $this->sanitize_llms_content($user_input['optional_links'])
882 ];
883 }
884
885 // Custom sections (user-defined markdown)
886 if (!empty($user_input['custom_sections'])) {
887 $sections['custom'] = [
888 'title' => '', // No title since user provides their own H2 headers
889 'content' => $this->sanitize_llms_content($user_input['custom_sections'])
890 ];
891 }
892
893 return $sections;
894 }
895
896 /**
897 * Sanitize LLMs.txt content while preserving line breaks
898 *
899 * @since 1.0.0
900 *
901 * @param string $content Raw content to sanitize
902 * @return string Sanitized content with preserved line breaks
903 */
904 public function sanitize_llms_content(string $content): string {
905 // Remove any potential script tags and dangerous content
906 $content = wp_kses($content, [
907 'a' => ['href' => [], 'title' => []],
908 'strong' => [],
909 'em' => [],
910 'code' => [],
911 'pre' => []
912 ]);
913
914 // wp_kses only guards HTML href attributes, not markdown link syntax
915 // [text](url). Neutralize dangerous schemes (javascript:/data:/vbscript:)
916 // in markdown link targets so they don't survive into the published file
917 // for downstream consumers that render it as markdown/HTML.
918 $content = preg_replace_callback('/\]\(([^)]*)\)/', static function ($m) {
919 if (preg_match('#^\s*(?:javascript|data|vbscript):#i', $m[1])) {
920 return '](#)';
921 }
922 return $m[0];
923 }, $content);
924
925 // Normalize line endings and preserve line breaks
926 $content = str_replace(["\r\n", "\r"], "\n", $content);
927
928 // Remove excessive whitespace but preserve intentional line breaks
929 $content = preg_replace('/[ \t]+/', ' ', $content); // Multiple spaces/tabs to single space
930 $content = preg_replace('/\n\s*\n\s*\n+/', "\n\n", $content); // Multiple empty lines to double
931
932 return trim($content);
933 }
934
935 /**
936 * Safely read file content with size limits
937 *
938 * @since 1.0.0
939 *
940 * @param string $file_path Path to file to read
941 * @param int|null $max_size Maximum file size to read (null for class default)
942 * @return array Result with success status, content, and any errors
943 */
944 private function safe_file_read(string $file_path, ?int $max_size = null): array {
945 $result = [
946 'success' => false,
947 'content' => '',
948 'error' => '',
949 'file_size' => 0
950 ];
951
952 if (!file_exists($file_path)) {
953 $result['error'] = 'File does not exist';
954 return $result;
955 }
956
957 $file_size = filesize($file_path);
958 $result['file_size'] = $file_size;
959
960 $max_allowed = $max_size ?? self::MAX_FILE_SIZE;
961
962 if ($file_size > $max_allowed) {
963 $result['error'] = sprintf(
964 'File size (%s) exceeds maximum allowed size (%s)',
965 size_format($file_size),
966 size_format($max_allowed)
967 );
968 return $result;
969 }
970
971 if (!$this->init_filesystem()) {
972 $result['error'] = 'Could not initialize WordPress filesystem';
973 return $result;
974 }
975
976 $content = $this->filesystem->get_contents($file_path);
977 if (false === $content) {
978 $result['error'] = 'Failed to read file content';
979 return $result;
980 }
981
982 $result['success'] = true;
983 $result['content'] = $content;
984 return $result;
985 }
986 private function build_llms_txt_content(array $sections, string $site_name = ''): string {
987 // Sanitize inside the manager rather than trusting callers — the MCP
988 // abilities pass site_name through unsanitized.
989 $site_name = sanitize_text_field($site_name ?: get_bloginfo('name'));
990 $content = "# {$site_name}\n\n";
991
992 foreach ($sections as $section_key => $section_data) {
993 // Only add H2 header if title is not empty
994 if (!empty($section_data['title'])) {
995 $content .= "## {$section_data['title']}\n\n";
996 }
997 $content .= $section_data['content'] . "\n\n";
998 }
999
1000 // Add generation timestamp
1001 $content .= "---\n";
1002 $content .= "Generated by ThinkRank SEO Plugin on " . gmdate('Y-m-d H:i:s') . " UTC\n";
1003
1004 return $content;
1005 }
1006
1007 /**
1008 * Validate SEO settings (implements interface)
1009 *
1010 * @since 1.0.0
1011 *
1012 * @param array $settings Settings array to validate
1013 * @return array Validation results
1014 */
1015 public function validate_settings(array $settings): array {
1016 $validation = [
1017 'valid' => true,
1018 'errors' => [],
1019 'warnings' => [],
1020 'suggestions' => [],
1021 'score' => 100
1022 ];
1023
1024 // Validate enabled setting
1025 if (!isset($settings['enabled'])) {
1026 $validation['errors'][] = 'Enabled setting is required';
1027 $validation['valid'] = false;
1028 $validation['score'] -= 25;
1029 }
1030
1031 // Validate website description
1032 if (isset($settings['website_description'])) {
1033 if (empty($settings['website_description'])) {
1034 $validation['warnings'][] = 'Website description is empty, consider adding a description';
1035 $validation['score'] -= 15;
1036 } elseif (strlen($settings['website_description']) < 50) {
1037 $validation['suggestions'][] = 'Website description is quite short, consider adding more details';
1038 $validation['score'] -= 5;
1039 }
1040 }
1041
1042 // Validate key features
1043 if (isset($settings['key_features'])) {
1044 if (empty($settings['key_features'])) {
1045 $validation['warnings'][] = 'Key features are empty, consider listing main website features';
1046 $validation['score'] -= 15;
1047 }
1048 }
1049
1050 // Validate target audience
1051 if (isset($settings['target_audience'])) {
1052 if (empty($settings['target_audience'])) {
1053 $validation['suggestions'][] = 'Target audience is not specified, consider defining your audience';
1054 $validation['score'] -= 5;
1055 }
1056 }
1057
1058 // Check file permissions if enabled
1059 if (!empty($settings['enabled'])) {
1060 if (!$this->is_directory_writable(ABSPATH)) {
1061 $validation['warnings'][] = 'WordPress root directory is not writable, llms.txt cannot be automatically managed';
1062 $validation['score'] -= 10;
1063 }
1064 }
1065
1066 // Ensure score doesn't go below 0
1067 $validation['score'] = max(0, $validation['score']);
1068
1069 return $validation;
1070 }
1071
1072 /**
1073 * Get output data for frontend rendering (implements interface)
1074 *
1075 * @since 1.0.0
1076 *
1077 * @param string $context_type The context type
1078 * @param int|null $context_id Optional. Context ID
1079 * @return array Output data ready for frontend rendering
1080 */
1081 public function get_output_data(string $context_type, ?int $context_id): array {
1082 $settings = $this->get_settings($context_type, $context_id);
1083
1084 $output = [
1085 'llms_txt_content' => '',
1086 'file_status' => [],
1087 'metadata' => [],
1088 'enabled' => $settings['enabled'] ?? true
1089 ];
1090
1091 if (!$output['enabled']) {
1092 return $output;
1093 }
1094
1095 // Get file status
1096 $output['file_status'] = $this->get_llms_txt_status();
1097
1098 // If file exists, get current content safely
1099 if ($output['file_status']['file_exists']) {
1100 $llms_file = ABSPATH . 'llms.txt';
1101 $read_result = $this->safe_file_read($llms_file);
1102 if ($read_result['success']) {
1103 $output['llms_txt_content'] = $read_result['content'];
1104 } else {
1105 $output['llms_txt_content'] = '';
1106 $output['file_read_error'] = $read_result['error'];
1107 }
1108 }
1109
1110 // Add metadata
1111 $output['metadata'] = [
1112 'last_generated' => $settings['last_generated'] ?? null,
1113 'generator_version' => THINKRANK_VERSION ?? '1.0.0',
1114 'website_url' => home_url()
1115 ];
1116
1117 return $output;
1118 }
1119
1120 /**
1121 * Get default settings for a context type (implements interface)
1122 *
1123 * @since 1.0.0
1124 *
1125 * @param string $context_type The context type to get defaults for
1126 * @return array Default settings array
1127 */
1128 public function get_default_settings(string $context_type): array {
1129 $defaults = [
1130 'enabled' => true,
1131 'site_name' => get_bloginfo('name'),
1132 'website_description' => get_bloginfo('description'),
1133 'key_features' => '',
1134 'target_audience' => 'general',
1135 'business_type' => 'website',
1136 'technical_stack' => 'WordPress',
1137 'development_approach' => '',
1138 'setup_instructions' => '',
1139 'ai_context_custom' => '',
1140 'auto_generate' => false,
1141 'last_generated' => null,
1142 // Structured sections for llms.txt spec compliance
1143 'documentation_links' => '',
1144 'technical_links' => '',
1145 'optional_links' => '',
1146 'custom_sections' => ''
1147 ];
1148
1149 // Context-specific defaults
1150 switch ($context_type) {
1151 case 'site':
1152 // Site-wide defaults are already set above
1153 break;
1154 default:
1155 // Use site defaults for other contexts
1156 break;
1157 }
1158
1159 return $defaults;
1160 }
1161
1162 /**
1163 * Get settings schema definition (implements interface)
1164 *
1165 * @since 1.0.0
1166 *
1167 * @param string $context_type The context type to get schema for
1168 * @return array Settings schema definition
1169 */
1170 public function get_settings_schema(string $context_type): array {
1171 return [
1172 'enabled' => [
1173 'type' => 'boolean',
1174 'title' => 'Enable LLMs.txt',
1175 'description' => 'Enable LLMs.txt file generation and management',
1176 'default' => true
1177 ],
1178 'site_name' => [
1179 'type' => 'string',
1180 'title' => 'Website Title',
1181 'description' => 'The name of your website as it will appear in the LLMs.txt file',
1182 'default' => get_bloginfo('name'),
1183 'maxLength' => 60
1184 ],
1185 'website_description' => [
1186 'type' => 'string',
1187 'title' => 'Website Description',
1188 'description' => 'Comprehensive description of your website and its purpose',
1189 'default' => get_bloginfo('description'),
1190 'maxLength' => 1000
1191 ],
1192 'key_features' => [
1193 'type' => 'string',
1194 'title' => 'Key Features',
1195 'description' => 'Main features and functionality of your website',
1196 'default' => '',
1197 'maxLength' => 500
1198 ],
1199 'target_audience' => [
1200 'type' => 'string',
1201 'title' => 'Target Audience',
1202 'description' => 'Primary audience for your website',
1203 'default' => 'general',
1204 'maxLength' => 200
1205 ],
1206 'business_type' => [
1207 'type' => 'string',
1208 'title' => 'Business Type',
1209 'description' => 'Type of website or business',
1210 'enum' => array_keys($this->business_types),
1211 'default' => 'website'
1212 ],
1213 'technical_stack' => [
1214 'type' => 'string',
1215 'title' => 'Technical Stack',
1216 'description' => 'Technologies and frameworks used',
1217 'default' => 'WordPress',
1218 'maxLength' => 300
1219 ],
1220 'development_approach' => [
1221 'type' => 'string',
1222 'title' => 'Development Approach',
1223 'description' => 'Development methodology and practices',
1224 'default' => '',
1225 'maxLength' => 400
1226 ],
1227 'setup_instructions' => [
1228 'type' => 'string',
1229 'title' => 'Setup Instructions',
1230 'description' => 'Instructions for setting up or working with the project',
1231 'default' => '',
1232 'maxLength' => 500
1233 ],
1234 'ai_context_custom' => [
1235 'type' => 'string',
1236 'title' => 'Additional AI Context',
1237 'description' => 'Custom context information for AI assistants',
1238 'default' => '',
1239 'maxLength' => 400
1240 ],
1241 'auto_generate' => [
1242 'type' => 'boolean',
1243 'title' => 'Auto-generate',
1244 'description' => 'Automatically regenerate llms.txt when settings change',
1245 'default' => false
1246 ],
1247 'last_generated' => [
1248 'type' => 'string',
1249 'title' => 'Last Generated',
1250 'description' => 'Timestamp of last generation',
1251 'format' => 'date-time',
1252 'readonly' => true
1253 ],
1254 'documentation_links' => [
1255 'type' => 'string',
1256 'title' => 'Documentation Links',
1257 'description' => 'Links to documentation, guides, and important pages',
1258 'default' => '',
1259 'maxLength' => 2000
1260 ],
1261 'technical_links' => [
1262 'type' => 'string',
1263 'title' => 'Technical Links',
1264 'description' => 'Links to technical resources, code repositories, and development info',
1265 'default' => '',
1266 'maxLength' => 2000
1267 ],
1268 'optional_links' => [
1269 'type' => 'string',
1270 'title' => 'Optional Links',
1271 'description' => 'Secondary resources that can be skipped for shorter context',
1272 'default' => '',
1273 'maxLength' => 2000
1274 ],
1275 'custom_sections' => [
1276 'type' => 'string',
1277 'title' => 'Custom Sections',
1278 'description' => 'Additional custom sections in markdown format',
1279 'default' => '',
1280 'maxLength' => 3000
1281 ]
1282 ];
1283 }
1284 private function build_summary_blockquote(array $user_input, array $settings): string {
1285 $description = sanitize_textarea_field($user_input['website_description'] ?? '');
1286
1287 if (empty($description)) {
1288 $site_name = sanitize_text_field($user_input['site_name'] ?? $settings['site_name'] ?? get_bloginfo('name'));
1289 $business_type = $user_input['business_type'] ?? 'website';
1290 $description = "{$site_name} is a {$this->business_types[$business_type]} providing valuable resources and information.";
1291 }
1292
1293 // Format as blockquote (required by spec)
1294 return "> " . $description . "\n\n";
1295 }
1296
1297 /**
1298 * Build additional details section
1299 *
1300 * @since 1.0.0
1301 *
1302 * @param array $user_input User input data
1303 * @param array $settings Current settings
1304 * @return string Additional details content
1305 */
1306 private function build_additional_details(array $user_input, array $settings): string {
1307 $content = '';
1308 $target_audience = sanitize_text_field($user_input['target_audience'] ?? '');
1309 $key_features = sanitize_textarea_field($user_input['key_features'] ?? '');
1310
1311 if (!empty($target_audience)) {
1312 $content .= "**Target Audience:** {$target_audience}\n\n";
1313 }
1314
1315 if (!empty($key_features)) {
1316 $content .= "**Key Features:**\n";
1317 // The UI field is a multi-line textarea and validation counts by
1318 // newline, so split on newlines (and still tolerate commas) rather
1319 // than commas only — otherwise newline-separated input collapses
1320 // into one broken bullet.
1321 $features = preg_split('/[\r\n,]+/', $key_features);
1322 foreach ($features as $feature) {
1323 $feature = trim($feature);
1324 if (!empty($feature)) {
1325 $content .= "- " . $feature . "\n";
1326 }
1327 }
1328 $content .= "\n";
1329 }
1330
1331 return $content;
1332 }
1333 private function get_default_documentation_links(): string {
1334 $website_url = home_url();
1335 $content = '';
1336
1337 // Add basic WordPress links
1338 $content .= "- [Website Home]({$website_url}): Main website homepage\n";
1339 $content .= "- [Sitemap]({$website_url}/sitemap.xml): Complete site structure\n";
1340
1341 return $content;
1342 }
1343
1344 /**
1345 * Get default technical links based on user input
1346 *
1347 * @since 1.0.0
1348 *
1349 * @param array $user_input User input data
1350 * @return string Default technical links
1351 */
1352 private function get_default_technical_links(array $user_input): string {
1353 $website_url = home_url();
1354 $content = '';
1355
1356 if (!empty($user_input['technical_stack'])) {
1357 $stack = sanitize_text_field($user_input['technical_stack']);
1358 $content .= "- [Technical Stack]({$website_url}): Built with {$stack}\n";
1359 }
1360
1361 if (!empty($user_input['development_approach'])) {
1362 $approach_summary = wp_trim_words($user_input['development_approach'], 10);
1363 $content .= "- [Development Guidelines]({$website_url}): {$approach_summary}\n";
1364 }
1365
1366 // Add robots.txt reference
1367 $content .= "- [Robots.txt]({$website_url}/robots.txt): Site crawling guidelines\n";
1368
1369 return $content;
1370 }
1371 }
1372