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-site-identity-endpoint.php

class-site-identity-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-site-identity-endpoint.php

1,321 lines 46.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Site Identity API Endpoints Class
4 *
5 * REST API endpoints for site identity management including title formats,
6 * breadcrumb configuration, robots.txt generation, AI-powered site identity
7 * optimization, and global SEO defaults. Provides comprehensive API access to
8 * Site Identity Manager and AI Manager functionality with proper authentication,
9 * validation, and error handling.
10 *
11 * @package ThinkRank
12 * @subpackage API
13 * @since 1.0.0
14 */
15
16 declare(strict_types=1);
17
18 namespace ThinkRank\API;
19
20 use ThinkRank\SEO\Site_Identity_Manager;
21 use ThinkRank\AI\Manager as AI_Manager;
22 use ThinkRank\API\Traits\CSRF_Protection;
23 use ThinkRank\API\Traits\Context_Authorization;
24 use WP_REST_Controller;
25 use WP_REST_Request;
26 use WP_REST_Response;
27 use WP_Error;
28
29 // Prevent direct access
30 if (!defined('ABSPATH')) {
31 exit;
32 }
33
34 // Load CSRF Protection trait
35 require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-csrf-protection.php';
36 require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-context-authorization.php';
37
38 /**
39 * Site Identity API Endpoints Class
40 *
41 * Provides REST API endpoints for site identity operations including
42 * title generation, breadcrumb management, robots.txt configuration,
43 * AI-powered site identity optimization, and rule-based optimization
44 * with proper authentication and validation.
45 *
46 * @since 1.0.0
47 */
48 class Site_Identity_Endpoint extends WP_REST_Controller {
49 use CSRF_Protection;
50 use Context_Authorization;
51
52 /**
53 * Site Identity Manager instance
54 *
55 * @since 1.0.0
56 * @var Site_Identity_Manager
57 */
58 private Site_Identity_Manager $identity_manager;
59
60 /**
61 * API namespace
62 *
63 * @since 1.0.0
64 * @var string
65 */
66 protected $namespace = 'thinkrank/v1';
67
68 /**
69 * API resource base
70 *
71 * @since 1.0.0
72 * @var string
73 */
74 protected $rest_base = 'site-identity';
75
76 /**
77 * AI Manager instance
78 *
79 * @since 1.0.0
80 * @var AI_Manager|null
81 */
82 private ?AI_Manager $ai_manager = null;
83
84 /**
85 * Constructor
86 *
87 * @since 1.0.0
88 */
89 public function __construct() {
90 // Ensure Site Identity Manager is loaded
91 if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) {
92 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-site-identity-manager.php';
93 }
94
95 $this->identity_manager = new Site_Identity_Manager();
96 }
97
98 /**
99 * Get AI Manager instance (lazy loading)
100 *
101 * @since 1.0.0
102 * @return AI_Manager
103 */
104 private function get_ai_manager(): AI_Manager {
105 if ($this->ai_manager === null) {
106 // Try to get from main plugin container first
107 $plugin_instance = \ThinkRank::get_instance();
108 $this->ai_manager = $plugin_instance->get_component('ai');
109
110 // Fallback to direct instantiation if container fails
111 if ($this->ai_manager === null) {
112 $this->ai_manager = new AI_Manager();
113 }
114 }
115
116 return $this->ai_manager;
117 }
118
119 /**
120 * Register API routes
121 *
122 * @since 1.0.0
123 */
124 public function register_routes(): void {
125 // Get site identity settings
126 register_rest_route(
127 $this->namespace,
128 '/' . $this->rest_base . '/settings',
129 [
130 [
131 'methods' => 'GET',
132 'callback' => [$this, 'get_settings'],
133 'permission_callback' => [$this, 'check_permissions'],
134 'args' => $this->get_context_route_args()
135 ],
136 [
137 'methods' => 'POST',
138 'callback' => [$this, 'update_settings'],
139 'permission_callback' => [$this, 'check_permissions'],
140 'args' => $this->get_settings_args()
141 ]
142 ]
143 );
144
145 // Generate title with template
146 register_rest_route(
147 $this->namespace,
148 '/' . $this->rest_base . '/title/generate',
149 [
150 [
151 'methods' => 'POST',
152 'callback' => [$this, 'generate_title'],
153 'permission_callback' => [$this, 'check_permissions'],
154 'args' => $this->get_title_generation_args()
155 ]
156 ]
157 );
158
159 // Get title templates
160 register_rest_route(
161 $this->namespace,
162 '/' . $this->rest_base . '/title/templates',
163 [
164 [
165 'methods' => 'GET',
166 'callback' => [$this, 'get_title_templates'],
167 'permission_callback' => [$this, 'check_permissions']
168 ]
169 ]
170 );
171
172 // Generate breadcrumbs
173 register_rest_route(
174 $this->namespace,
175 '/' . $this->rest_base . '/breadcrumbs/generate',
176 [
177 [
178 'methods' => 'POST',
179 'callback' => [$this, 'generate_breadcrumbs'],
180 'permission_callback' => [$this, 'check_permissions'],
181 'args' => $this->get_breadcrumb_generation_args()
182 ]
183 ]
184 );
185
186 // Get breadcrumb types
187 register_rest_route(
188 $this->namespace,
189 '/' . $this->rest_base . '/breadcrumbs/types',
190 [
191 [
192 'methods' => 'GET',
193 'callback' => [$this, 'get_breadcrumb_types'],
194 'permission_callback' => [$this, 'check_permissions']
195 ]
196 ]
197 );
198
199 // Robots.txt management
200 register_rest_route(
201 $this->namespace,
202 '/' . $this->rest_base . '/robots',
203 [
204 [
205 'methods' => 'GET',
206 'callback' => [$this, 'get_robots_txt'],
207 // Reading the robots.txt config is a Site Identity operation —
208 // gate it on the module cap, not the generic 'read' cap.
209 'permission_callback' => [$this, 'check_permissions']
210 ],
211 [
212 'methods' => 'POST',
213 'callback' => [$this, 'update_robots_txt'],
214 // Writing robots.txt to the webroot is site-wide — require the
215 // Site Identity management capability, not just edit_posts.
216 'permission_callback' => [$this, 'check_permissions'],
217 'args' => $this->get_robots_txt_args()
218 ]
219 ]
220 );
221
222 // Site identity optimization (rule-based)
223 register_rest_route(
224 $this->namespace,
225 '/' . $this->rest_base . '/optimize',
226 [
227 [
228 'methods' => 'POST',
229 'callback' => [$this, 'optimize_site_identity'],
230 'permission_callback' => [$this, 'check_permissions'],
231 'args' => $this->get_optimization_args()
232 ]
233 ]
234 );
235
236 // AI-powered site identity optimization
237 register_rest_route(
238 $this->namespace,
239 '/' . $this->rest_base . '/ai-optimize-info',
240 [
241 [
242 'methods' => 'POST',
243 'callback' => [$this, 'ai_optimize_site_info'],
244 'permission_callback' => [$this, 'check_permissions'],
245 'args' => $this->get_ai_optimization_args()
246 ]
247 ]
248 );
249
250 // AI-powered hero content optimization
251 register_rest_route(
252 $this->namespace,
253 '/' . $this->rest_base . '/ai-optimize-hero',
254 [
255 [
256 'methods' => 'POST',
257 'callback' => [$this, 'ai_optimize_hero_content'],
258 'permission_callback' => [$this, 'check_permissions'],
259 'args' => $this->get_hero_optimization_args()
260 ]
261 ]
262 );
263
264 // Validate site identity settings
265 register_rest_route(
266 $this->namespace,
267 '/' . $this->rest_base . '/validate',
268 [
269 [
270 'methods' => 'POST',
271 'callback' => [$this, 'validate_identity_settings'],
272 'permission_callback' => [$this, 'check_permissions'],
273 'args' => $this->get_validation_args()
274 ]
275 ]
276 );
277
278 }
279
280 /**
281 * Get site identity settings
282 *
283 * @since 1.0.0
284 *
285 * @param WP_REST_Request $request Request object
286 * @return WP_REST_Response|WP_Error Response object, or the context error
287 */
288 public function get_settings(WP_REST_Request $request) {
289 try {
290 // SECURITY: the settings are stored per context, so the object has
291 // to be authorised before it is read (#385).
292 $context = $this->resolve_request_context($request);
293 if (is_wp_error($context)) {
294 return $context;
295 }
296 [$context_type, $context_id] = $context;
297
298 // Get settings from Site Identity Manager
299 $settings = $this->identity_manager->get_settings($context_type, $context_id);
300
301 // Get settings schema for validation
302 $schema = $this->identity_manager->get_settings_schema($context_type);
303
304 return new WP_REST_Response([
305 'success' => true,
306 'data' => [
307 'settings' => $settings,
308 'schema' => $schema,
309 'context_type' => $context_type,
310 'context_id' => $context_id
311 ],
312 'message' => 'Site identity settings retrieved successfully'
313 ], 200);
314
315 } catch (\Exception $e) {
316 return new WP_REST_Response([
317 'success' => false,
318 'error' => 'Failed to retrieve settings: ' . $e->getMessage()
319 ], 500);
320 }
321 }
322
323 /**
324 * Update site identity settings
325 *
326 * @since 1.0.0
327 *
328 * @param WP_REST_Request $request Request object
329 * @return WP_REST_Response|WP_Error Response object or error
330 */
331 public function update_settings(WP_REST_Request $request) {
332 try {
333 $settings = $request->get_param('settings');
334
335 // SECURITY: this write is keyed by the context, so the object has to
336 // be authorised before anything is persisted (#385).
337 $context = $this->resolve_request_context($request);
338 if (is_wp_error($context)) {
339 return $context;
340 }
341 [$context_type, $context_id] = $context;
342
343 // Validate settings
344 if (empty($settings) || !is_array($settings)) {
345 return new WP_Error(
346 'invalid_settings',
347 'Settings must be provided as an array',
348 ['status' => 400]
349 );
350 }
351
352 // Validate settings using Site Identity Manager
353 $validation = $this->identity_manager->validate_settings($settings);
354
355 if (!$validation['valid']) {
356 return new WP_Error(
357 'validation_failed',
358 'Settings validation failed',
359 [
360 'status' => 400,
361 'validation_errors' => $validation['errors'],
362 'validation_warnings' => $validation['warnings']
363 ]
364 );
365 }
366
367 // Update settings
368 $update_result = $this->identity_manager->save_settings($context_type, $context_id, $settings);
369
370 if (!$update_result) {
371 return new WP_Error(
372 'update_failed',
373 $this->describe_save_failure('Failed to update site identity settings'),
374 [
375 'status' => 500,
376 'failure_code' => $this->identity_manager->get_last_save_error_code()
377 ]
378 );
379 }
380
381 // If this save changed the robots.txt content/toggle and a physical
382 // robots.txt exists, keep it in lockstep. The web server serves that
383 // static file directly (bypassing the robots_txt filter), so without
384 // this the file goes stale and /robots.txt shows the old content
385 // while the textarea shows the new — regardless of what the frontend
386 // believed about the file's existence.
387 if ($context_type === 'site'
388 && (array_key_exists('robots_txt_content', $settings)
389 || array_key_exists('robots_txt_enabled', $settings))
390 ) {
391 $this->identity_manager->sync_robots_txt_file();
392 }
393
394 // Get updated settings
395 $updated_settings = $this->identity_manager->get_settings($context_type, $context_id);
396
397 return new WP_REST_Response([
398 'success' => true,
399 'data' => [
400 'settings' => $updated_settings,
401 'validation' => $validation,
402 'context_type' => $context_type,
403 'context_id' => $context_id
404 ],
405 'message' => 'Site identity settings updated successfully'
406 ], 200);
407
408 } catch (\Throwable $e) {
409 return new WP_Error(
410 'update_failed',
411 'Settings update failed: ' . $e->getMessage(),
412 ['status' => 500]
413 );
414 }
415 }
416
417 /**
418 * Generate title using template
419 *
420 * @since 1.0.0
421 *
422 * @param WP_REST_Request $request Request object
423 * @return WP_REST_Response|WP_Error Response object or error
424 */
425 public function generate_title(WP_REST_Request $request) {
426 try {
427 $template_name = $request->get_param('template_name') ?? 'default';
428 $data = $request->get_param('data') ?? [];
429 $context = $request->get_param('context') ?? 'site';
430
431 // Generate title using Site Identity Manager
432 $generated_title = $this->identity_manager->generate_title($template_name, $data, $context);
433
434 // Get available templates for reference
435 $templates = $this->get_available_title_templates();
436
437 return new WP_REST_Response([
438 'success' => true,
439 'data' => [
440 'generated_title' => $generated_title,
441 'template_used' => $template_name,
442 'context' => $context,
443 'input_data' => $data,
444 'available_templates' => $templates
445 ],
446 'message' => 'Title generated successfully'
447 ], 200);
448
449 } catch (\Exception $e) {
450 return new WP_Error(
451 'title_generation_failed',
452 'Title generation failed: ' . $e->getMessage(),
453 ['status' => 500]
454 );
455 }
456 }
457
458 /**
459 * Get available title templates
460 *
461 * @since 1.0.0
462 *
463 * @param WP_REST_Request $request Request object
464 * @return WP_REST_Response Response object
465 */
466 public function get_title_templates(WP_REST_Request $request): WP_REST_Response {
467 $templates = $this->get_available_title_templates();
468
469 return new WP_REST_Response([
470 'success' => true,
471 'data' => [
472 'templates' => $templates,
473 'total_templates' => count($templates)
474 ],
475 'message' => 'Title templates retrieved successfully'
476 ], 200);
477 }
478
479 /**
480 * Generate breadcrumbs
481 *
482 * @since 1.0.0
483 *
484 * @param WP_REST_Request $request Request object
485 * @return WP_REST_Response|WP_Error Response object or error
486 */
487 public function generate_breadcrumbs(WP_REST_Request $request) {
488 try {
489 $breadcrumb_type = $request->get_param('breadcrumb_type') ?? 'hierarchical';
490 $options = $request->get_param('options') ?? [];
491
492 // Generate breadcrumbs using Site Identity Manager
493 $breadcrumbs = $this->identity_manager->generate_breadcrumbs($breadcrumb_type, $options);
494
495 // Get available breadcrumb types for reference
496 $types = $this->get_available_breadcrumb_types();
497
498 return new WP_REST_Response([
499 'success' => true,
500 'data' => [
501 'breadcrumbs' => $breadcrumbs,
502 'breadcrumb_type' => $breadcrumb_type,
503 'options' => $options,
504 'available_types' => $types
505 ],
506 'message' => 'Breadcrumbs generated successfully'
507 ], 200);
508
509 } catch (\Exception $e) {
510 return new WP_Error(
511 'breadcrumb_generation_failed',
512 'Breadcrumb generation failed: ' . $e->getMessage(),
513 ['status' => 500]
514 );
515 }
516 }
517
518 /**
519 * Get available breadcrumb types
520 *
521 * @since 1.0.0
522 *
523 * @param WP_REST_Request $request Request object
524 * @return WP_REST_Response Response object
525 */
526 public function get_breadcrumb_types(WP_REST_Request $request): WP_REST_Response {
527 $types = $this->get_available_breadcrumb_types();
528
529 return new WP_REST_Response([
530 'success' => true,
531 'data' => [
532 'breadcrumb_types' => $types,
533 'total_types' => count($types)
534 ],
535 'message' => 'Breadcrumb types retrieved successfully'
536 ], 200);
537 }
538
539 /**
540 * Get robots.txt configuration
541 *
542 * @since 1.0.0
543 *
544 * @param WP_REST_Request $request Request object
545 * @return WP_REST_Response Response object
546 */
547 public function get_robots_txt(WP_REST_Request $request): WP_REST_Response {
548 try {
549 $custom_rules = $request->get_param('custom_rules') ?? [];
550
551 // Generate robots.txt using Site Identity Manager
552 $robots_data = $this->identity_manager->generate_robots_txt($custom_rules);
553
554 // The editor shows the body that is actually being served (physical
555 // file if present, else the effective content) — header-stripped so
556 // the auto-generated comment/timestamp never lands in the textarea.
557 $robots_data['content'] = $this->identity_manager->get_served_robots_body();
558
559 // Keep `rules` describing that same body. generate_robots_txt()
560 // returned the rules it generated, which stopped matching `content`
561 // the moment a stored override or a physical file supplied it.
562 $robots_data['rules'] = $this->identity_manager->parse_robots_txt_rules($robots_data['content']);
563
564 // How /robots.txt is actually delivered right now, so the screen can
565 // show the served output next to the editable body and flag a
566 // physical file in the web root that has drifted from the settings.
567 $robots_data['effective'] = $this->identity_manager->get_robots_txt_delivery();
568
569 return new WP_REST_Response([
570 'success' => true,
571 'data' => $robots_data,
572 'message' => 'Robots.txt data retrieved successfully'
573 ], 200);
574
575 } catch (\Exception $e) {
576 return new WP_REST_Response([
577 'success' => false,
578 'error' => 'Failed to retrieve robots.txt: ' . $e->getMessage()
579 ], 500);
580 }
581 }
582
583 /**
584 * Update robots.txt configuration
585 *
586 * @since 1.0.0
587 *
588 * @param WP_REST_Request $request Request object
589 * @return WP_REST_Response|WP_Error Response object or error
590 */
591 public function update_robots_txt(WP_REST_Request $request) {
592 try {
593 // Check rate limiting
594 if (!$this->check_robots_rate_limit()) {
595 return new WP_Error(
596 'rate_limit_exceeded',
597 'Too many requests. Please wait a few minutes before trying again.',
598 ['status' => 429]
599 );
600 }
601 $custom_rules = $request->get_param('custom_rules') ?? [];
602 $enable_management = $request->get_param('enable_management') ?? true;
603
604 // Two callers share this route:
605 // - "Generate" rebuilds the content from rules and adopts it as the
606 // stored textarea content ($regenerate = true).
607 // - A plain save that only needs the physical file re-synced to the
608 // already-stored textarea content ($regenerate = false).
609 // Default to true so the historical Generate contract is unchanged.
610 $regenerate = $request->get_param('regenerate');
611 if ($regenerate === null) {
612 $regenerate = true;
613 }
614
615 // Validate custom rules format
616 if (!is_array($custom_rules)) {
617 return new WP_Error(
618 'invalid_rules',
619 'Custom rules must be provided as an array',
620 ['status' => 400]
621 );
622 }
623
624 $settings = ['robots_txt_enabled' => $enable_management];
625
626 if ($regenerate) {
627 // Generate and validate robots.txt from the rules.
628 $robots_data = $this->identity_manager->generate_robots_txt($custom_rules);
629
630 if (!empty($robots_data['validation']['errors'])) {
631 return new WP_Error(
632 'validation_failed',
633 'Robots.txt validation failed',
634 [
635 'status' => 400,
636 'validation_errors' => $robots_data['validation']['errors']
637 ]
638 );
639 }
640
641 // Adopt the freshly generated content as the stored source.
642 $settings['custom_robots_rules'] = $custom_rules;
643 $settings['robots_txt_content'] = $robots_data['content'];
644 }
645
646 $update_result = $this->identity_manager->save_settings('site', null, $settings);
647
648 if (!$update_result) {
649 return new WP_Error(
650 'update_failed',
651 $this->describe_save_failure('Failed to update robots.txt settings'),
652 [
653 'status' => 500,
654 'failure_code' => $this->identity_manager->get_last_save_error_code()
655 ]
656 );
657 }
658
659 // Effective content = the stored textarea content when set, else the
660 // auto-generated body. This is exactly what the live /robots.txt
661 // serves, so writing it here keeps the physical file in lockstep.
662 $effective_content = $this->identity_manager->render_robots_txt();
663
664 // Write robots.txt file to filesystem if management is enabled.
665 $file_write_result = ['success' => false, 'message' => 'File writing disabled'];
666 if ($enable_management && $effective_content !== '') {
667 $write_to_file = $request->get_param('write_to_file') ?? true;
668
669 if ($write_to_file) {
670 $file_write_result = $this->identity_manager->write_robots_txt(
671 $effective_content
672 );
673 }
674 }
675
676 $robots_data = $robots_data ?? [];
677 // Return the header-stripped body so the client textarea reflects
678 // exactly what it should hold (the header is added only at render).
679 $robots_data['content'] = $this->identity_manager->get_served_robots_body();
680 $robots_data['rules'] = $this->identity_manager->parse_robots_txt_rules($robots_data['content']);
681
682 // Re-read delivery after the write above so the screen reflects the
683 // file that now exists rather than the state it was in on load.
684 $robots_data['effective'] = $this->identity_manager->get_robots_txt_delivery();
685
686 return new WP_REST_Response([
687 'success' => true,
688 'data' => [
689 'robots_data' => $robots_data,
690 'settings_updated' => $settings,
691 'file_write_result' => $file_write_result
692 ],
693 'message' => $file_write_result['success']
694 ? 'Robots.txt configuration updated and file written successfully'
695 : 'Robots.txt configuration updated (file not written: ' . $file_write_result['message'] . ')'
696 ], 200);
697
698 } catch (\Throwable $e) {
699 return new WP_Error(
700 'update_failed',
701 'Robots.txt update failed: ' . $e->getMessage(),
702 ['status' => 500]
703 );
704 }
705 }
706
707 /**
708 * Optimize site identity (rule-based)
709 *
710 * @since 1.0.0
711 *
712 * @param WP_REST_Request $request Request object
713 * @return WP_REST_Response|WP_Error Response object or error
714 */
715 public function optimize_site_identity(WP_REST_Request $request) {
716 try {
717 $identity_data = $request->get_param('identity_data');
718 $options = $request->get_param('options') ?? [];
719
720 // Validate identity data
721 if (empty($identity_data) || !is_array($identity_data)) {
722 return new WP_Error(
723 'invalid_data',
724 'Identity data must be provided as an array',
725 ['status' => 400]
726 );
727 }
728
729 // Optimize site identity using Site Identity Manager with options
730 $optimization_results = $this->identity_manager->optimize_site_identity($identity_data, $options);
731
732 return new WP_REST_Response([
733 'success' => true,
734 'data' => $optimization_results,
735 'message' => 'Site identity optimization completed'
736 ], 200);
737
738 } catch (\Exception $e) {
739 return new WP_Error(
740 'optimization_failed',
741 'Site identity optimization failed: ' . $e->getMessage(),
742 ['status' => 500]
743 );
744 }
745 }
746
747 /**
748 * AI optimize site information
749 *
750 * @since 1.0.0
751 *
752 * @param WP_REST_Request $request Request object
753 * @return WP_REST_Response|WP_Error Response object or error
754 */
755 public function ai_optimize_site_info(WP_REST_Request $request) {
756 try {
757 $site_data = $request->get_param('site_data');
758 $options = [
759 'business_type' => $request->get_param('business_type'),
760 'target_audience' => $request->get_param('target_audience'),
761 'tone' => $request->get_param('tone')
762 ];
763
764 // Validate site data
765 if (empty($site_data) || !is_array($site_data)) {
766 return new WP_Error(
767 'invalid_data',
768 'Site data must be provided as an array',
769 ['status' => 400]
770 );
771 }
772
773 // Only the site name is required. An empty description or tagline
774 // is a valid state — and exactly when AI help is most useful — so
775 // let the optimizer generate them instead of rejecting the request.
776 if (empty($site_data['site_name'])) {
777 return new WP_Error(
778 'missing_field',
779 __('Site name is required to run AI optimization.', 'thinkrank'),
780 ['status' => 400]
781 );
782 }
783
784 // Sanitize site data (description/tagline may be empty by design)
785 $sanitized_site_data = [
786 'site_name' => sanitize_text_field($site_data['site_name']),
787 'site_description' => sanitize_textarea_field($site_data['site_description'] ?? ''),
788 'tagline' => sanitize_text_field($site_data['tagline'] ?? ''),
789 'default_meta_description' => sanitize_textarea_field($site_data['default_meta_description'] ?? '')
790 ];
791
792 // Sanitize options
793 $sanitized_options = [
794 'business_type' => sanitize_text_field($options['business_type'] ?? 'website'),
795 'target_audience' => sanitize_text_field($options['target_audience'] ?? 'general'),
796 'tone' => sanitize_text_field($options['tone'] ?? 'professional')
797 ];
798
799 // Get AI manager and perform optimization
800 $ai_manager = $this->get_ai_manager();
801 $optimization_results = $ai_manager->optimize_site_identity($sanitized_site_data, $sanitized_options);
802
803 return new WP_REST_Response([
804 'success' => true,
805 'data' => $optimization_results,
806 'message' => 'Site identity AI optimization completed'
807 ], 200);
808
809 } catch (\Exception $e) {
810 return new WP_Error(
811 'ai_optimization_failed',
812 'AI optimization failed: ' . $e->getMessage(),
813 ['status' => 500]
814 );
815 }
816 }
817
818 /**
819 * AI-powered hero content optimization
820 *
821 * @since 1.0.0
822 *
823 * @param WP_REST_Request $request Request object
824 * @return WP_REST_Response|WP_Error Response object or error
825 */
826 public function ai_optimize_hero_content(WP_REST_Request $request) {
827 try {
828 $hero_data = $request->get_param('hero_data');
829 $context = $request->get_param('context') ?? [];
830 $options = [
831 'business_type' => $request->get_param('business_type'),
832 'target_audience' => $request->get_param('target_audience'),
833 'tone' => $request->get_param('tone')
834 ];
835
836 // Validate hero data
837 if (empty($hero_data) || !is_array($hero_data)) {
838 return new WP_Error(
839 'invalid_data',
840 'Hero data must be provided as an array',
841 ['status' => 400]
842 );
843 }
844
845 // Sanitize hero data
846 $sanitized_hero_data = [
847 'hero_title' => sanitize_text_field($hero_data['hero_title'] ?? ''),
848 'hero_subtitle' => sanitize_textarea_field($hero_data['hero_subtitle'] ?? ''),
849 'hero_cta_text' => sanitize_text_field($hero_data['hero_cta_text'] ?? ''),
850 'hero_cta_url' => esc_url_raw($hero_data['hero_cta_url'] ?? '')
851 ];
852
853 // Sanitize context data
854 $sanitized_context = [
855 'site_name' => sanitize_text_field($context['site_name'] ?? ''),
856 'site_url' => esc_url_raw($context['site_url'] ?? ''),
857 'business_type' => sanitize_text_field($context['business_type'] ?? ''),
858 'site_description' => sanitize_textarea_field($context['site_description'] ?? '')
859 ];
860
861 // Sanitize options
862 $sanitized_options = [
863 'business_type' => sanitize_text_field($options['business_type'] ?? 'website'),
864 'target_audience' => sanitize_text_field($options['target_audience'] ?? 'general'),
865 'tone' => sanitize_text_field($options['tone'] ?? 'professional'),
866 'context' => $sanitized_context
867 ];
868
869 // Get AI manager and perform optimization
870 $ai_manager = $this->get_ai_manager();
871 $optimization_results = $ai_manager->optimize_homepage_hero($sanitized_hero_data, $sanitized_options);
872
873 return new WP_REST_Response([
874 'success' => true,
875 'data' => $optimization_results,
876 'message' => 'Hero content AI optimization completed'
877 ], 200);
878
879 } catch (\Exception $e) {
880 return new WP_Error(
881 'ai_optimization_failed',
882 'Hero AI optimization failed: ' . $e->getMessage(),
883 ['status' => 500]
884 );
885 }
886 }
887
888 /**
889 * Validate site identity settings
890 *
891 * @since 1.0.0
892 *
893 * @param WP_REST_Request $request Request object
894 * @return WP_REST_Response Response object
895 */
896 public function validate_identity_settings(WP_REST_Request $request): WP_REST_Response {
897 try {
898 $settings = $request->get_param('settings');
899 $tab_context = $request->get_param('tab_context');
900
901 // Validate settings using Site Identity Manager with tab context.
902 // `settings` is registered required, so REST rejects a missing value
903 // before this point and the old `?? []` fallback was unreachable.
904 $validation = $this->identity_manager->validate_settings($settings, $tab_context);
905
906 return new WP_REST_Response([
907 'success' => true,
908 'data' => $validation,
909 'message' => 'Settings validation completed'
910 ], 200);
911
912 } catch (\Exception $e) {
913 return new WP_REST_Response([
914 'success' => false,
915 'error' => 'Validation failed: ' . $e->getMessage()
916 ], 500);
917 }
918 }
919
920 /**
921 * Permission callbacks
922 */
923
924 /**
925 * Check permissions for site identity operations (admin-only)
926 *
927 * @since 1.0.0
928 *
929 * @return bool Permission status
930 */
931 public function check_permissions(): bool {
932 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_site_identity');
933 }
934 private function get_available_title_templates(): array {
935 return [
936 'default' => [
937 'name' => 'Default',
938 'template' => '%title% %separator% %sitename%',
939 'description' => 'Standard title format with site name'
940 ]
941 ];
942 }
943
944 /**
945 * Get available breadcrumb types
946 *
947 * @since 1.0.0
948 *
949 * @return array Breadcrumb types
950 */
951 private function get_available_breadcrumb_types(): array {
952 return [
953 'hierarchical' => [
954 'name' => 'Hierarchical',
955 'description' => 'Based on page hierarchy and categories'
956 ]
957 ];
958 }
959
960 /**
961 * Argument validation methods
962 */
963
964 /**
965 * Build a save-failure message that names the actual cause.
966 *
967 * The manager knows why the save failed — missing settings table, rejected
968 * INSERT with the MySQL error attached — and used to write that to the
969 * error log and throw it away, leaving the client a fixed string that told
970 * nobody anything. Append the reason so the response is diagnosable on its
971 * own. Status stays 500: a rejected INSERT is a server-side failure.
972 *
973 * @since 1.32.1
974 *
975 * @param string $fallback Message to use when no reason was recorded.
976 * @return string Failure message.
977 */
978 private function describe_save_failure(string $fallback): string {
979 $reason = $this->identity_manager->get_last_save_error();
980
981 return '' !== $reason ? $fallback . ': ' . $reason : $fallback;
982 }
983
984 /**
985 * Get arguments for settings endpoints
986 *
987 * @since 1.0.0
988 *
989 * @return array Arguments array
990 */
991 private function get_settings_args(): array {
992 return [
993 'settings' => [
994 'required' => true,
995 'type' => 'object',
996 'description' => 'Site identity settings to update'
997 ],
998 'context_type' => [
999 'required' => false,
1000 'type' => 'string',
1001 'enum' => ['site'],
1002 'default' => 'site',
1003 'description' => 'Context type (site only)'
1004 ],
1005 'context_id' => [
1006 'required' => false,
1007 'type' => 'integer',
1008 'minimum' => 1,
1009 'description' => 'Context ID (not required for site context)'
1010 ]
1011 ];
1012 }
1013
1014 /**
1015 * Get arguments for title generation endpoint
1016 *
1017 * @since 1.0.0
1018 *
1019 * @return array Arguments array
1020 */
1021 private function get_title_generation_args(): array {
1022 return [
1023 'template_name' => [
1024 'required' => false,
1025 'type' => 'string',
1026 // Must match get_available_title_templates(), which returns
1027 // 'default' and nothing else. The extra names advertised
1028 // templates the resolver has never been able to produce.
1029 'enum' => ['default'],
1030 'default' => 'default',
1031 'description' => 'Title template to use'
1032 ],
1033 'data' => [
1034 'required' => false,
1035 'type' => 'object',
1036 'description' => 'Data for placeholder replacement'
1037 ],
1038 'context' => [
1039 'required' => false,
1040 'type' => 'string',
1041 'enum' => ['site'],
1042 'default' => 'site',
1043 'description' => 'Context (site only)'
1044 ]
1045 ];
1046 }
1047
1048 /**
1049 * Get arguments for breadcrumb generation endpoint
1050 *
1051 * @since 1.0.0
1052 *
1053 * @return array Arguments array
1054 */
1055 private function get_breadcrumb_generation_args(): array {
1056 return [
1057 'breadcrumb_type' => [
1058 'required' => false,
1059 'type' => 'string',
1060 // Must match get_available_breadcrumb_types(), which returns
1061 // 'hierarchical' and nothing else.
1062 'enum' => ['hierarchical'],
1063 'default' => 'hierarchical',
1064 'description' => 'Type of breadcrumb navigation to generate'
1065 ],
1066 'options' => [
1067 'required' => false,
1068 'type' => 'object',
1069 'description' => 'Additional options for breadcrumb generation'
1070 ]
1071 ];
1072 }
1073
1074 /**
1075 * Get arguments for robots.txt endpoints
1076 *
1077 * @since 1.0.0
1078 *
1079 * @return array Arguments array
1080 */
1081 private function get_robots_txt_args(): array {
1082 return [
1083 'custom_rules' => [
1084 'required' => false,
1085 'type' => 'array',
1086 'items' => [
1087 'type' => 'object'
1088 ],
1089 'description' => 'Custom robots.txt rules'
1090 ],
1091 'enable_management' => [
1092 'required' => false,
1093 'type' => 'boolean',
1094 'default' => true,
1095 'description' => 'Enable automatic robots.txt management'
1096 ],
1097 'regenerate' => [
1098 'required' => false,
1099 'type' => 'boolean',
1100 'default' => true,
1101 'description' => 'Rebuild content from rules (Generate). When false, only re-sync the physical file to the stored content.'
1102 ],
1103 // The handler reads this and the route never declared it, so it
1104 // arrived as whatever string the client sent. RobotsManagement.js
1105 // sends it, and "false" is a non-empty string — truthy — so the
1106 // file was written when the caller had asked it not to be. ("0" is
1107 // falsy, which is why the failure was asymmetric.) Registering it
1108 // gets core's boolean coercion (#394).
1109 'write_to_file' => [
1110 'required' => false,
1111 'type' => 'boolean',
1112 'default' => true,
1113 'description' => 'Write the generated content to the physical robots.txt file.'
1114 ]
1115 ];
1116 }
1117
1118 /**
1119 * Get arguments for optimization endpoint
1120 *
1121 * @since 1.0.0
1122 *
1123 * @return array Arguments array
1124 */
1125 private function get_optimization_args(): array {
1126 return [
1127 'identity_data' => [
1128 'required' => true,
1129 'type' => 'object',
1130 'description' => 'Site identity data to optimize'
1131 ],
1132 // Read by optimize_site_identity(); previously unregistered, so it
1133 // never appeared in the published schema.
1134 'options' => [
1135 'required' => false,
1136 'type' => 'object',
1137 'default' => [],
1138 'description' => 'Additional optimization options'
1139 ]
1140 ];
1141 }
1142
1143 /**
1144 * Get arguments for AI optimization endpoint
1145 *
1146 * @since 1.0.0
1147 *
1148 * @return array Arguments array
1149 */
1150 private function get_ai_optimization_args(): array {
1151 return [
1152 'site_data' => [
1153 'required' => true,
1154 'type' => 'object',
1155 'description' => 'Site identity data to optimize with AI',
1156 'properties' => [
1157 'site_name' => [
1158 'type' => 'string',
1159 'description' => 'Site name to optimize'
1160 ],
1161 'site_description' => [
1162 'type' => 'string',
1163 'description' => 'Site description to optimize'
1164 ],
1165 'tagline' => [
1166 'type' => 'string',
1167 'description' => 'Site tagline to optimize'
1168 ]
1169 ]
1170 ],
1171 'business_type' => [
1172 'required' => false,
1173 'type' => 'string',
1174 'default' => 'website',
1175 'sanitize_callback' => 'sanitize_text_field',
1176 'description' => 'Type of business for context'
1177 ],
1178 'target_audience' => [
1179 'required' => false,
1180 'type' => 'string',
1181 'default' => 'general',
1182 'sanitize_callback' => 'sanitize_text_field',
1183 'description' => 'Target audience for optimization'
1184 ],
1185 'tone' => [
1186 'required' => false,
1187 'type' => 'string',
1188 'default' => 'professional',
1189 'sanitize_callback' => 'sanitize_text_field',
1190 'description' => 'Desired tone for optimization'
1191 ]
1192 ];
1193 }
1194
1195 /**
1196 * Get arguments for hero AI optimization endpoint
1197 *
1198 * @since 1.0.0
1199 *
1200 * @return array Arguments array
1201 */
1202 private function get_hero_optimization_args(): array {
1203 return [
1204 'hero_data' => [
1205 'required' => true,
1206 'type' => 'object',
1207 'description' => 'Hero content data to optimize with AI',
1208 'properties' => [
1209 'hero_title' => [
1210 'type' => 'string',
1211 'description' => 'Hero section title'
1212 ],
1213 'hero_subtitle' => [
1214 'type' => 'string',
1215 'description' => 'Hero section subtitle'
1216 ],
1217 'hero_cta_text' => [
1218 'type' => 'string',
1219 'description' => 'Call-to-action button text'
1220 ],
1221 'hero_cta_url' => [
1222 'type' => 'string',
1223 'description' => 'Call-to-action button URL'
1224 ]
1225 ]
1226 ],
1227 'context' => [
1228 'required' => false,
1229 'type' => 'object',
1230 'description' => 'Additional context for optimization',
1231 'properties' => [
1232 'site_name' => [
1233 'type' => 'string',
1234 'description' => 'Site name for context'
1235 ],
1236 'site_url' => [
1237 'type' => 'string',
1238 'description' => 'Site URL for context'
1239 ],
1240 'business_type' => [
1241 'type' => 'string',
1242 'description' => 'Business type for context'
1243 ],
1244 'site_description' => [
1245 'type' => 'string',
1246 'description' => 'Site description for context'
1247 ]
1248 ]
1249 ],
1250 'business_type' => [
1251 'required' => false,
1252 'type' => 'string',
1253 'default' => 'website',
1254 'sanitize_callback' => 'sanitize_text_field',
1255 'description' => 'Type of business for context'
1256 ],
1257 'target_audience' => [
1258 'required' => false,
1259 'type' => 'string',
1260 'default' => 'general',
1261 'sanitize_callback' => 'sanitize_text_field',
1262 'description' => 'Target audience for optimization'
1263 ],
1264 'tone' => [
1265 'required' => false,
1266 'type' => 'string',
1267 'default' => 'professional',
1268 'sanitize_callback' => 'sanitize_text_field',
1269 'description' => 'Desired tone for optimization'
1270 ]
1271 ];
1272 }
1273
1274 /**
1275 * Get arguments for validation endpoint
1276 *
1277 * @since 1.0.0
1278 *
1279 * @return array Arguments array
1280 */
1281 private function get_validation_args(): array {
1282 return [
1283 'settings' => [
1284 'required' => true,
1285 'type' => 'object',
1286 'description' => 'Settings to validate'
1287 ],
1288 // Read by validate_identity_settings() to scope validation to one
1289 // tab; it was never registered, so it was absent from the published
1290 // schema and got no type or sanitization.
1291 'tab_context' => [
1292 'required' => false,
1293 'type' => 'string',
1294 'default' => '',
1295 'sanitize_callback' => 'sanitize_key',
1296 'description' => 'Limit validation to a single settings tab'
1297 ]
1298 ];
1299 }
1300
1301 /**
1302 * Check rate limit for robots.txt operations
1303 *
1304 * @since 1.0.0
1305 * @return bool True if within rate limit
1306 */
1307 private function check_robots_rate_limit(): bool {
1308 $user_id = get_current_user_id();
1309 $rate_key = "thinkrank_robots_rate_{$user_id}";
1310
1311 $requests = get_transient($rate_key) ?: 0;
1312
1313 if ($requests >= 20) { // Max 20 requests per 5 minutes
1314 return false;
1315 }
1316
1317 set_transient($rate_key, $requests + 1, 5 * MINUTE_IN_SECONDS);
1318 return true;
1319 }
1320 }
1321