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

1,552 lines 53.8 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 * LLMs.txt content sections configuration
47 *
48 * @since 1.0.0
49 * @var array
50 */
51 private array $content_sections = [
52 'project_overview' => [
53 'title' => 'Project Overview',
54 'required' => true,
55 'description' => 'High-level description of the website/project purpose',
56 'max_length' => 500
57 ],
58 'key_features' => [
59 'title' => 'Key Features',
60 'required' => true,
61 'description' => 'Main features and functionality of the website',
62 'max_length' => 300
63 ],
64 'architecture' => [
65 'title' => 'Architecture & Components',
66 'required' => false,
67 'description' => 'Technical architecture and key components',
68 'max_length' => 400
69 ],
70 'development_guidelines' => [
71 'title' => 'Development Guidelines',
72 'required' => false,
73 'description' => 'Coding standards and development practices',
74 'max_length' => 300
75 ],
76 'setup_instructions' => [
77 'title' => 'Setup Instructions',
78 'required' => false,
79 'description' => 'How to get the project running',
80 'max_length' => 400
81 ],
82 'ai_context' => [
83 'title' => 'Context for AI Assistants',
84 'required' => true,
85 'description' => 'Specific information to help AI understand the project',
86 'max_length' => 300
87 ]
88 ];
89
90 /**
91 * Maximum file size for LLMs.txt files (1MB)
92 *
93 * @since 1.0.0
94 * @var int
95 */
96 private const MAX_FILE_SIZE = 1048576; // 1MB in bytes
97
98 /**
99 * Business type templates for content generation
100 *
101 * @since 1.0.0
102 * @var array
103 */
104 private array $business_types = [
105 'website' => 'website',
106 'blog' => 'Personal or professional blog',
107 'business' => 'Business/corporate website',
108 'ecommerce' => 'E-commerce/online store',
109 'portfolio' => 'Portfolio/showcase website',
110 'nonprofit' => 'Non-profit organization',
111 'educational' => 'Educational institution',
112 'news' => 'News/media website',
113 'community' => 'Community/forum website',
114 'saas' => 'Software as a Service',
115 'agency' => 'Agency/service provider',
116 'other' => 'Other type of website'
117 ];
118
119 /**
120 * Constructor
121 *
122 * @since 1.0.0
123 */
124 public function __construct() {
125 parent::__construct('llms_txt');
126 }
127
128 /**
129 * Initialize WordPress filesystem
130 *
131 * @since 1.0.0
132 * @return bool True if filesystem is initialized, false otherwise
133 */
134 private function init_filesystem(): bool {
135 if ($this->filesystem !== null) {
136 return true;
137 }
138
139 global $wp_filesystem;
140
141 if (!function_exists('WP_Filesystem')) {
142 require_once ABSPATH . 'wp-admin/includes/file.php';
143 }
144
145 $credentials = request_filesystem_credentials('', '', false, false, null);
146 if (!WP_Filesystem($credentials)) {
147 return false;
148 }
149
150 $this->filesystem = $wp_filesystem;
151 return true;
152 }
153
154 /**
155 * Check if directory is writable using WP_Filesystem
156 *
157 * @since 1.0.0
158 * @param string $path Directory path to check
159 * @return bool True if writable, false otherwise
160 */
161 private function is_directory_writable(string $path): bool {
162 if (!$this->init_filesystem()) {
163 return false;
164 }
165
166 return $this->filesystem->is_writable($path);
167 }
168
169 /**
170 * Check if file is writable using WP_Filesystem
171 *
172 * @since 1.0.0
173 * @param string $file File path to check
174 * @return bool True if writable, false otherwise
175 */
176 private function is_file_writable(string $file): bool {
177 if (!$this->init_filesystem()) {
178 return false;
179 }
180
181 return $this->filesystem->is_writable($file);
182 }
183
184 /**
185 * Set file permissions using WP_Filesystem
186 *
187 * @since 1.0.0
188 * @param string $file File path
189 * @param int $mode File permissions mode
190 * @return bool True if successful, false otherwise
191 */
192 private function set_file_permissions(string $file, int $mode): bool {
193 if (!$this->init_filesystem()) {
194 return false;
195 }
196
197 return $this->filesystem->chmod($file, $mode);
198 }
199
200 /**
201 * Generate LLMs.txt content based on user input
202 *
203 * @since 1.0.0
204 *
205 * @param array $user_input User-provided website information
206 * @param array $options Generation options
207 * @return array Generated LLMs.txt data with content and metadata
208 */
209 public function generate_llms_txt(array $user_input, array $options = []): array {
210 $llms_data = [
211 'content' => '',
212 'sections' => [],
213 'metadata' => [],
214 'validation' => [],
215 'file_info' => []
216 ];
217
218 // Get current settings
219 $settings = $this->get_settings('site');
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 * Write LLMs.txt content to filesystem
259 *
260 * @since 1.0.0
261 *
262 * @param string $content LLMs.txt content to write
263 * @return array Write operation result
264 */
265 public function write_llms_txt_to_file(string $content): array {
266 $result = [
267 'success' => false,
268 'message' => '',
269 'file_path' => '',
270 'permissions' => []
271 ];
272
273 $llms_file = ABSPATH . 'llms.txt';
274
275 // Security: Validate file path to prevent path traversal attacks
276 $real_llms_file = realpath(dirname($llms_file)) . DIRECTORY_SEPARATOR . basename($llms_file);
277 $allowed_dir = realpath(ABSPATH);
278
279 if (!$allowed_dir || strpos(dirname($real_llms_file), $allowed_dir) !== 0) {
280 $result['message'] = 'Invalid file path detected for security reasons.';
281 return $result;
282 }
283
284 $result['file_path'] = $llms_file;
285
286 // Check directory permissions
287 $result['permissions'] = [
288 'directory_writable' => $this->is_directory_writable(ABSPATH),
289 'file_exists' => file_exists($llms_file),
290 'file_writable' => file_exists($llms_file) ? $this->is_file_writable($llms_file) : null
291 ];
292
293 // Check if we can write to the directory
294 if (!$result['permissions']['directory_writable']) {
295 $result['message'] = 'WordPress root directory is not writable. Please check file permissions.';
296 return $result;
297 }
298
299 // Check if existing file is writable (if it exists)
300 if ($result['permissions']['file_exists'] && !$result['permissions']['file_writable']) {
301 $result['message'] = 'Existing llms.txt file is not writable. Please check file permissions.';
302 return $result;
303 }
304
305 try {
306 // Write new content (directly replace existing file)
307 $bytes_written = file_put_contents($llms_file, $content, LOCK_EX);
308
309 if ($bytes_written !== false) {
310 $result['success'] = true;
311 $result['message'] = 'LLMs.txt file written successfully.';
312 $result['bytes_written'] = $bytes_written;
313
314 // Set appropriate file permissions (644)
315 $this->set_file_permissions($llms_file, 0644);
316
317 // Invalidate file status cache since file has changed
318 delete_transient('thinkrank_llms_file_status');
319 } else {
320 $result['message'] = 'Failed to write llms.txt file.';
321 }
322
323 } catch (\Exception $e) {
324 $result['message'] = 'Error writing llms.txt file: ' . $e->getMessage();
325 }
326
327 return $result;
328 }
329
330 /**
331 * Get LLMs.txt file status and information
332 *
333 * @since 1.0.0
334 *
335 * @return array File status information
336 */
337 public function get_llms_txt_status(bool $force_refresh = false): array {
338 // Check cache first (5 minute cache for performance)
339 $cache_key = 'thinkrank_llms_file_status';
340
341 if (!$force_refresh) {
342 $cached_status = get_transient($cache_key);
343 if ($cached_status !== false) {
344 return $cached_status;
345 }
346 }
347
348 $llms_file = ABSPATH . 'llms.txt';
349
350 $status = [
351 'file_exists' => file_exists($llms_file),
352 'file_path' => $llms_file,
353 'file_url' => home_url('/llms.txt'),
354 'writable' => $this->is_directory_writable(dirname($llms_file)),
355 'last_modified' => null,
356 'file_size' => null,
357 'content_preview' => ''
358 ];
359
360 if ($status['file_exists']) {
361 $status['last_modified'] = filemtime($llms_file);
362 $status['file_size'] = filesize($llms_file);
363
364 // Get content preview (first 200 characters) with size safety
365 $read_result = $this->safe_file_read($llms_file);
366 if ($read_result['success']) {
367 $status['content_preview'] = substr($read_result['content'], 0, 200);
368 if (strlen($read_result['content']) > 200) {
369 $status['content_preview'] .= '...';
370 }
371 } else {
372 $status['content_preview'] = 'Error: ' . $read_result['error'];
373 $status['read_error'] = $read_result['error'];
374 }
375 }
376
377 // Cache the result for 5 minutes to improve performance
378 set_transient($cache_key, $status, 5 * MINUTE_IN_SECONDS);
379
380 return $status;
381 }
382
383 /**
384 * Validate LLMs.txt content
385 *
386 * @since 1.0.0
387 *
388 * @param string $content LLMs.txt content to validate
389 * @return array Validation results
390 */
391 public function validate_llms_txt_content(string $content): array {
392 $validation = [
393 'valid' => true,
394 'errors' => [],
395 'warnings' => [],
396 'suggestions' => [],
397 'score' => 100
398 ];
399
400 // Check if content is empty
401 if (empty(trim($content))) {
402 $validation['errors'][] = 'LLMs.txt content cannot be empty';
403 $validation['valid'] = false;
404 $validation['score'] = 0;
405 return $validation;
406 }
407
408 // Check content length
409 $content_length = strlen($content);
410 if ($content_length < 100) {
411 $validation['warnings'][] = 'LLMs.txt content is very short, consider adding more details';
412 $validation['score'] -= 20;
413 } elseif ($content_length > 10000) {
414 $validation['warnings'][] = 'LLMs.txt content is very long, consider condensing key information';
415 $validation['score'] -= 10;
416 }
417
418 // Check for required sections
419 $required_sections = ['Project Overview', 'Key Features', 'Context for AI Assistants'];
420 foreach ($required_sections as $section) {
421 if (stripos($content, $section) === false) {
422 $validation['warnings'][] = "Missing recommended section: {$section}";
423 $validation['score'] -= 15;
424 }
425 }
426
427 // Check for proper structure
428 if (!preg_match('/^#\s+/', $content)) {
429 $validation['suggestions'][] = 'Consider starting with a main heading (# Project Name)';
430 $validation['score'] -= 5;
431 }
432
433 // Ensure score doesn't go below 0
434 $validation['score'] = max(0, $validation['score']);
435
436 return $validation;
437 }
438
439 /**
440 * Validate user input for LLMs.txt generation
441 *
442 * @since 1.0.0
443 *
444 * @param array $user_input User-provided data
445 * @return array Validation results
446 */
447 private function validate_user_input(array $user_input): array {
448 $validation = [
449 'valid' => true,
450 'errors' => [],
451 'warnings' => [],
452 'suggestions' => [],
453 'score' => 100
454 ];
455
456 // Check required fields
457 $required_fields = [
458 'website_description' => 'Website Description',
459 'key_features' => 'Key Features',
460 'target_audience' => 'Target Audience'
461 ];
462
463 foreach ($required_fields as $field => $label) {
464 if (empty($user_input[$field])) {
465 $validation['errors'][] = "{$label} is required for quality LLMs.txt generation";
466 $validation['valid'] = false;
467 $validation['score'] -= 25;
468 } else {
469 $validation['suggestions'][] = "{$label} is properly configured";
470 }
471 }
472
473 // Validate link formats in structured sections
474 $this->validate_link_sections($user_input, $validation);
475
476 // Check content quality
477 $this->validate_content_quality($user_input, $validation);
478
479 // Check optional enhancements
480 $this->validate_optional_enhancements($user_input, $validation);
481
482 // Validate website description
483 if (!empty($user_input['website_description'])) {
484 $desc_length = strlen($user_input['website_description']);
485 if ($desc_length < 50) {
486 $validation['warnings'][] = 'Website description is quite short, consider adding more details';
487 $validation['score'] -= 10;
488 } elseif ($desc_length > 1000) {
489 $validation['warnings'][] = 'Website description is very long, consider condensing key points';
490 $validation['score'] -= 5;
491 }
492 }
493
494 // Validate business type
495 if (!empty($user_input['business_type']) && !isset($this->business_types[$user_input['business_type']])) {
496 $validation['warnings'][] = 'Unknown business type specified';
497 $validation['score'] -= 5;
498 }
499
500 // Ensure score doesn't go below 0
501 $validation['score'] = max(0, $validation['score']);
502
503 return $validation;
504 }
505
506 /**
507 * Validate LLMs.txt input (public method for API)
508 *
509 * @since 1.0.0
510 *
511 * @param array $user_input User-provided data
512 * @return array Validation results
513 */
514 public function validate_llms_txt_input(array $user_input): array {
515 return $this->validate_user_input($user_input);
516 }
517
518 /**
519 * Override parent sanitize_settings to preserve line breaks in link fields
520 *
521 * @since 1.0.0
522 *
523 * @param array $settings Settings to sanitize
524 * @return array Sanitized settings
525 */
526 protected function sanitize_settings(array $settings): array {
527 $sanitized = [];
528
529 // Fields that should preserve line breaks
530 $preserve_linebreaks = [
531 'documentation_links',
532 'technical_links',
533 'optional_links',
534 'custom_sections',
535 'key_features',
536 'website_description',
537 'technical_stack',
538 'development_approach',
539 'setup_instructions',
540 'ai_context_custom'
541 ];
542
543 foreach ($settings as $key => $value) {
544 $sanitized_key = sanitize_key($key);
545
546 if (is_string($value)) {
547 if (in_array($key, $preserve_linebreaks, true)) {
548 // Use our custom sanitization that preserves line breaks
549 if (in_array($key, ['documentation_links', 'technical_links', 'optional_links', 'custom_sections'], true)) {
550 $sanitized[$sanitized_key] = $this->sanitize_llms_content($value);
551 } else {
552 // For textarea fields, use sanitize_textarea_field which preserves line breaks
553 $sanitized[$sanitized_key] = sanitize_textarea_field($value);
554 }
555 } else {
556 // For regular text fields, use sanitize_text_field
557 $sanitized[$sanitized_key] = sanitize_text_field($value);
558 }
559 } elseif (is_array($value)) {
560 $sanitized[$sanitized_key] = $this->sanitize_array_recursive($value);
561 } elseif (is_numeric($value)) {
562 $sanitized[$sanitized_key] = (float) $value;
563 } elseif (is_bool($value)) {
564 $sanitized[$sanitized_key] = (bool) $value;
565 } else {
566 $sanitized[$sanitized_key] = sanitize_text_field((string) $value);
567 }
568 }
569
570 return $sanitized;
571 }
572
573 /**
574 * Recursively sanitize array values (preserving line breaks where needed)
575 *
576 * @since 1.0.0
577 *
578 * @param array $array Array to sanitize
579 * @return array Sanitized array
580 */
581 private function sanitize_array_recursive(array $array): array {
582 $sanitized = [];
583
584 foreach ($array as $key => $value) {
585 $sanitized_key = sanitize_key($key);
586
587 if (is_string($value)) {
588 $sanitized[$sanitized_key] = sanitize_textarea_field($value);
589 } elseif (is_array($value)) {
590 $sanitized[$sanitized_key] = $this->sanitize_array_recursive($value);
591 } elseif (is_numeric($value)) {
592 $sanitized[$sanitized_key] = (float) $value;
593 } elseif (is_bool($value)) {
594 $sanitized[$sanitized_key] = (bool) $value;
595 } else {
596 $sanitized[$sanitized_key] = sanitize_text_field((string) $value);
597 }
598 }
599
600 return $sanitized;
601 }
602
603 /**
604 * Validate link formats in structured sections
605 *
606 * @since 1.0.0
607 *
608 * @param array $user_input User input data
609 * @param array &$validation Validation results (passed by reference)
610 */
611 private function validate_link_sections(array $user_input, array &$validation): void {
612 $link_sections = [
613 'documentation_links' => 'Documentation Links',
614 'technical_links' => 'Technical Links',
615 'optional_links' => 'Optional Links'
616 ];
617
618 foreach ($link_sections as $field => $label) {
619 if (!empty($user_input[$field])) {
620 $links = explode("\n", $user_input[$field]);
621 $valid_links = 0;
622 $total_links = 0;
623
624 foreach ($links as $line) {
625 $line = trim($line);
626 if (empty($line) || !str_starts_with($line, '-')) {
627 continue;
628 }
629
630 $total_links++;
631
632 // Check for proper markdown link format: - [Title](URL): Description
633 if (preg_match('/^-\s*\[([^\]]+)\]\(([^)]+)\):\s*(.+)$/', $line, $matches)) {
634 $title = trim($matches[1]);
635 $url = trim($matches[2]);
636 $description = trim($matches[3]);
637
638 if (!empty($title) && !empty($url) && !empty($description)) {
639 if (filter_var($url, FILTER_VALIDATE_URL)) {
640 $valid_links++;
641 } else {
642 $validation['warnings'][] = "Invalid URL in {$label}: {$url}";
643 $validation['score'] -= 5;
644 }
645 } else {
646 $validation['warnings'][] = "Incomplete link format in {$label}: missing title, URL, or description";
647 $validation['score'] -= 5;
648 }
649 } else {
650 $validation['warnings'][] = "Invalid link format in {$label}. Use: - [Title](URL): Description";
651 $validation['score'] -= 5;
652 }
653 }
654
655 if ($total_links > 0) {
656 if ($valid_links === $total_links) {
657 $validation['suggestions'][] = "✓ All {$label} are properly formatted";
658 } else {
659 $validation['warnings'][] = "{$label}: {$valid_links}/{$total_links} links are properly formatted";
660 }
661 }
662 }
663 }
664 }
665
666 /**
667 * Validate content quality
668 *
669 * @since 1.0.0
670 *
671 * @param array $user_input User input data
672 * @param array &$validation Validation results (passed by reference)
673 */
674 private function validate_content_quality(array $user_input, array &$validation): void {
675 // Check website description quality
676 if (!empty($user_input['website_description'])) {
677 $desc_length = strlen($user_input['website_description']);
678 if ($desc_length < 50) {
679 $validation['warnings'][] = 'Website description is quite short. Consider adding more detail for better AI understanding';
680 $validation['score'] -= 10;
681 } elseif ($desc_length > 500) {
682 $validation['warnings'][] = 'Website description is very long. Consider making it more concise';
683 $validation['score'] -= 5;
684 } else {
685 $validation['suggestions'][] = '✓ Website description length is optimal';
686 }
687 }
688
689 // Check key features quality
690 if (!empty($user_input['key_features'])) {
691 $features = explode("\n", $user_input['key_features']);
692 $feature_count = count(array_filter($features, 'trim'));
693
694 if ($feature_count < 3) {
695 $validation['warnings'][] = 'Consider adding more key features (3-8 recommended) for comprehensive AI understanding';
696 $validation['score'] -= 10;
697 } elseif ($feature_count > 10) {
698 $validation['warnings'][] = 'Many key features listed. Consider focusing on the most important ones';
699 $validation['score'] -= 5;
700 } else {
701 $validation['suggestions'][] = "✓ Good number of key features ({$feature_count})";
702 }
703 }
704 }
705
706 /**
707 * Validate optional enhancements
708 *
709 * @since 1.0.0
710 *
711 * @param array $user_input User input data
712 * @param array &$validation Validation results (passed by reference)
713 */
714 private function validate_optional_enhancements(array $user_input, array &$validation): void {
715 $enhancement_score = 0;
716
717 // Check for technical stack
718 if (!empty($user_input['technical_stack'])) {
719 $validation['suggestions'][] = '✓ Technical stack information provided';
720 $enhancement_score += 5;
721 } else {
722 $validation['suggestions'][] = 'Consider adding technical stack information for developer context';
723 }
724
725 // Check for development approach
726 if (!empty($user_input['development_approach'])) {
727 $validation['suggestions'][] = '✓ Development approach documented';
728 $enhancement_score += 5;
729 } else {
730 $validation['suggestions'][] = 'Consider documenting development approach for better AI assistance';
731 }
732
733 // Check for setup instructions
734 if (!empty($user_input['setup_instructions'])) {
735 $validation['suggestions'][] = '✓ Setup instructions provided';
736 $enhancement_score += 5;
737 } else {
738 $validation['suggestions'][] = 'Consider adding setup instructions for new developers';
739 }
740
741 // Check for custom sections
742 if (!empty($user_input['custom_sections'])) {
743 $validation['suggestions'][] = '✓ Custom sections enhance documentation';
744 $enhancement_score += 5;
745 }
746
747 // Bonus points for comprehensive documentation
748 if ($enhancement_score >= 15) {
749 $validation['suggestions'][] = '✓ Comprehensive LLMs.txt documentation - excellent for AI assistance!';
750 }
751 }
752
753 /**
754 * Build content sections from user input
755 *
756 * @since 1.0.0
757 *
758 * @param array $user_input User-provided data
759 * @param array $settings Current settings
760 * @return array Built content sections
761 */
762 private function build_content_sections(array $user_input, array $settings): array {
763 $sections = [];
764
765 // Blockquote summary (required by spec)
766 $sections['summary'] = [
767 'title' => '', // No title for blockquote
768 'content' => $this->build_summary_blockquote($user_input, $settings)
769 ];
770
771 // Additional details (optional descriptive content)
772 if (!empty($user_input['website_description'])) {
773 $sections['details'] = [
774 'title' => '', // No title for details
775 'content' => $this->build_additional_details($user_input, $settings)
776 ];
777 }
778
779 // Development Approach section (if provided)
780 if (!empty($user_input['development_approach'])) {
781 $sections['development_approach'] = [
782 'title' => 'Development Approach',
783 'content' => sanitize_textarea_field($user_input['development_approach'])
784 ];
785 }
786
787 // Setup Instructions section (if provided)
788 if (!empty($user_input['setup_instructions'])) {
789 $sections['setup_instructions'] = [
790 'title' => 'Setup Instructions',
791 'content' => sanitize_textarea_field($user_input['setup_instructions'])
792 ];
793 }
794
795 // User-controlled structured sections (always include with defaults if empty)
796 $documentation_content = !empty($user_input['documentation_links'])
797 ? $this->sanitize_llms_content($user_input['documentation_links'])
798 : $this->get_default_documentation_links();
799
800 $sections['documentation'] = [
801 'title' => 'Documentation',
802 'content' => $documentation_content
803 ];
804
805 // Technical section (only if user provided content or technical details exist)
806 if (!empty($user_input['technical_links']) || !empty($user_input['technical_stack']) || !empty($user_input['development_approach'])) {
807 $technical_content = !empty($user_input['technical_links'])
808 ? $this->sanitize_llms_content($user_input['technical_links'])
809 : $this->get_default_technical_links($user_input);
810
811 $sections['technical'] = [
812 'title' => 'Technical Details',
813 'content' => $technical_content
814 ];
815 }
816
817 // Optional section (only if user provided content)
818 if (!empty($user_input['optional_links'])) {
819 $sections['optional'] = [
820 'title' => 'Optional',
821 'content' => $this->sanitize_llms_content($user_input['optional_links'])
822 ];
823 }
824
825 // Custom sections (user-defined markdown)
826 if (!empty($user_input['custom_sections'])) {
827 $sections['custom'] = [
828 'title' => '', // No title since user provides their own H2 headers
829 'content' => $this->sanitize_llms_content($user_input['custom_sections'])
830 ];
831 }
832
833 return $sections;
834 }
835
836 /**
837 * Sanitize LLMs.txt content while preserving line breaks
838 *
839 * @since 1.0.0
840 *
841 * @param string $content Raw content to sanitize
842 * @return string Sanitized content with preserved line breaks
843 */
844 public function sanitize_llms_content(string $content): string {
845 // Remove any potential script tags and dangerous content
846 $content = wp_kses($content, [
847 'a' => ['href' => [], 'title' => []],
848 'strong' => [],
849 'em' => [],
850 'code' => [],
851 'pre' => []
852 ]);
853
854 // Normalize line endings and preserve line breaks
855 $content = str_replace(["\r\n", "\r"], "\n", $content);
856
857 // Remove excessive whitespace but preserve intentional line breaks
858 $content = preg_replace('/[ \t]+/', ' ', $content); // Multiple spaces/tabs to single space
859 $content = preg_replace('/\n\s*\n\s*\n+/', "\n\n", $content); // Multiple empty lines to double
860
861 return trim($content);
862 }
863
864 /**
865 * Safely read file content with size limits
866 *
867 * @since 1.0.0
868 *
869 * @param string $file_path Path to file to read
870 * @param int|null $max_size Maximum file size to read (null for class default)
871 * @return array Result with success status, content, and any errors
872 */
873 private function safe_file_read(string $file_path, ?int $max_size = null): array {
874 $result = [
875 'success' => false,
876 'content' => '',
877 'error' => '',
878 'file_size' => 0
879 ];
880
881 if (!file_exists($file_path)) {
882 $result['error'] = 'File does not exist';
883 return $result;
884 }
885
886 $file_size = filesize($file_path);
887 $result['file_size'] = $file_size;
888
889 $max_allowed = $max_size ?? self::MAX_FILE_SIZE;
890
891 if ($file_size > $max_allowed) {
892 $result['error'] = sprintf(
893 'File size (%s) exceeds maximum allowed size (%s)',
894 size_format($file_size),
895 size_format($max_allowed)
896 );
897 return $result;
898 }
899
900 $content = file_get_contents($file_path);
901 if (false === $content) {
902 $result['error'] = 'Failed to read file content';
903 return $result;
904 }
905
906 $result['success'] = true;
907 $result['content'] = $content;
908 return $result;
909 }
910
911 /**
912 * Build project overview content
913 *
914 * @since 1.0.0
915 *
916 * @param array $user_input User input data
917 * @param array $settings Current settings
918 * @return string Project overview content
919 */
920 private function build_project_overview(array $user_input, array $settings): string {
921 $site_name = $user_input['site_name'] ?? $settings['site_name'] ?? get_bloginfo('name');
922 $website_url = home_url();
923 $description = sanitize_textarea_field($user_input['website_description'] ?? '');
924 $business_type = $user_input['business_type'] ?? 'website';
925 $target_audience = sanitize_text_field($user_input['target_audience'] ?? '');
926
927 $overview = "{$site_name} is a {$this->business_types[$business_type]} located at {$website_url}.\n\n";
928 $overview .= "{$description}\n\n";
929
930 if (!empty($target_audience)) {
931 $overview .= "Target Audience: {$target_audience}\n";
932 }
933
934 return $overview;
935 }
936
937 /**
938 * Build key features content
939 *
940 * @since 1.0.0
941 *
942 * @param array $user_input User input data
943 * @return string Key features content
944 */
945 private function build_key_features(array $user_input): string {
946 $features = sanitize_textarea_field($user_input['key_features'] ?? '');
947
948 // If features are provided as a list, format them properly
949 if (strpos($features, "\n") !== false || strpos($features, ',') !== false) {
950 $feature_list = preg_split('/[,\n]+/', $features);
951 $formatted_features = '';
952 foreach ($feature_list as $feature) {
953 $feature = trim($feature);
954 if (!empty($feature)) {
955 $formatted_features .= "- " . $feature . "\n";
956 }
957 }
958 return $formatted_features;
959 }
960
961 return $features;
962 }
963
964 /**
965 * Build architecture section content
966 *
967 * @since 1.0.0
968 *
969 * @param array $user_input User input data
970 * @return string Architecture content
971 */
972 private function build_architecture_section(array $user_input): string {
973 $content = '';
974
975 if (!empty($user_input['technical_stack'])) {
976 $content .= "**Technical Stack:**\n";
977 $stack = sanitize_textarea_field($user_input['technical_stack']);
978 $content .= $stack . "\n\n";
979 }
980
981 if (!empty($user_input['development_approach'])) {
982 $content .= "**Development Approach:**\n";
983 $approach = sanitize_textarea_field($user_input['development_approach']);
984 $content .= $approach . "\n";
985 }
986
987 return $content;
988 }
989
990 /**
991 * Build development guidelines content
992 *
993 * @since 1.0.0
994 *
995 * @param array $user_input User input data
996 * @return string Development guidelines content
997 */
998 private function build_development_guidelines(array $user_input): string {
999 $guidelines = sanitize_textarea_field($user_input['development_approach'] ?? '');
1000
1001 // Add some standard guidelines if not provided
1002 if (empty($guidelines)) {
1003 $guidelines = "- Follow WordPress coding standards\n";
1004 $guidelines .= "- Use semantic HTML and accessible design\n";
1005 $guidelines .= "- Optimize for performance and SEO\n";
1006 }
1007
1008 return $guidelines;
1009 }
1010
1011 /**
1012 * Build AI context content
1013 *
1014 * @since 1.0.0
1015 *
1016 * @param array $user_input User input data
1017 * @param array $settings Current settings
1018 * @return string AI context content
1019 */
1020 private function build_ai_context(array $user_input, array $settings): string {
1021 $context = "This website is built with WordPress and uses the ThinkRank SEO plugin for optimization.\n\n";
1022
1023 $business_type = $user_input['business_type'] ?? 'website';
1024 $context .= "When providing assistance:\n";
1025 $context .= "- Consider this is a {$this->business_types[$business_type]}\n";
1026
1027 if (!empty($user_input['target_audience'])) {
1028 $target_audience = sanitize_text_field($user_input['target_audience']);
1029 $context .= "- Target audience: {$target_audience}\n";
1030 }
1031
1032 $context .= "- Focus on SEO best practices and user experience\n";
1033 $context .= "- Maintain WordPress coding standards\n";
1034
1035 if (!empty($user_input['ai_context_custom'])) {
1036 $context .= "\nAdditional Context:\n";
1037 $context .= sanitize_textarea_field($user_input['ai_context_custom']);
1038 }
1039
1040 return $context;
1041 }
1042
1043 /**
1044 * Build final LLMs.txt content from sections
1045 *
1046 * @since 1.0.0
1047 *
1048 * @param array $sections Content sections
1049 * @param string $site_name Site name to use in the header
1050 * @return string Complete LLMs.txt content
1051 */
1052 private function build_llms_txt_content(array $sections, string $site_name = ''): string {
1053 $site_name = $site_name ?: get_bloginfo('name');
1054 $content = "# {$site_name}\n\n";
1055
1056 foreach ($sections as $section_key => $section_data) {
1057 // Only add H2 header if title is not empty
1058 if (!empty($section_data['title'])) {
1059 $content .= "## {$section_data['title']}\n\n";
1060 }
1061 $content .= $section_data['content'] . "\n\n";
1062 }
1063
1064 // Add generation timestamp
1065 $content .= "---\n";
1066 $content .= "Generated by ThinkRank SEO Plugin on " . gmdate('Y-m-d H:i:s') . " UTC\n";
1067
1068 return $content;
1069 }
1070
1071 /**
1072 * Validate SEO settings (implements interface)
1073 *
1074 * @since 1.0.0
1075 *
1076 * @param array $settings Settings array to validate
1077 * @return array Validation results
1078 */
1079 public function validate_settings(array $settings): array {
1080 $validation = [
1081 'valid' => true,
1082 'errors' => [],
1083 'warnings' => [],
1084 'suggestions' => [],
1085 'score' => 100
1086 ];
1087
1088 // Validate enabled setting
1089 if (!isset($settings['enabled'])) {
1090 $validation['errors'][] = 'Enabled setting is required';
1091 $validation['valid'] = false;
1092 $validation['score'] -= 25;
1093 }
1094
1095 // Validate website description
1096 if (isset($settings['website_description'])) {
1097 if (empty($settings['website_description'])) {
1098 $validation['warnings'][] = 'Website description is empty, consider adding a description';
1099 $validation['score'] -= 15;
1100 } elseif (strlen($settings['website_description']) < 50) {
1101 $validation['suggestions'][] = 'Website description is quite short, consider adding more details';
1102 $validation['score'] -= 5;
1103 }
1104 }
1105
1106 // Validate key features
1107 if (isset($settings['key_features'])) {
1108 if (empty($settings['key_features'])) {
1109 $validation['warnings'][] = 'Key features are empty, consider listing main website features';
1110 $validation['score'] -= 15;
1111 }
1112 }
1113
1114 // Validate target audience
1115 if (isset($settings['target_audience'])) {
1116 if (empty($settings['target_audience'])) {
1117 $validation['suggestions'][] = 'Target audience is not specified, consider defining your audience';
1118 $validation['score'] -= 5;
1119 }
1120 }
1121
1122 // Check file permissions if enabled
1123 if (!empty($settings['enabled'])) {
1124 if (!$this->is_directory_writable(ABSPATH)) {
1125 $validation['warnings'][] = 'WordPress root directory is not writable, llms.txt cannot be automatically managed';
1126 $validation['score'] -= 10;
1127 }
1128 }
1129
1130 // Ensure score doesn't go below 0
1131 $validation['score'] = max(0, $validation['score']);
1132
1133 return $validation;
1134 }
1135
1136 /**
1137 * Get output data for frontend rendering (implements interface)
1138 *
1139 * @since 1.0.0
1140 *
1141 * @param string $context_type The context type
1142 * @param int|null $context_id Optional. Context ID
1143 * @return array Output data ready for frontend rendering
1144 */
1145 public function get_output_data(string $context_type, ?int $context_id): array {
1146 $settings = $this->get_settings($context_type, $context_id);
1147
1148 $output = [
1149 'llms_txt_content' => '',
1150 'file_status' => [],
1151 'metadata' => [],
1152 'enabled' => $settings['enabled'] ?? true
1153 ];
1154
1155 if (!$output['enabled']) {
1156 return $output;
1157 }
1158
1159 // Get file status
1160 $output['file_status'] = $this->get_llms_txt_status();
1161
1162 // If file exists, get current content safely
1163 if ($output['file_status']['file_exists']) {
1164 $llms_file = ABSPATH . 'llms.txt';
1165 $read_result = $this->safe_file_read($llms_file);
1166 if ($read_result['success']) {
1167 $output['llms_txt_content'] = $read_result['content'];
1168 } else {
1169 $output['llms_txt_content'] = '';
1170 $output['file_read_error'] = $read_result['error'];
1171 }
1172 }
1173
1174 // Add metadata
1175 $output['metadata'] = [
1176 'last_generated' => $settings['last_generated'] ?? null,
1177 'generator_version' => THINKRANK_VERSION ?? '1.0.0',
1178 'website_url' => home_url()
1179 ];
1180
1181 return $output;
1182 }
1183
1184 /**
1185 * Get default settings for a context type (implements interface)
1186 *
1187 * @since 1.0.0
1188 *
1189 * @param string $context_type The context type to get defaults for
1190 * @return array Default settings array
1191 */
1192 public function get_default_settings(string $context_type): array {
1193 $defaults = [
1194 'enabled' => true,
1195 'site_name' => get_bloginfo('name'),
1196 'website_description' => get_bloginfo('description'),
1197 'key_features' => '',
1198 'target_audience' => 'general',
1199 'business_type' => 'website',
1200 'technical_stack' => 'WordPress',
1201 'development_approach' => '',
1202 'setup_instructions' => '',
1203 'ai_context_custom' => '',
1204 'auto_generate' => false,
1205 'last_generated' => null,
1206 // Structured sections for llms.txt spec compliance
1207 'documentation_links' => '',
1208 'technical_links' => '',
1209 'optional_links' => '',
1210 'custom_sections' => ''
1211 ];
1212
1213 // Context-specific defaults
1214 switch ($context_type) {
1215 case 'site':
1216 // Site-wide defaults are already set above
1217 break;
1218 default:
1219 // Use site defaults for other contexts
1220 break;
1221 }
1222
1223 return $defaults;
1224 }
1225
1226 /**
1227 * Get settings schema definition (implements interface)
1228 *
1229 * @since 1.0.0
1230 *
1231 * @param string $context_type The context type to get schema for
1232 * @return array Settings schema definition
1233 */
1234 public function get_settings_schema(string $context_type): array {
1235 return [
1236 'enabled' => [
1237 'type' => 'boolean',
1238 'title' => 'Enable LLMs.txt',
1239 'description' => 'Enable LLMs.txt file generation and management',
1240 'default' => true
1241 ],
1242 'site_name' => [
1243 'type' => 'string',
1244 'title' => 'Website Title',
1245 'description' => 'The name of your website as it will appear in the LLMs.txt file',
1246 'default' => get_bloginfo('name'),
1247 'maxLength' => 60
1248 ],
1249 'website_description' => [
1250 'type' => 'string',
1251 'title' => 'Website Description',
1252 'description' => 'Comprehensive description of your website and its purpose',
1253 'default' => get_bloginfo('description'),
1254 'maxLength' => 1000
1255 ],
1256 'key_features' => [
1257 'type' => 'string',
1258 'title' => 'Key Features',
1259 'description' => 'Main features and functionality of your website',
1260 'default' => '',
1261 'maxLength' => 500
1262 ],
1263 'target_audience' => [
1264 'type' => 'string',
1265 'title' => 'Target Audience',
1266 'description' => 'Primary audience for your website',
1267 'default' => 'general',
1268 'maxLength' => 200
1269 ],
1270 'business_type' => [
1271 'type' => 'string',
1272 'title' => 'Business Type',
1273 'description' => 'Type of website or business',
1274 'enum' => array_keys($this->business_types),
1275 'default' => 'website'
1276 ],
1277 'technical_stack' => [
1278 'type' => 'string',
1279 'title' => 'Technical Stack',
1280 'description' => 'Technologies and frameworks used',
1281 'default' => 'WordPress',
1282 'maxLength' => 300
1283 ],
1284 'development_approach' => [
1285 'type' => 'string',
1286 'title' => 'Development Approach',
1287 'description' => 'Development methodology and practices',
1288 'default' => '',
1289 'maxLength' => 400
1290 ],
1291 'setup_instructions' => [
1292 'type' => 'string',
1293 'title' => 'Setup Instructions',
1294 'description' => 'Instructions for setting up or working with the project',
1295 'default' => '',
1296 'maxLength' => 500
1297 ],
1298 'ai_context_custom' => [
1299 'type' => 'string',
1300 'title' => 'Additional AI Context',
1301 'description' => 'Custom context information for AI assistants',
1302 'default' => '',
1303 'maxLength' => 400
1304 ],
1305 'auto_generate' => [
1306 'type' => 'boolean',
1307 'title' => 'Auto-generate',
1308 'description' => 'Automatically regenerate llms.txt when settings change',
1309 'default' => false
1310 ],
1311 'last_generated' => [
1312 'type' => 'string',
1313 'title' => 'Last Generated',
1314 'description' => 'Timestamp of last generation',
1315 'format' => 'date-time',
1316 'readonly' => true
1317 ],
1318 'documentation_links' => [
1319 'type' => 'string',
1320 'title' => 'Documentation Links',
1321 'description' => 'Links to documentation, guides, and important pages',
1322 'default' => '',
1323 'maxLength' => 2000
1324 ],
1325 'technical_links' => [
1326 'type' => 'string',
1327 'title' => 'Technical Links',
1328 'description' => 'Links to technical resources, code repositories, and development info',
1329 'default' => '',
1330 'maxLength' => 2000
1331 ],
1332 'optional_links' => [
1333 'type' => 'string',
1334 'title' => 'Optional Links',
1335 'description' => 'Secondary resources that can be skipped for shorter context',
1336 'default' => '',
1337 'maxLength' => 2000
1338 ],
1339 'custom_sections' => [
1340 'type' => 'string',
1341 'title' => 'Custom Sections',
1342 'description' => 'Additional custom sections in markdown format',
1343 'default' => '',
1344 'maxLength' => 3000
1345 ]
1346 ];
1347 }
1348
1349 /**
1350 * Calculate validation score based on validation results
1351 *
1352 * @since 1.0.0
1353 *
1354 * @param array $validation Validation results
1355 * @return int Calculated score (0-100)
1356 */
1357 private function calculate_validation_score(array $validation): int {
1358 $score = 100;
1359
1360 // Deduct points for errors (major issues)
1361 $score -= count($validation['errors']) * 25;
1362
1363 // Deduct points for warnings (moderate issues)
1364 $score -= count($validation['warnings']) * 10;
1365
1366 // Deduct points for suggestions (minor issues)
1367 $score -= count($validation['suggestions']) * 5;
1368
1369 // Ensure score doesn't go below 0
1370 return max(0, $score);
1371 }
1372
1373 /**
1374 * Build summary blockquote (required by llms.txt spec)
1375 *
1376 * @since 1.0.0
1377 *
1378 * @param array $user_input User input data
1379 * @param array $settings Current settings
1380 * @return string Blockquote summary content
1381 */
1382 private function build_summary_blockquote(array $user_input, array $settings): string {
1383 $description = sanitize_textarea_field($user_input['website_description'] ?? '');
1384
1385 if (empty($description)) {
1386 $site_name = $user_input['site_name'] ?? $settings['site_name'] ?? get_bloginfo('name');
1387 $business_type = $user_input['business_type'] ?? 'website';
1388 $description = "{$site_name} is a {$this->business_types[$business_type]} providing valuable resources and information.";
1389 }
1390
1391 // Format as blockquote (required by spec)
1392 return "> " . $description . "\n\n";
1393 }
1394
1395 /**
1396 * Build additional details section
1397 *
1398 * @since 1.0.0
1399 *
1400 * @param array $user_input User input data
1401 * @param array $settings Current settings
1402 * @return string Additional details content
1403 */
1404 private function build_additional_details(array $user_input, array $settings): string {
1405 $content = '';
1406 $target_audience = sanitize_text_field($user_input['target_audience'] ?? '');
1407 $key_features = sanitize_textarea_field($user_input['key_features'] ?? '');
1408
1409 if (!empty($target_audience)) {
1410 $content .= "**Target Audience:** {$target_audience}\n\n";
1411 }
1412
1413 if (!empty($key_features)) {
1414 $content .= "**Key Features:**\n";
1415 $features = explode(',', $key_features);
1416 foreach ($features as $feature) {
1417 $feature = trim($feature);
1418 if (!empty($feature)) {
1419 $content .= "- " . $feature . "\n";
1420 }
1421 }
1422 $content .= "\n";
1423 }
1424
1425 return $content;
1426 }
1427
1428 /**
1429 * Build documentation links section (H2 with file lists)
1430 *
1431 * @since 1.0.0
1432 *
1433 * @param array $user_input User input data
1434 * @param array $settings Current settings
1435 * @return string Documentation links content
1436 */
1437 private function build_documentation_links(array $user_input, array $settings): string {
1438 $content = '';
1439 $website_url = home_url();
1440
1441 // Add common WordPress documentation links
1442 $content .= "- [WordPress Documentation]({$website_url}/wp-admin/): Admin dashboard and content management\n";
1443 $content .= "- [Site Pages]({$website_url}/sitemap.xml): Complete sitemap of all pages\n";
1444
1445 // Add custom documentation if setup instructions provided
1446 if (!empty($user_input['setup_instructions'])) {
1447 $content .= "- [Setup Guide]({$website_url}): " . wp_trim_words($user_input['setup_instructions'], 10) . "\n";
1448 }
1449
1450 return $content;
1451 }
1452
1453 /**
1454 * Build technical details links section
1455 *
1456 * @since 1.0.0
1457 *
1458 * @param array $user_input User input data
1459 * @param array $settings Current settings
1460 * @return string Technical links content
1461 */
1462 private function build_technical_links(array $user_input, array $settings): string {
1463 $content = '';
1464 $website_url = home_url();
1465
1466 if (!empty($user_input['technical_stack'])) {
1467 $content .= "- [Technical Stack Information]({$website_url}): Built with " . sanitize_text_field($user_input['technical_stack']) . "\n";
1468 }
1469
1470 if (!empty($user_input['development_approach'])) {
1471 $content .= "- [Development Guidelines]({$website_url}): " . wp_trim_words($user_input['development_approach'], 15) . "\n";
1472 }
1473
1474 // Add robots.txt reference
1475 $content .= "- [Robots.txt]({$website_url}/robots.txt): Site crawling guidelines\n";
1476
1477 return $content;
1478 }
1479
1480 /**
1481 * Build optional links section (for secondary information)
1482 *
1483 * @since 1.0.0
1484 *
1485 * @param array $user_input User input data
1486 * @param array $settings Current settings
1487 * @return string Optional links content
1488 */
1489 private function build_optional_links(array $user_input, array $settings): string {
1490 $content = '';
1491 $website_url = home_url();
1492
1493 // Add optional/secondary resources
1494 $content .= "- [WordPress Codex](https://codex.wordpress.org/): Official WordPress documentation\n";
1495 $content .= "- [Plugin Directory](https://wordpress.org/plugins/): WordPress plugin repository\n";
1496
1497 // Add theme information if available
1498 $theme = wp_get_theme();
1499 if ($theme->exists()) {
1500 $content .= "- [Theme Information]({$website_url}): Using " . $theme->get('Name') . " theme\n";
1501 }
1502
1503 return $content;
1504 }
1505
1506 /**
1507 * Get default documentation links when user hasn't provided any
1508 *
1509 * @since 1.0.0
1510 *
1511 * @return string Default documentation links
1512 */
1513 private function get_default_documentation_links(): string {
1514 $website_url = home_url();
1515 $content = '';
1516
1517 // Add basic WordPress links
1518 $content .= "- [Website Home]({$website_url}): Main website homepage\n";
1519 $content .= "- [Sitemap]({$website_url}/sitemap.xml): Complete site structure\n";
1520
1521 return $content;
1522 }
1523
1524 /**
1525 * Get default technical links based on user input
1526 *
1527 * @since 1.0.0
1528 *
1529 * @param array $user_input User input data
1530 * @return string Default technical links
1531 */
1532 private function get_default_technical_links(array $user_input): string {
1533 $website_url = home_url();
1534 $content = '';
1535
1536 if (!empty($user_input['technical_stack'])) {
1537 $stack = sanitize_text_field($user_input['technical_stack']);
1538 $content .= "- [Technical Stack]({$website_url}): Built with {$stack}\n";
1539 }
1540
1541 if (!empty($user_input['development_approach'])) {
1542 $approach_summary = wp_trim_words($user_input['development_approach'], 10);
1543 $content .= "- [Development Guidelines]({$website_url}): {$approach_summary}\n";
1544 }
1545
1546 // Add robots.txt reference
1547 $content .= "- [Robots.txt]({$website_url}/robots.txt): Site crawling guidelines\n";
1548
1549 return $content;
1550 }
1551 }
1552