PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / seo / class-llms-txt-manager.php

class-llms-txt-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.0.0, at includes/seo/class-llms-txt-manager.php

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