PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 / 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.7.0, at includes/api/class-site-identity-endpoint.php

1,361 lines 48.4 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 //
388 // ai_crawler_rules counts as a robots.txt change even though it is
389 // not the body: the directives it produces are composed into the
390 // served output at render time, so a physical file left alone here
391 // would keep serving the previous allow/block set (#657).
392 if ($context_type === 'site'
393 && (array_key_exists('robots_txt_content', $settings)
394 || array_key_exists('robots_txt_enabled', $settings)
395 || array_key_exists('ai_crawler_rules', $settings))
396 ) {
397 $this->identity_manager->sync_robots_txt_file();
398 }
399
400 // Get updated settings
401 $updated_settings = $this->identity_manager->get_settings($context_type, $context_id);
402
403 return new WP_REST_Response([
404 'success' => true,
405 'data' => [
406 'settings' => $updated_settings,
407 'validation' => $validation,
408 'context_type' => $context_type,
409 'context_id' => $context_id
410 ],
411 'message' => 'Site identity settings updated successfully'
412 ], 200);
413
414 } catch (\Throwable $e) {
415 return new WP_Error(
416 'update_failed',
417 'Settings update failed: ' . $e->getMessage(),
418 ['status' => 500]
419 );
420 }
421 }
422
423 /**
424 * Generate title using template
425 *
426 * @since 1.0.0
427 *
428 * @param WP_REST_Request $request Request object
429 * @return WP_REST_Response|WP_Error Response object or error
430 */
431 public function generate_title(WP_REST_Request $request) {
432 try {
433 $template_name = $request->get_param('template_name') ?? 'default';
434 $data = $request->get_param('data') ?? [];
435 $context = $request->get_param('context') ?? 'site';
436
437 // Generate title using Site Identity Manager
438 $generated_title = $this->identity_manager->generate_title($template_name, $data, $context);
439
440 // Get available templates for reference
441 $templates = $this->get_available_title_templates();
442
443 return new WP_REST_Response([
444 'success' => true,
445 'data' => [
446 'generated_title' => $generated_title,
447 'template_used' => $template_name,
448 'context' => $context,
449 'input_data' => $data,
450 'available_templates' => $templates
451 ],
452 'message' => 'Title generated successfully'
453 ], 200);
454
455 } catch (\Exception $e) {
456 return new WP_Error(
457 'title_generation_failed',
458 'Title generation failed: ' . $e->getMessage(),
459 ['status' => 500]
460 );
461 }
462 }
463
464 /**
465 * Get available title templates
466 *
467 * @since 1.0.0
468 *
469 * @param WP_REST_Request $request Request object
470 * @return WP_REST_Response Response object
471 */
472 public function get_title_templates(WP_REST_Request $request): WP_REST_Response {
473 $templates = $this->get_available_title_templates();
474
475 return new WP_REST_Response([
476 'success' => true,
477 'data' => [
478 'templates' => $templates,
479 'total_templates' => count($templates)
480 ],
481 'message' => 'Title templates retrieved successfully'
482 ], 200);
483 }
484
485 /**
486 * Generate breadcrumbs
487 *
488 * @since 1.0.0
489 *
490 * @param WP_REST_Request $request Request object
491 * @return WP_REST_Response|WP_Error Response object or error
492 */
493 public function generate_breadcrumbs(WP_REST_Request $request) {
494 try {
495 $breadcrumb_type = $request->get_param('breadcrumb_type') ?? 'hierarchical';
496 $options = $request->get_param('options') ?? [];
497
498 // Generate breadcrumbs using Site Identity Manager
499 $breadcrumbs = $this->identity_manager->generate_breadcrumbs($breadcrumb_type, $options);
500
501 // Get available breadcrumb types for reference
502 $types = $this->get_available_breadcrumb_types();
503
504 return new WP_REST_Response([
505 'success' => true,
506 'data' => [
507 'breadcrumbs' => $breadcrumbs,
508 'breadcrumb_type' => $breadcrumb_type,
509 'options' => $options,
510 'available_types' => $types
511 ],
512 'message' => 'Breadcrumbs generated successfully'
513 ], 200);
514
515 } catch (\Exception $e) {
516 return new WP_Error(
517 'breadcrumb_generation_failed',
518 'Breadcrumb generation failed: ' . $e->getMessage(),
519 ['status' => 500]
520 );
521 }
522 }
523
524 /**
525 * Get available breadcrumb types
526 *
527 * @since 1.0.0
528 *
529 * @param WP_REST_Request $request Request object
530 * @return WP_REST_Response Response object
531 */
532 public function get_breadcrumb_types(WP_REST_Request $request): WP_REST_Response {
533 $types = $this->get_available_breadcrumb_types();
534
535 return new WP_REST_Response([
536 'success' => true,
537 'data' => [
538 'breadcrumb_types' => $types,
539 'total_types' => count($types)
540 ],
541 'message' => 'Breadcrumb types retrieved successfully'
542 ], 200);
543 }
544
545 /**
546 * Get robots.txt configuration
547 *
548 * @since 1.0.0
549 *
550 * @param WP_REST_Request $request Request object
551 * @return WP_REST_Response Response object
552 */
553 public function get_robots_txt(WP_REST_Request $request): WP_REST_Response {
554 try {
555 $custom_rules = $request->get_param('custom_rules') ?? [];
556
557 // Generate robots.txt using Site Identity Manager
558 $robots_data = $this->identity_manager->generate_robots_txt($custom_rules);
559
560 // The editor shows the body that is actually being served (physical
561 // file if present, else the effective content) — header-stripped so
562 // the auto-generated comment/timestamp never lands in the textarea.
563 $robots_data['content'] = $this->identity_manager->get_served_robots_body();
564
565 // Keep `rules` describing that same body. generate_robots_txt()
566 // returned the rules it generated, which stopped matching `content`
567 // the moment a stored override or a physical file supplied it.
568 $robots_data['rules'] = $this->identity_manager->parse_robots_txt_rules($robots_data['content']);
569
570 // How /robots.txt is actually delivered right now, so the screen can
571 // show the served output next to the editable body and flag a
572 // physical file in the web root that has drifted from the settings.
573 $robots_data['effective'] = $this->identity_manager->get_robots_txt_delivery();
574
575 // The per-agent AI crawler surface (#657). The registry ships with
576 // the response rather than being duplicated in the bundle, so a
577 // crawler added by the `thinkrank_ai_crawlers` filter appears in
578 // the UI without a rebuild. Rules are returned normalised, so a
579 // crawler with nothing stored comes back explicitly allowed rather
580 // than as an absence the client has to interpret.
581 $settings = $this->identity_manager->get_settings('site');
582 $rules = \ThinkRank\SEO\AI_Crawlers::normalize_rules($settings['ai_crawler_rules'] ?? []);
583
584 $robots_data['ai_crawlers'] = \ThinkRank\SEO\AI_Crawlers::for_display();
585 $robots_data['ai_crawler_rules'] = [];
586
587 foreach ($robots_data['ai_crawlers'] as $agent) {
588 $robots_data['ai_crawler_rules'][$agent['slug']] = $rules[$agent['slug']] ?? 'allow';
589 }
590
591 return new WP_REST_Response([
592 'success' => true,
593 'data' => $robots_data,
594 'message' => 'Robots.txt data retrieved successfully'
595 ], 200);
596
597 } catch (\Exception $e) {
598 return new WP_REST_Response([
599 'success' => false,
600 'error' => 'Failed to retrieve robots.txt: ' . $e->getMessage()
601 ], 500);
602 }
603 }
604
605 /**
606 * Update robots.txt configuration
607 *
608 * @since 1.0.0
609 *
610 * @param WP_REST_Request $request Request object
611 * @return WP_REST_Response|WP_Error Response object or error
612 */
613 public function update_robots_txt(WP_REST_Request $request) {
614 try {
615 // Check rate limiting
616 if (!$this->check_robots_rate_limit()) {
617 return new WP_Error(
618 'rate_limit_exceeded',
619 'Too many requests. Please wait a few minutes before trying again.',
620 ['status' => 429]
621 );
622 }
623 $custom_rules = $request->get_param('custom_rules') ?? [];
624 $enable_management = $request->get_param('enable_management') ?? true;
625
626 // Two callers share this route:
627 // - "Generate" rebuilds the content from rules and adopts it as the
628 // stored textarea content ($regenerate = true).
629 // - A plain save that only needs the physical file re-synced to the
630 // already-stored textarea content ($regenerate = false).
631 // Default to true so the historical Generate contract is unchanged.
632 $regenerate = $request->get_param('regenerate');
633 if ($regenerate === null) {
634 $regenerate = true;
635 }
636
637 // Validate custom rules format
638 if (!is_array($custom_rules)) {
639 return new WP_Error(
640 'invalid_rules',
641 'Custom rules must be provided as an array',
642 ['status' => 400]
643 );
644 }
645
646 $settings = ['robots_txt_enabled' => $enable_management];
647
648 // Per-agent AI crawler rules (#657). Only written when the caller
649 // sends them: this route is also the plain "re-sync the file" save,
650 // and defaulting a missing parameter to an empty map there would
651 // unblock every crawler the site had blocked.
652 $ai_rules = $request->get_param('ai_crawler_rules');
653 if (null !== $ai_rules) {
654 $settings['ai_crawler_rules'] = \ThinkRank\SEO\AI_Crawlers::normalize_rules($ai_rules);
655 }
656
657 if ($regenerate) {
658 // Generate and validate robots.txt from the rules.
659 $robots_data = $this->identity_manager->generate_robots_txt($custom_rules);
660
661 if (!empty($robots_data['validation']['errors'])) {
662 return new WP_Error(
663 'validation_failed',
664 'Robots.txt validation failed',
665 [
666 'status' => 400,
667 'validation_errors' => $robots_data['validation']['errors']
668 ]
669 );
670 }
671
672 // Adopt the freshly generated content as the stored source.
673 $settings['custom_robots_rules'] = $custom_rules;
674 $settings['robots_txt_content'] = $robots_data['content'];
675 }
676
677 $update_result = $this->identity_manager->save_settings('site', null, $settings);
678
679 if (!$update_result) {
680 return new WP_Error(
681 'update_failed',
682 $this->describe_save_failure('Failed to update robots.txt settings'),
683 [
684 'status' => 500,
685 'failure_code' => $this->identity_manager->get_last_save_error_code()
686 ]
687 );
688 }
689
690 // Effective content = the stored textarea content when set, else the
691 // auto-generated body. This is exactly what the live /robots.txt
692 // serves, so writing it here keeps the physical file in lockstep.
693 $effective_content = $this->identity_manager->render_robots_txt();
694
695 // Write robots.txt file to filesystem if management is enabled.
696 $file_write_result = ['success' => false, 'message' => 'File writing disabled'];
697 if ($enable_management && $effective_content !== '') {
698 $write_to_file = $request->get_param('write_to_file') ?? true;
699
700 if ($write_to_file) {
701 $file_write_result = $this->identity_manager->write_robots_txt(
702 $effective_content
703 );
704 }
705 }
706
707 $robots_data = $robots_data ?? [];
708 // Return the header-stripped body so the client textarea reflects
709 // exactly what it should hold (the header is added only at render).
710 $robots_data['content'] = $this->identity_manager->get_served_robots_body();
711 $robots_data['rules'] = $this->identity_manager->parse_robots_txt_rules($robots_data['content']);
712
713 // Re-read delivery after the write above so the screen reflects the
714 // file that now exists rather than the state it was in on load.
715 $robots_data['effective'] = $this->identity_manager->get_robots_txt_delivery();
716
717 return new WP_REST_Response([
718 'success' => true,
719 'data' => [
720 'robots_data' => $robots_data,
721 'settings_updated' => $settings,
722 'file_write_result' => $file_write_result
723 ],
724 'message' => $file_write_result['success']
725 ? 'Robots.txt configuration updated and file written successfully'
726 : 'Robots.txt configuration updated (file not written: ' . $file_write_result['message'] . ')'
727 ], 200);
728
729 } catch (\Throwable $e) {
730 return new WP_Error(
731 'update_failed',
732 'Robots.txt update failed: ' . $e->getMessage(),
733 ['status' => 500]
734 );
735 }
736 }
737
738 /**
739 * Optimize site identity (rule-based)
740 *
741 * @since 1.0.0
742 *
743 * @param WP_REST_Request $request Request object
744 * @return WP_REST_Response|WP_Error Response object or error
745 */
746 public function optimize_site_identity(WP_REST_Request $request) {
747 try {
748 $identity_data = $request->get_param('identity_data');
749 $options = $request->get_param('options') ?? [];
750
751 // Validate identity data
752 if (empty($identity_data) || !is_array($identity_data)) {
753 return new WP_Error(
754 'invalid_data',
755 'Identity data must be provided as an array',
756 ['status' => 400]
757 );
758 }
759
760 // Optimize site identity using Site Identity Manager with options
761 $optimization_results = $this->identity_manager->optimize_site_identity($identity_data, $options);
762
763 return new WP_REST_Response([
764 'success' => true,
765 'data' => $optimization_results,
766 'message' => 'Site identity optimization completed'
767 ], 200);
768
769 } catch (\Exception $e) {
770 return new WP_Error(
771 'optimization_failed',
772 'Site identity optimization failed: ' . $e->getMessage(),
773 ['status' => 500]
774 );
775 }
776 }
777
778 /**
779 * AI optimize site information
780 *
781 * @since 1.0.0
782 *
783 * @param WP_REST_Request $request Request object
784 * @return WP_REST_Response|WP_Error Response object or error
785 */
786 public function ai_optimize_site_info(WP_REST_Request $request) {
787 try {
788 $site_data = $request->get_param('site_data');
789 $options = [
790 'business_type' => $request->get_param('business_type'),
791 'target_audience' => $request->get_param('target_audience'),
792 'tone' => $request->get_param('tone')
793 ];
794
795 // Validate site data
796 if (empty($site_data) || !is_array($site_data)) {
797 return new WP_Error(
798 'invalid_data',
799 'Site data must be provided as an array',
800 ['status' => 400]
801 );
802 }
803
804 // Only the site name is required. An empty description or tagline
805 // is a valid state — and exactly when AI help is most useful — so
806 // let the optimizer generate them instead of rejecting the request.
807 if (empty($site_data['site_name'])) {
808 return new WP_Error(
809 'missing_field',
810 __('Site name is required to run AI optimization.', 'thinkrank'),
811 ['status' => 400]
812 );
813 }
814
815 // Sanitize site data (description/tagline may be empty by design)
816 $sanitized_site_data = [
817 'site_name' => sanitize_text_field($site_data['site_name']),
818 'site_description' => sanitize_textarea_field($site_data['site_description'] ?? ''),
819 'tagline' => sanitize_text_field($site_data['tagline'] ?? ''),
820 'default_meta_description' => sanitize_textarea_field($site_data['default_meta_description'] ?? '')
821 ];
822
823 // Sanitize options
824 $sanitized_options = [
825 'business_type' => sanitize_text_field($options['business_type'] ?? 'website'),
826 'target_audience' => sanitize_text_field($options['target_audience'] ?? 'general'),
827 'tone' => sanitize_text_field($options['tone'] ?? 'professional')
828 ];
829
830 // Get AI manager and perform optimization
831 $ai_manager = $this->get_ai_manager();
832 $optimization_results = $ai_manager->optimize_site_identity($sanitized_site_data, $sanitized_options);
833
834 return new WP_REST_Response([
835 'success' => true,
836 'data' => $optimization_results,
837 'message' => 'Site identity AI optimization completed'
838 ], 200);
839
840 } catch (\Exception $e) {
841 return new WP_Error(
842 'ai_optimization_failed',
843 'AI optimization failed: ' . $e->getMessage(),
844 ['status' => 500]
845 );
846 }
847 }
848
849 /**
850 * AI-powered hero content optimization
851 *
852 * @since 1.0.0
853 *
854 * @param WP_REST_Request $request Request object
855 * @return WP_REST_Response|WP_Error Response object or error
856 */
857 public function ai_optimize_hero_content(WP_REST_Request $request) {
858 try {
859 $hero_data = $request->get_param('hero_data');
860 $context = $request->get_param('context') ?? [];
861 $options = [
862 'business_type' => $request->get_param('business_type'),
863 'target_audience' => $request->get_param('target_audience'),
864 'tone' => $request->get_param('tone')
865 ];
866
867 // Validate hero data
868 if (empty($hero_data) || !is_array($hero_data)) {
869 return new WP_Error(
870 'invalid_data',
871 'Hero data must be provided as an array',
872 ['status' => 400]
873 );
874 }
875
876 // Sanitize hero data
877 $sanitized_hero_data = [
878 'hero_title' => sanitize_text_field($hero_data['hero_title'] ?? ''),
879 'hero_subtitle' => sanitize_textarea_field($hero_data['hero_subtitle'] ?? ''),
880 'hero_cta_text' => sanitize_text_field($hero_data['hero_cta_text'] ?? ''),
881 'hero_cta_url' => esc_url_raw($hero_data['hero_cta_url'] ?? '')
882 ];
883
884 // Sanitize context data
885 $sanitized_context = [
886 'site_name' => sanitize_text_field($context['site_name'] ?? ''),
887 'site_url' => esc_url_raw($context['site_url'] ?? ''),
888 'business_type' => sanitize_text_field($context['business_type'] ?? ''),
889 'site_description' => sanitize_textarea_field($context['site_description'] ?? '')
890 ];
891
892 // Sanitize options
893 $sanitized_options = [
894 'business_type' => sanitize_text_field($options['business_type'] ?? 'website'),
895 'target_audience' => sanitize_text_field($options['target_audience'] ?? 'general'),
896 'tone' => sanitize_text_field($options['tone'] ?? 'professional'),
897 'context' => $sanitized_context
898 ];
899
900 // Get AI manager and perform optimization
901 $ai_manager = $this->get_ai_manager();
902 $optimization_results = $ai_manager->optimize_homepage_hero($sanitized_hero_data, $sanitized_options);
903
904 return new WP_REST_Response([
905 'success' => true,
906 'data' => $optimization_results,
907 'message' => 'Hero content AI optimization completed'
908 ], 200);
909
910 } catch (\Exception $e) {
911 return new WP_Error(
912 'ai_optimization_failed',
913 'Hero AI optimization failed: ' . $e->getMessage(),
914 ['status' => 500]
915 );
916 }
917 }
918
919 /**
920 * Validate site identity settings
921 *
922 * @since 1.0.0
923 *
924 * @param WP_REST_Request $request Request object
925 * @return WP_REST_Response Response object
926 */
927 public function validate_identity_settings(WP_REST_Request $request): WP_REST_Response {
928 try {
929 $settings = $request->get_param('settings');
930 $tab_context = $request->get_param('tab_context');
931
932 // Validate settings using Site Identity Manager with tab context.
933 // `settings` is registered required, so REST rejects a missing value
934 // before this point and the old `?? []` fallback was unreachable.
935 $validation = $this->identity_manager->validate_settings($settings, $tab_context);
936
937 return new WP_REST_Response([
938 'success' => true,
939 'data' => $validation,
940 'message' => 'Settings validation completed'
941 ], 200);
942
943 } catch (\Exception $e) {
944 return new WP_REST_Response([
945 'success' => false,
946 'error' => 'Validation failed: ' . $e->getMessage()
947 ], 500);
948 }
949 }
950
951 /**
952 * Permission callbacks
953 */
954
955 /**
956 * Check permissions for site identity operations (admin-only)
957 *
958 * @since 1.0.0
959 *
960 * @return bool Permission status
961 */
962 public function check_permissions(): bool {
963 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_site_identity');
964 }
965 private function get_available_title_templates(): array {
966 return [
967 'default' => [
968 'name' => 'Default',
969 'template' => '%title% %separator% %sitename%',
970 'description' => 'Standard title format with site name'
971 ]
972 ];
973 }
974
975 /**
976 * Get available breadcrumb types
977 *
978 * @since 1.0.0
979 *
980 * @return array Breadcrumb types
981 */
982 private function get_available_breadcrumb_types(): array {
983 return [
984 'hierarchical' => [
985 'name' => 'Hierarchical',
986 'description' => 'Based on page hierarchy and categories'
987 ]
988 ];
989 }
990
991 /**
992 * Argument validation methods
993 */
994
995 /**
996 * Build a save-failure message that names the actual cause.
997 *
998 * The manager knows why the save failed — missing settings table, rejected
999 * INSERT with the MySQL error attached — and used to write that to the
1000 * error log and throw it away, leaving the client a fixed string that told
1001 * nobody anything. Append the reason so the response is diagnosable on its
1002 * own. Status stays 500: a rejected INSERT is a server-side failure.
1003 *
1004 * @since 1.32.1
1005 *
1006 * @param string $fallback Message to use when no reason was recorded.
1007 * @return string Failure message.
1008 */
1009 private function describe_save_failure(string $fallback): string {
1010 $reason = $this->identity_manager->get_last_save_error();
1011
1012 return '' !== $reason ? $fallback . ': ' . $reason : $fallback;
1013 }
1014
1015 /**
1016 * Get arguments for settings endpoints
1017 *
1018 * @since 1.0.0
1019 *
1020 * @return array Arguments array
1021 */
1022 private function get_settings_args(): array {
1023 return [
1024 'settings' => [
1025 'required' => true,
1026 'type' => 'object',
1027 'description' => 'Site identity settings to update'
1028 ],
1029 'context_type' => [
1030 'required' => false,
1031 'type' => 'string',
1032 'enum' => ['site'],
1033 'default' => 'site',
1034 'description' => 'Context type (site only)'
1035 ],
1036 'context_id' => [
1037 'required' => false,
1038 'type' => 'integer',
1039 'minimum' => 1,
1040 'description' => 'Context ID (not required for site context)'
1041 ]
1042 ];
1043 }
1044
1045 /**
1046 * Get arguments for title generation endpoint
1047 *
1048 * @since 1.0.0
1049 *
1050 * @return array Arguments array
1051 */
1052 private function get_title_generation_args(): array {
1053 return [
1054 'template_name' => [
1055 'required' => false,
1056 'type' => 'string',
1057 // Must match get_available_title_templates(), which returns
1058 // 'default' and nothing else. The extra names advertised
1059 // templates the resolver has never been able to produce.
1060 'enum' => ['default'],
1061 'default' => 'default',
1062 'description' => 'Title template to use'
1063 ],
1064 'data' => [
1065 'required' => false,
1066 'type' => 'object',
1067 'description' => 'Data for placeholder replacement'
1068 ],
1069 'context' => [
1070 'required' => false,
1071 'type' => 'string',
1072 'enum' => ['site'],
1073 'default' => 'site',
1074 'description' => 'Context (site only)'
1075 ]
1076 ];
1077 }
1078
1079 /**
1080 * Get arguments for breadcrumb generation endpoint
1081 *
1082 * @since 1.0.0
1083 *
1084 * @return array Arguments array
1085 */
1086 private function get_breadcrumb_generation_args(): array {
1087 return [
1088 'breadcrumb_type' => [
1089 'required' => false,
1090 'type' => 'string',
1091 // Must match get_available_breadcrumb_types(), which returns
1092 // 'hierarchical' and nothing else.
1093 'enum' => ['hierarchical'],
1094 'default' => 'hierarchical',
1095 'description' => 'Type of breadcrumb navigation to generate'
1096 ],
1097 'options' => [
1098 'required' => false,
1099 'type' => 'object',
1100 'description' => 'Additional options for breadcrumb generation'
1101 ]
1102 ];
1103 }
1104
1105 /**
1106 * Get arguments for robots.txt endpoints
1107 *
1108 * @since 1.0.0
1109 *
1110 * @return array Arguments array
1111 */
1112 private function get_robots_txt_args(): array {
1113 return [
1114 'custom_rules' => [
1115 'required' => false,
1116 'type' => 'array',
1117 'items' => [
1118 'type' => 'object'
1119 ],
1120 'description' => 'Custom robots.txt rules'
1121 ],
1122 'enable_management' => [
1123 'required' => false,
1124 'type' => 'boolean',
1125 'default' => true,
1126 'description' => 'Enable automatic robots.txt management'
1127 ],
1128 'regenerate' => [
1129 'required' => false,
1130 'type' => 'boolean',
1131 'default' => true,
1132 'description' => 'Rebuild content from rules (Generate). When false, only re-sync the physical file to the stored content.'
1133 ],
1134 // The handler reads this and the route never declared it, so it
1135 // arrived as whatever string the client sent. RobotsManagement.js
1136 // sends it, and "false" is a non-empty string — truthy — so the
1137 // file was written when the caller had asked it not to be. ("0" is
1138 // falsy, which is why the failure was asymmetric.) Registering it
1139 // gets core's boolean coercion (#394).
1140 'ai_crawler_rules' => [
1141 'required' => false,
1142 'type' => 'object',
1143 'description' => 'Per-agent AI crawler rules, keyed by crawler slug, each "allow" or "block".',
1144 'additionalProperties' => [
1145 'type' => 'string',
1146 'enum' => ['allow', 'block'],
1147 ],
1148 ],
1149 'write_to_file' => [
1150 'required' => false,
1151 'type' => 'boolean',
1152 'default' => true,
1153 'description' => 'Write the generated content to the physical robots.txt file.'
1154 ]
1155 ];
1156 }
1157
1158 /**
1159 * Get arguments for optimization endpoint
1160 *
1161 * @since 1.0.0
1162 *
1163 * @return array Arguments array
1164 */
1165 private function get_optimization_args(): array {
1166 return [
1167 'identity_data' => [
1168 'required' => true,
1169 'type' => 'object',
1170 'description' => 'Site identity data to optimize'
1171 ],
1172 // Read by optimize_site_identity(); previously unregistered, so it
1173 // never appeared in the published schema.
1174 'options' => [
1175 'required' => false,
1176 'type' => 'object',
1177 'default' => [],
1178 'description' => 'Additional optimization options'
1179 ]
1180 ];
1181 }
1182
1183 /**
1184 * Get arguments for AI optimization endpoint
1185 *
1186 * @since 1.0.0
1187 *
1188 * @return array Arguments array
1189 */
1190 private function get_ai_optimization_args(): array {
1191 return [
1192 'site_data' => [
1193 'required' => true,
1194 'type' => 'object',
1195 'description' => 'Site identity data to optimize with AI',
1196 'properties' => [
1197 'site_name' => [
1198 'type' => 'string',
1199 'description' => 'Site name to optimize'
1200 ],
1201 'site_description' => [
1202 'type' => 'string',
1203 'description' => 'Site description to optimize'
1204 ],
1205 'tagline' => [
1206 'type' => 'string',
1207 'description' => 'Site tagline to optimize'
1208 ]
1209 ]
1210 ],
1211 'business_type' => [
1212 'required' => false,
1213 'type' => 'string',
1214 'default' => 'website',
1215 'sanitize_callback' => 'sanitize_text_field',
1216 'description' => 'Type of business for context'
1217 ],
1218 'target_audience' => [
1219 'required' => false,
1220 'type' => 'string',
1221 'default' => 'general',
1222 'sanitize_callback' => 'sanitize_text_field',
1223 'description' => 'Target audience for optimization'
1224 ],
1225 'tone' => [
1226 'required' => false,
1227 'type' => 'string',
1228 'default' => 'professional',
1229 'sanitize_callback' => 'sanitize_text_field',
1230 'description' => 'Desired tone for optimization'
1231 ]
1232 ];
1233 }
1234
1235 /**
1236 * Get arguments for hero AI optimization endpoint
1237 *
1238 * @since 1.0.0
1239 *
1240 * @return array Arguments array
1241 */
1242 private function get_hero_optimization_args(): array {
1243 return [
1244 'hero_data' => [
1245 'required' => true,
1246 'type' => 'object',
1247 'description' => 'Hero content data to optimize with AI',
1248 'properties' => [
1249 'hero_title' => [
1250 'type' => 'string',
1251 'description' => 'Hero section title'
1252 ],
1253 'hero_subtitle' => [
1254 'type' => 'string',
1255 'description' => 'Hero section subtitle'
1256 ],
1257 'hero_cta_text' => [
1258 'type' => 'string',
1259 'description' => 'Call-to-action button text'
1260 ],
1261 'hero_cta_url' => [
1262 'type' => 'string',
1263 'description' => 'Call-to-action button URL'
1264 ]
1265 ]
1266 ],
1267 'context' => [
1268 'required' => false,
1269 'type' => 'object',
1270 'description' => 'Additional context for optimization',
1271 'properties' => [
1272 'site_name' => [
1273 'type' => 'string',
1274 'description' => 'Site name for context'
1275 ],
1276 'site_url' => [
1277 'type' => 'string',
1278 'description' => 'Site URL for context'
1279 ],
1280 'business_type' => [
1281 'type' => 'string',
1282 'description' => 'Business type for context'
1283 ],
1284 'site_description' => [
1285 'type' => 'string',
1286 'description' => 'Site description for context'
1287 ]
1288 ]
1289 ],
1290 'business_type' => [
1291 'required' => false,
1292 'type' => 'string',
1293 'default' => 'website',
1294 'sanitize_callback' => 'sanitize_text_field',
1295 'description' => 'Type of business for context'
1296 ],
1297 'target_audience' => [
1298 'required' => false,
1299 'type' => 'string',
1300 'default' => 'general',
1301 'sanitize_callback' => 'sanitize_text_field',
1302 'description' => 'Target audience for optimization'
1303 ],
1304 'tone' => [
1305 'required' => false,
1306 'type' => 'string',
1307 'default' => 'professional',
1308 'sanitize_callback' => 'sanitize_text_field',
1309 'description' => 'Desired tone for optimization'
1310 ]
1311 ];
1312 }
1313
1314 /**
1315 * Get arguments for validation endpoint
1316 *
1317 * @since 1.0.0
1318 *
1319 * @return array Arguments array
1320 */
1321 private function get_validation_args(): array {
1322 return [
1323 'settings' => [
1324 'required' => true,
1325 'type' => 'object',
1326 'description' => 'Settings to validate'
1327 ],
1328 // Read by validate_identity_settings() to scope validation to one
1329 // tab; it was never registered, so it was absent from the published
1330 // schema and got no type or sanitization.
1331 'tab_context' => [
1332 'required' => false,
1333 'type' => 'string',
1334 'default' => '',
1335 'sanitize_callback' => 'sanitize_key',
1336 'description' => 'Limit validation to a single settings tab'
1337 ]
1338 ];
1339 }
1340
1341 /**
1342 * Check rate limit for robots.txt operations
1343 *
1344 * @since 1.0.0
1345 * @return bool True if within rate limit
1346 */
1347 private function check_robots_rate_limit(): bool {
1348 $user_id = get_current_user_id();
1349 $rate_key = "thinkrank_robots_rate_{$user_id}";
1350
1351 $requests = get_transient($rate_key) ?: 0;
1352
1353 if ($requests >= 20) { // Max 20 requests per 5 minutes
1354 return false;
1355 }
1356
1357 set_transient($rate_key, $requests + 1, 5 * MINUTE_IN_SECONDS);
1358 return true;
1359 }
1360 }
1361