PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.1.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.1.1
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 / api / class-llms-txt-endpoint.php

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

1,045 lines 38.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * LLMs.txt API Endpoints Class
4 *
5 * REST API endpoints for LLMs.txt file management including content generation,
6 * AI-powered optimization, file writing, and status monitoring. Provides
7 * comprehensive API access to LLMs.txt Manager and AI Manager functionality
8 * with proper authentication, validation, and error handling.
9 *
10 * @package ThinkRank
11 * @subpackage API
12 * @since 1.0.0
13 */
14
15 declare(strict_types=1);
16
17 namespace ThinkRank\API;
18
19 use ThinkRank\SEO\LLMs_Txt_Manager;
20 use ThinkRank\AI\Manager as AI_Manager;
21 use ThinkRank\API\Traits\CSRF_Protection;
22 use ThinkRank\API\Traits\Context_Authorization;
23 use WP_REST_Controller;
24 use WP_REST_Request;
25 use WP_REST_Response;
26 use WP_Error;
27
28 // Prevent direct access
29 if (!defined('ABSPATH')) {
30 exit;
31 }
32
33 // Load CSRF Protection trait
34 require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-csrf-protection.php';
35 require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-context-authorization.php';
36
37 /**
38 * LLMs.txt API Endpoints Class
39 *
40 * Provides REST API endpoints for LLMs.txt operations including
41 * content generation, AI-powered optimization, file management,
42 * and status monitoring with proper authentication and validation.
43 *
44 * @since 1.0.0
45 */
46 class LLMs_Txt_Endpoint extends WP_REST_Controller {
47 use CSRF_Protection;
48 use Context_Authorization;
49
50 /**
51 * LLMs.txt Manager instance
52 *
53 * @since 1.0.0
54 * @var LLMs_Txt_Manager
55 */
56 private LLMs_Txt_Manager $llms_txt_manager;
57
58 /**
59 * API namespace
60 *
61 * @since 1.0.0
62 * @var string
63 */
64 protected $namespace = 'thinkrank/v1';
65
66 /**
67 * API resource base
68 *
69 * @since 1.0.0
70 * @var string
71 */
72 protected $rest_base = 'llms-txt';
73
74 /**
75 * AI Manager instance
76 *
77 * @since 1.0.0
78 * @var AI_Manager|null
79 */
80 private ?AI_Manager $ai_manager = null;
81
82 /**
83 * Constructor
84 *
85 * @since 1.0.0
86 */
87 public function __construct() {
88 // Ensure LLMs.txt Manager is loaded
89 if (!class_exists('ThinkRank\\SEO\\LLMs_Txt_Manager')) {
90 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-llms-txt-manager.php';
91 }
92
93 $this->llms_txt_manager = new LLMs_Txt_Manager();
94 }
95
96 /**
97 * Get AI Manager instance
98 *
99 * @since 1.0.0
100 *
101 * @return AI_Manager AI Manager instance
102 * @throws \Exception If AI Manager cannot be initialized
103 */
104 private function get_ai_manager(): AI_Manager {
105 if (!$this->ai_manager) {
106 // Ensure AI Manager is loaded
107 if (!class_exists('ThinkRank\\AI\\Manager')) {
108 require_once THINKRANK_PLUGIN_DIR . 'includes/ai/class-manager.php';
109 }
110
111 $this->ai_manager = new AI_Manager();
112 }
113
114 return $this->ai_manager;
115 }
116
117 /**
118 * Register API routes
119 *
120 * @since 1.0.0
121 */
122 public function register_routes(): void {
123 // Get/Update LLMs.txt settings
124 register_rest_route(
125 $this->namespace,
126 '/' . $this->rest_base . '/settings',
127 [
128 [
129 'methods' => 'GET',
130 'callback' => [$this, 'get_settings'],
131 'permission_callback' => [$this, 'check_permissions'],
132 'args' => $this->get_context_route_args()
133 ],
134 [
135 'methods' => 'POST',
136 'callback' => [$this, 'update_settings'],
137 'permission_callback' => [$this, 'check_permissions'],
138 'args' => $this->get_settings_args()
139 ]
140 ]
141 );
142
143 // Generate LLMs.txt file
144 register_rest_route(
145 $this->namespace,
146 '/' . $this->rest_base . '/generate',
147 [
148 [
149 'methods' => 'POST',
150 'callback' => [$this, 'generate_llms_txt'],
151 'permission_callback' => [$this, 'check_permissions'],
152 'args' => $this->get_generation_args()
153 ]
154 ]
155 );
156
157 // AI-powered LLMs.txt optimization
158 register_rest_route(
159 $this->namespace,
160 '/' . $this->rest_base . '/ai-optimize',
161 [
162 [
163 'methods' => 'POST',
164 'callback' => [$this, 'ai_optimize_llms_txt'],
165 'permission_callback' => [$this, 'check_permissions'],
166 'args' => $this->get_ai_optimization_args()
167 ]
168 ]
169 );
170
171 // Get LLMs.txt file status
172 register_rest_route(
173 $this->namespace,
174 '/' . $this->rest_base . '/status',
175 [
176 [
177 'methods' => 'GET',
178 'callback' => [$this, 'get_llms_txt_status'],
179 'permission_callback' => [$this, 'check_permissions']
180 ]
181 ]
182 );
183
184 // Validate LLMs.txt settings (read-only validation, no CSRF needed)
185 register_rest_route(
186 $this->namespace,
187 '/' . $this->rest_base . '/validate',
188 [
189 [
190 'methods' => 'POST',
191 'callback' => [$this, 'validate_llms_txt_settings'],
192 // Require thinkrank_crawling like every other llms-txt route
193 // (was 'read', which let any subscriber hit this admin tool).
194 'permission_callback' => [$this, 'check_permissions'],
195 'args' => $this->get_validation_args()
196 ]
197 ]
198 );
199
200 // Get latest optimization results
201 register_rest_route(
202 $this->namespace,
203 '/' . $this->rest_base . '/optimization-results',
204 [
205 [
206 'methods' => 'GET',
207 'callback' => [$this, 'get_optimization_results'],
208 'permission_callback' => [$this, 'check_permissions'],
209 // The handler reads `limit` and forwards it to a prepared
210 // LIMIT %d. Not injectable, but unbounded — and unregistered
211 // means no coercion either (#394).
212 'args' => [
213 'limit' => [
214 'required' => false,
215 'type' => 'integer',
216 'default' => 20,
217 'minimum' => 1,
218 'maximum' => 100,
219 ],
220 ]
221 ]
222 ]
223 );
224
225 // Get overview data (combined endpoint for performance)
226 register_rest_route(
227 $this->namespace,
228 '/' . $this->rest_base . '/overview',
229 [
230 [
231 'methods' => 'GET',
232 'callback' => [$this, 'get_overview_data'],
233 'permission_callback' => [$this, 'check_permissions']
234 ]
235 ]
236 );
237 }
238
239 /**
240 * Get LLMs.txt settings
241 *
242 * @since 1.0.0
243 *
244 * @param WP_REST_Request $request Request object
245 * @return WP_REST_Response|WP_Error Response object, or the context error
246 */
247 public function get_settings(WP_REST_Request $request) {
248 try {
249 // SECURITY: the settings are stored per context, so the object has
250 // to be authorised before it is read (#385).
251 $context = $this->resolve_request_context($request);
252 if (is_wp_error($context)) {
253 return $context;
254 }
255 [$context_type, $context_id] = $context;
256
257 // Get settings from LLMs.txt Manager
258 $settings = $this->llms_txt_manager->get_settings($context_type, $context_id);
259
260 // Get settings schema for validation
261 $schema = $this->llms_txt_manager->get_settings_schema($context_type);
262
263 return new WP_REST_Response([
264 'success' => true,
265 'data' => [
266 'settings' => $settings,
267 'schema' => $schema,
268 'context_type' => $context_type,
269 'context_id' => $context_id
270 ],
271 'message' => 'LLMs.txt settings retrieved successfully'
272 ], 200);
273
274 } catch (\Exception $e) {
275 return new WP_REST_Response([
276 'success' => false,
277 'error' => 'Failed to retrieve LLMs.txt settings: ' . $e->getMessage()
278 ], 500);
279 }
280 }
281
282 /**
283 * Update LLMs.txt settings
284 *
285 * @since 1.0.0
286 *
287 * @param WP_REST_Request $request Request object
288 * @return WP_REST_Response|WP_Error Response object or error
289 */
290 public function update_settings(WP_REST_Request $request) {
291 try {
292 $settings = $request->get_param('settings');
293
294 // SECURITY: this write is keyed by the context, so the object has to
295 // be authorised before anything is persisted (#385).
296 $context = $this->resolve_request_context($request);
297 if (is_wp_error($context)) {
298 return $context;
299 }
300 [$context_type, $context_id] = $context;
301
302 // Validate settings
303 if (empty($settings) || !is_array($settings)) {
304 return new WP_Error(
305 'invalid_settings',
306 'Settings must be provided as an array',
307 ['status' => 400]
308 );
309 }
310
311 // Validate settings using LLMs.txt Manager
312 $validation = $this->llms_txt_manager->validate_settings($settings);
313
314 if (!$validation['valid']) {
315 return new WP_Error(
316 'validation_failed',
317 'Settings validation failed',
318 [
319 'status' => 400,
320 'validation_errors' => $validation['errors'],
321 'validation_warnings' => $validation['warnings']
322 ]
323 );
324 }
325
326 // Update settings
327 $update_result = $this->llms_txt_manager->save_settings($context_type, $context_id, $settings);
328
329 if (!$update_result) {
330 return new WP_Error(
331 'update_failed',
332 'Failed to update LLMs.txt settings',
333 ['status' => 500]
334 );
335 }
336
337 // The settings persisted, but a disable-save may have failed to remove
338 // the published llms.txt file. Report that explicitly so the caller
339 // knows /llms.txt might still be served rather than assuming success.
340 if ($this->llms_txt_manager->unpublish_failed()) {
341 return new WP_REST_Response([
342 'success' => true,
343 'file_unpublished' => false,
344 'data' => [
345 'settings' => $settings,
346 'validation' => $validation
347 ],
348 'message' => 'Settings were saved, but the published llms.txt file could not be removed and may still be served. Please remove it manually.'
349 ], 200);
350 }
351
352 // A delivery-mode switch that could not move the already-published
353 // document leaves /llms.txt on the old path; say so instead of
354 // reporting a clean save. A switch that worked but landed on a
355 // server that answers the file without a charset warns under its own
356 // key — reporting that one as a failed switch misdescribes it.
357 $delivery_warning = $this->llms_txt_manager->delivery_switch_warning();
358
359 if ('' !== $delivery_warning) {
360 $switch_failed = $this->llms_txt_manager->delivery_switch_failed();
361
362 return new WP_REST_Response([
363 'success' => true,
364 ($switch_failed ? 'delivery_switch_failed' : 'delivery_warning') => true,
365 'data' => [
366 'settings' => $settings,
367 'validation' => $validation
368 ],
369 'message' => $delivery_warning
370 ], 200);
371 }
372
373 return new WP_REST_Response([
374 'success' => true,
375 'data' => [
376 'settings' => $settings,
377 'validation' => $validation
378 ],
379 'message' => 'LLMs.txt settings updated successfully'
380 ], 200);
381
382 } catch (\Exception $e) {
383 return new WP_Error(
384 'update_failed',
385 'Failed to update LLMs.txt settings: ' . $e->getMessage(),
386 ['status' => 500]
387 );
388 }
389 }
390
391 /**
392 * Generate LLMs.txt file
393 *
394 * @since 1.0.0
395 *
396 * @param WP_REST_Request $request Request object
397 * @return WP_REST_Response|WP_Error Response object or error
398 */
399 public function generate_llms_txt(WP_REST_Request $request) {
400 try {
401 // Check rate limiting
402 if (!$this->check_llms_rate_limit()) {
403 return new WP_Error(
404 'rate_limit_exceeded',
405 'Too many requests. Please wait a few minutes before trying again.',
406 ['status' => 429]
407 );
408 }
409 $user_input = $request->get_param('user_input');
410 $options = $request->get_param('options') ?? [];
411 $save_to_file = $request->get_param('save_to_file') ?? true;
412
413 // Validate user input
414 if (empty($user_input) || !is_array($user_input)) {
415 return new WP_Error(
416 'invalid_input',
417 'User input must be provided as an array',
418 ['status' => 400]
419 );
420 }
421
422 // Validate required fields
423 $required_fields = ['website_description', 'key_features', 'target_audience'];
424 foreach ($required_fields as $field) {
425 if (empty($user_input[$field])) {
426 return new WP_Error(
427 'missing_field',
428 "Required field '{$field}' is missing or empty",
429 ['status' => 400]
430 );
431 }
432 }
433
434 // Sanitize user input
435 $sanitized_input = [
436 'site_name' => sanitize_text_field($user_input['site_name'] ?? ''),
437 'website_description' => sanitize_textarea_field($user_input['website_description']),
438 'key_features' => sanitize_textarea_field($user_input['key_features']),
439 'target_audience' => sanitize_text_field($user_input['target_audience']),
440 'business_type' => sanitize_text_field($user_input['business_type'] ?? 'website'),
441 'technical_stack' => sanitize_textarea_field($user_input['technical_stack'] ?? ''),
442 'development_approach' => sanitize_textarea_field($user_input['development_approach'] ?? ''),
443 'setup_instructions' => sanitize_textarea_field($user_input['setup_instructions'] ?? ''),
444 'ai_context_custom' => sanitize_textarea_field($user_input['ai_context_custom'] ?? ''),
445 'documentation_links' => $this->llms_txt_manager->sanitize_llms_content($user_input['documentation_links'] ?? ''),
446 'technical_links' => $this->llms_txt_manager->sanitize_llms_content($user_input['technical_links'] ?? ''),
447 'optional_links' => $this->llms_txt_manager->sanitize_llms_content($user_input['optional_links'] ?? ''),
448 'custom_sections' => $this->llms_txt_manager->sanitize_llms_content($user_input['custom_sections'] ?? '')
449 ];
450
451 // Generate LLMs.txt using LLMs.txt Manager
452 $generation_result = $this->llms_txt_manager->generate_llms_txt($sanitized_input, $options);
453
454 if (!$generation_result['validation']['valid']) {
455 return new WP_Error(
456 'generation_failed',
457 'LLMs.txt generation validation failed',
458 [
459 'status' => 400,
460 'validation_errors' => $generation_result['validation']['errors']
461 ]
462 );
463 }
464
465 // Write to file if requested
466 $file_write_result = ['success' => false, 'message' => 'File writing disabled'];
467 if ($save_to_file && !empty($generation_result['content'])) {
468 $file_write_result = $this->llms_txt_manager->write_llms_txt_to_file(
469 $generation_result['content']
470 );
471 }
472
473 return new WP_REST_Response([
474 'success' => true,
475 'data' => [
476 'content' => $generation_result['content'],
477 'sections' => $generation_result['sections'],
478 'metadata' => $generation_result['metadata'],
479 'validation' => $generation_result['validation'],
480 'file_info' => $generation_result['file_info'],
481 'file_write_result' => $file_write_result
482 ],
483 'message' => 'LLMs.txt generated successfully'
484 ], 200);
485
486 } catch (\Exception $e) {
487 return new WP_Error(
488 'generation_failed',
489 'LLMs.txt generation failed: ' . $e->getMessage(),
490 ['status' => 500]
491 );
492 }
493 }
494
495 /**
496 * AI optimize LLMs.txt content
497 *
498 * @since 1.0.0
499 *
500 * @param WP_REST_Request $request Request object
501 * @return WP_REST_Response|WP_Error Response object or error
502 */
503 public function ai_optimize_llms_txt(WP_REST_Request $request) {
504 try {
505 // Check rate limiting for AI operations
506 if (!$this->check_ai_rate_limit()) {
507 return new WP_Error(
508 'rate_limit_exceeded',
509 'Too many AI requests. Please wait a few minutes before trying again.',
510 ['status' => 429]
511 );
512 }
513
514 $website_data = $request->get_param('website_data');
515 $options = [
516 'business_type' => $request->get_param('business_type'),
517 'target_audience' => $request->get_param('target_audience'),
518 'tone' => $request->get_param('tone')
519 ];
520
521 // Validate website data
522 if (empty($website_data) || !is_array($website_data)) {
523 return new WP_Error(
524 'invalid_data',
525 'Website data must be provided as an array',
526 ['status' => 400]
527 );
528 }
529
530 // Validate required website data fields
531 $required_fields = ['website_description', 'key_features'];
532 foreach ($required_fields as $field) {
533 if (empty($website_data[$field])) {
534 return new WP_Error(
535 'missing_field',
536 "Required field '{$field}' is missing or empty",
537 ['status' => 400]
538 );
539 }
540 }
541
542 // Sanitize website data
543 $sanitized_website_data = [
544 'website_description' => sanitize_textarea_field($website_data['website_description']),
545 'key_features' => sanitize_textarea_field($website_data['key_features']),
546 'target_audience' => sanitize_text_field($website_data['target_audience'] ?? ''),
547 'business_type' => sanitize_text_field($website_data['business_type'] ?? 'website'),
548 'technical_stack' => sanitize_textarea_field($website_data['technical_stack'] ?? ''),
549 'development_approach' => sanitize_textarea_field($website_data['development_approach'] ?? '')
550 ];
551
552 // Sanitize options
553 $sanitized_options = [
554 'business_type' => sanitize_text_field($options['business_type'] ?? 'website'),
555 'target_audience' => sanitize_text_field($options['target_audience'] ?? 'general'),
556 'tone' => sanitize_text_field($options['tone'] ?? 'professional')
557 ];
558
559 // Get AI manager and perform optimization
560 $ai_manager = $this->get_ai_manager();
561 $optimization_results = $ai_manager->optimize_llms_txt($sanitized_website_data, $sanitized_options);
562
563 // Store optimization results in database (following Site Identity pattern)
564 $this->store_optimization_results($optimization_results);
565
566 return new WP_REST_Response([
567 'success' => true,
568 'data' => $optimization_results,
569 'message' => 'LLMs.txt AI optimization completed'
570 ], 200);
571
572 } catch (\Exception $e) {
573 return new WP_Error(
574 'ai_optimization_failed',
575 'AI optimization failed: ' . $e->getMessage(),
576 ['status' => 500]
577 );
578 }
579 }
580
581 /**
582 * Store optimization results in seo_analysis table (following Site Identity pattern)
583 *
584 * @since 1.0.0
585 *
586 * @param array $optimization Optimization results
587 * @return void
588 */
589 private function store_optimization_results(array $optimization): void {
590 global $wpdb;
591
592 $table_name = $wpdb->prefix . 'thinkrank_seo_analysis';
593
594 // Only store if we have meaningful results
595 if (empty($optimization['suggestions'])) {
596 return;
597 }
598
599 $analysis_type = 'llms_txt_ai_optimization';
600
601 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO analysis storage requires direct database access
602 $wpdb->insert(
603 $table_name,
604 [
605 'context_type' => 'site',
606 'context_id' => null,
607 'analysis_type' => $analysis_type,
608 'analysis_data' => wp_json_encode($optimization),
609 'score' => $optimization['score'] ?? 0,
610 'status' => 'completed',
611 'recommendations' => wp_json_encode($optimization['suggestions'] ?? []),
612 'analyzed_by' => get_current_user_id()
613 ],
614 ['%s', '%d', '%s', '%s', '%d', '%s', '%s', '%d']
615 );
616 }
617
618 /**
619 * Get optimization results history (Content Brief style)
620 *
621 * @since 1.0.0
622 *
623 * @param WP_REST_Request $request Request object
624 * @return WP_REST_Response|WP_Error Response object
625 */
626 public function get_optimization_results(WP_REST_Request $request) {
627 try {
628 global $wpdb;
629
630 $table_name = $wpdb->prefix . 'thinkrank_seo_analysis';
631 $limit = $request->get_param('limit') ?? 5; // Default to 5 recent results
632
633 // Get recent LLMs.txt optimization results
634 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO analysis retrieval requires direct database access
635 $sql = "SELECT analysis_data, recommendations, score, created_at
636 FROM {$table_name}
637 WHERE analysis_type = %s AND context_type = %s
638 ORDER BY created_at DESC
639 LIMIT %d";
640
641 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analysis data retrieval requires direct database access
642 $results = $wpdb->get_results(
643 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated with WordPress prefix, SQL is properly prepared
644 $wpdb->prepare($sql, 'llms_txt_ai_optimization', 'site', $limit)
645 );
646
647 if (!$results) {
648 return new WP_REST_Response([
649 'success' => true,
650 'data' => [],
651 'message' => 'No optimization results found'
652 ], 200);
653 }
654
655 $history = [];
656 foreach ($results as $result) {
657 $analysis_data = json_decode($result->analysis_data, true);
658 $suggestions = json_decode($result->recommendations, true);
659
660 $history[] = [
661 'suggestions' => $suggestions,
662 'provider' => $analysis_data['provider'] ?? '',
663 'model' => $analysis_data['model'] ?? $analysis_data['ai_model'] ?? '',
664 'score' => $result->score,
665 'created_at' => $result->created_at,
666 'formatted_date' => wp_date('M j, Y g:i A', strtotime($result->created_at))
667 ];
668 }
669
670 return new WP_REST_Response([
671 'success' => true,
672 'data' => $history,
673 'message' => 'Optimization results history retrieved'
674 ], 200);
675
676 } catch (\Exception $e) {
677 return new WP_Error(
678 'optimization_results_failed',
679 'Failed to retrieve optimization results: ' . $e->getMessage(),
680 ['status' => 500]
681 );
682 }
683 }
684
685 /**
686 * Get LLMs.txt file status
687 *
688 * @since 1.0.0
689 *
690 * @param WP_REST_Request $request Request object
691 * @return WP_REST_Response Response object
692 */
693 public function get_llms_txt_status(WP_REST_Request $request): WP_REST_Response {
694 try {
695 // Get file status using LLMs.txt Manager
696 $status = $this->llms_txt_manager->get_llms_txt_status();
697
698 return new WP_REST_Response([
699 'success' => true,
700 'data' => $status,
701 'message' => 'LLMs.txt status retrieved successfully'
702 ], 200);
703
704 } catch (\Exception $e) {
705 return new WP_REST_Response([
706 'success' => false,
707 'error' => 'Failed to retrieve LLMs.txt status: ' . $e->getMessage()
708 ], 500);
709 }
710 }
711
712 /**
713 * Validate LLMs.txt settings
714 *
715 * @since 1.0.0
716 *
717 * @param WP_REST_Request $request Request object
718 * @return WP_REST_Response Response object
719 */
720 public function validate_llms_txt_settings(WP_REST_Request $request): WP_REST_Response {
721 try {
722 $settings = $request->get_param('settings');
723
724 // Sanitize user input for validation
725 $sanitized_input = [
726 'site_name' => sanitize_text_field($settings['site_name'] ?? ''),
727 'website_description' => sanitize_textarea_field($settings['website_description'] ?? ''),
728 'key_features' => sanitize_textarea_field($settings['key_features'] ?? ''),
729 'target_audience' => sanitize_text_field($settings['target_audience'] ?? ''),
730 'business_type' => sanitize_text_field($settings['business_type'] ?? 'website'),
731 'technical_stack' => sanitize_textarea_field($settings['technical_stack'] ?? ''),
732 'development_approach' => sanitize_textarea_field($settings['development_approach'] ?? ''),
733 'setup_instructions' => sanitize_textarea_field($settings['setup_instructions'] ?? ''),
734 'documentation_links' => $this->llms_txt_manager->sanitize_llms_content($settings['documentation_links'] ?? ''),
735 'technical_links' => $this->llms_txt_manager->sanitize_llms_content($settings['technical_links'] ?? ''),
736 'optional_links' => $this->llms_txt_manager->sanitize_llms_content($settings['optional_links'] ?? ''),
737 'custom_sections' => $this->llms_txt_manager->sanitize_llms_content($settings['custom_sections'] ?? '')
738 ];
739
740 // Validate user input using LLMs.txt Manager
741 $validation = $this->llms_txt_manager->validate_llms_txt_input($sanitized_input);
742
743 return new WP_REST_Response([
744 'success' => true,
745 'data' => $validation,
746 'message' => 'LLMs.txt validation completed'
747 ], 200);
748
749 } catch (\Exception $e) {
750 return new WP_REST_Response([
751 'success' => false,
752 'error' => 'Validation failed: ' . $e->getMessage()
753 ], 500);
754 }
755 }
756
757 /**
758 * Permission callbacks
759 */
760
761 /**
762 * Check permissions for LLMs.txt operations (admin-only)
763 *
764 * @since 1.0.0
765 *
766 * @return bool Permission status
767 */
768 public function check_permissions(): bool {
769 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_crawling');
770 }
771 private function get_settings_args(): array {
772 return [
773 'settings' => [
774 'required' => true,
775 'type' => 'object',
776 'description' => 'LLMs.txt settings to update'
777 ],
778 'context_type' => [
779 'required' => false,
780 'type' => 'string',
781 'enum' => ['site'],
782 'default' => 'site',
783 'description' => 'Context type (site only)'
784 ],
785 'context_id' => [
786 'required' => false,
787 'type' => 'integer',
788 'minimum' => 1,
789 'description' => 'Context ID (not required for site context)'
790 ]
791 ];
792 }
793
794 /**
795 * Get arguments for generation endpoint
796 *
797 * @since 1.0.0
798 *
799 * @return array Arguments array
800 */
801 private function get_generation_args(): array {
802 return [
803 'user_input' => [
804 'required' => true,
805 'type' => 'object',
806 'description' => 'User input data for LLMs.txt generation',
807 'properties' => [
808 'website_description' => [
809 'type' => 'string',
810 'description' => 'Website description'
811 ],
812 'key_features' => [
813 'type' => 'string',
814 'description' => 'Key website features'
815 ],
816 'target_audience' => [
817 'type' => 'string',
818 'description' => 'Target audience'
819 ],
820 'business_type' => [
821 'type' => 'string',
822 'description' => 'Business type'
823 ],
824 'technical_stack' => [
825 'type' => 'string',
826 'description' => 'Technical stack information'
827 ]
828 ]
829 ],
830 'options' => [
831 'required' => false,
832 'type' => 'object',
833 'description' => 'Generation options'
834 ],
835 'save_to_file' => [
836 'required' => false,
837 'type' => 'boolean',
838 'default' => true,
839 'description' => 'Whether to save generated content to file'
840 ]
841 ];
842 }
843
844 /**
845 * Get arguments for AI optimization endpoint
846 *
847 * @since 1.0.0
848 *
849 * @return array Arguments array
850 */
851 private function get_ai_optimization_args(): array {
852 return [
853 'website_data' => [
854 'required' => true,
855 'type' => 'object',
856 'description' => 'Website data to optimize with AI',
857 'properties' => [
858 'website_description' => [
859 'type' => 'string',
860 'description' => 'Website description to optimize'
861 ],
862 'key_features' => [
863 'type' => 'string',
864 'description' => 'Key features to optimize'
865 ],
866 'target_audience' => [
867 'type' => 'string',
868 'description' => 'Target audience information'
869 ],
870 'business_type' => [
871 'type' => 'string',
872 'description' => 'Business type'
873 ]
874 ]
875 ],
876 'business_type' => [
877 'required' => false,
878 'type' => 'string',
879 'default' => 'website',
880 'sanitize_callback' => 'sanitize_text_field',
881 'description' => 'Type of business for context'
882 ],
883 'target_audience' => [
884 'required' => false,
885 'type' => 'string',
886 'default' => 'general',
887 'sanitize_callback' => 'sanitize_text_field',
888 'description' => 'Target audience for optimization'
889 ],
890 'tone' => [
891 'required' => false,
892 'type' => 'string',
893 'default' => 'professional',
894 'sanitize_callback' => 'sanitize_text_field',
895 'description' => 'Desired tone for optimization'
896 ]
897 ];
898 }
899
900 /**
901 * Get arguments for validation endpoint
902 *
903 * @since 1.0.0
904 *
905 * @return array Arguments array
906 */
907 private function get_validation_args(): array {
908 return [
909 'settings' => [
910 'required' => true,
911 'type' => 'object',
912 'description' => 'Settings to validate'
913 ]
914 ];
915 }
916
917 /**
918 * Get overview data (combined endpoint for performance)
919 *
920 * Combines settings, file status, and optimization history into a single API call
921 * to reduce initial load time and improve user experience.
922 *
923 * @since 1.0.0
924 *
925 * @param WP_REST_Request $request Request object
926 * @return WP_REST_Response Response object
927 */
928 public function get_overview_data(WP_REST_Request $request): WP_REST_Response {
929 try {
930 // Get all data in parallel to minimize processing time
931 $settings = $this->llms_txt_manager->get_settings('site');
932 $file_status = $this->llms_txt_manager->get_llms_txt_status();
933
934 // Get recent optimization history (limit to 5 for performance)
935 $optimization_history = $this->get_recent_optimization_history(5);
936
937 return new WP_REST_Response([
938 'success' => true,
939 'data' => [
940 'settings' => $settings,
941 'file_status' => $file_status,
942 'optimization_history' => $optimization_history
943 ]
944 ], 200);
945
946 } catch (\Exception $e) {
947 // Was `catch (Exception $e)` inside `namespace ThinkRank\API;` with
948 // no `use Exception;`, so it resolved to ThinkRank\API\Exception —
949 // a class that does not exist. The catch never matched and every
950 // exception escaped as a fatal. Every other catch in this file
951 // already uses the leading backslash (#394).
952 return new WP_REST_Response([
953 'success' => false,
954 'error' => 'Failed to load overview data: ' . $e->getMessage()
955 ], 500);
956 }
957 }
958
959 /**
960 * Get recent optimization history (optimized version)
961 *
962 * @since 1.0.0
963 *
964 * @param int $limit Number of results to return
965 * @return array Optimization history
966 */
967 private function get_recent_optimization_history(int $limit = 5): array {
968 global $wpdb;
969
970 $table_name = $wpdb->prefix . 'thinkrank_ai_usage';
971
972 // Optimized query with limit and specific fields only
973 $sql = "SELECT
974 id,
975 created_at,
976 tokens_used,
977 metadata
978 FROM {$table_name}
979 WHERE action = 'llms_txt_optimization'
980 ORDER BY created_at DESC
981 LIMIT %d";
982
983 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Usage analytics retrieval requires direct database access
984 $results = $wpdb->get_results(
985 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated with WordPress prefix, SQL is properly prepared
986 $wpdb->prepare($sql, $limit), ARRAY_A);
987
988 if (empty($results)) {
989 return [];
990 }
991
992 // Process results efficiently
993 return array_map(function($result) {
994 $metadata = json_decode($result['metadata'], true) ?? [];
995 return [
996 'id' => (int) $result['id'],
997 'created_at' => $result['created_at'],
998 'tokens_used' => (int) $result['tokens_used'],
999 'improvements_made' => $metadata['improvements_made'] ?? [],
1000 'suggestions' => $metadata['suggestions'] ?? []
1001 ];
1002 }, $results);
1003 }
1004
1005 /**
1006 * Check rate limit for LLMs.txt generation operations
1007 *
1008 * @since 1.0.0
1009 * @return bool True if within rate limit
1010 */
1011 private function check_llms_rate_limit(): bool {
1012 $user_id = get_current_user_id();
1013 $rate_key = "thinkrank_llms_rate_{$user_id}";
1014
1015 $requests = get_transient($rate_key) ?: 0;
1016
1017 if ($requests >= 3) { // Max 3 requests per 5 minutes
1018 return false;
1019 }
1020
1021 set_transient($rate_key, $requests + 1, 5 * MINUTE_IN_SECONDS);
1022 return true;
1023 }
1024
1025 /**
1026 * Check rate limit for AI optimization operations
1027 *
1028 * @since 1.0.0
1029 * @return bool True if within rate limit
1030 */
1031 private function check_ai_rate_limit(): bool {
1032 $user_id = get_current_user_id();
1033 $rate_key = "thinkrank_ai_rate_{$user_id}";
1034
1035 $requests = get_transient($rate_key) ?: 0;
1036
1037 if ($requests >= 2) { // Max 2 AI requests per 10 minutes (more restrictive)
1038 return false;
1039 }
1040
1041 set_transient($rate_key, $requests + 1, 10 * MINUTE_IN_SECONDS);
1042 return true;
1043 }
1044 }
1045