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

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

1,291 lines 43.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Robots Meta Manager Class
4 *
5 * Universal robots meta tag generation and validation for search engine optimization.
6 * Implements 2025 search engine guidelines with real robots meta specifications,
7 * canonical URL management, and advanced robots directives handling.
8 *
9 * @package ThinkRank
10 * @subpackage SEO
11 * @since 1.0.0
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\SEO;
17
18 /**
19 * Robots Meta Manager Class
20 *
21 * Generates and validates robots meta tags for search engine optimization.
22 * Provides context-aware robots meta generation with canonical URL management
23 * and advanced robots directives handling.
24 *
25 * @since 1.0.0
26 */
27 class Robots_Meta_Manager extends Abstract_SEO_Manager {
28
29 /**
30 * Supported robots directives with their specifications
31 *
32 * @since 1.0.0
33 * @var array
34 */
35 private array $robots_directives = [
36 // Index directives
37 'index' => [
38 'type' => 'index',
39 'opposite' => 'noindex',
40 'description' => 'Allow search engines to index this page',
41 'default' => true,
42 'search_engines' => ['google', 'bing', 'yahoo', 'yandex', 'baidu']
43 ],
44 'noindex' => [
45 'type' => 'index',
46 'opposite' => 'index',
47 'description' => 'Prevent search engines from indexing this page',
48 'default' => false,
49 'search_engines' => ['google', 'bing', 'yahoo', 'yandex', 'baidu']
50 ],
51
52 // Follow directives
53 'follow' => [
54 'type' => 'follow',
55 'opposite' => 'nofollow',
56 'description' => 'Allow search engines to follow links on this page',
57 'default' => true,
58 'search_engines' => ['google', 'bing', 'yahoo', 'yandex', 'baidu']
59 ],
60 'nofollow' => [
61 'type' => 'follow',
62 'opposite' => 'follow',
63 'description' => 'Prevent search engines from following links on this page',
64 'default' => false,
65 'search_engines' => ['google', 'bing', 'yahoo', 'yandex', 'baidu']
66 ],
67
68 // Archive directives
69 'archive' => [
70 'type' => 'archive',
71 'opposite' => 'noarchive',
72 'description' => 'Allow search engines to show cached versions of this page',
73 'default' => true,
74 'search_engines' => ['google', 'bing', 'yahoo']
75 ],
76 'noarchive' => [
77 'type' => 'archive',
78 'opposite' => 'archive',
79 'description' => 'Prevent search engines from showing cached versions of this page',
80 'default' => false,
81 'search_engines' => ['google', 'bing', 'yahoo']
82 ],
83
84 // Snippet directives
85 'snippet' => [
86 'type' => 'snippet',
87 'opposite' => 'nosnippet',
88 'description' => 'Allow search engines to show text snippets of this page',
89 'default' => true,
90 'search_engines' => ['google', 'bing']
91 ],
92 'nosnippet' => [
93 'type' => 'snippet',
94 'opposite' => 'snippet',
95 'description' => 'Prevent search engines from showing text snippets of this page',
96 'default' => false,
97 'search_engines' => ['google', 'bing']
98 ],
99
100 // Image directives
101 'noimageindex' => [
102 'type' => 'image',
103 'opposite' => null,
104 'description' => 'Prevent search engines from indexing images on this page',
105 'default' => false,
106 'search_engines' => ['google']
107 ],
108
109 // Translation directives
110 'notranslate' => [
111 'type' => 'translate',
112 'opposite' => null,
113 'description' => 'Prevent search engines from offering translation of this page',
114 'default' => false,
115 'search_engines' => ['google']
116 ],
117
118 // Video directives
119 'novideoindex' => [
120 'type' => 'video',
121 'opposite' => null,
122 'description' => 'Prevent search engines from indexing videos on this page',
123 'default' => false,
124 'search_engines' => ['google']
125 ]
126 ];
127
128 /**
129 * Advanced robots directives with parameters
130 *
131 * @since 1.0.0
132 * @var array
133 */
134 private array $advanced_directives = [
135 'max-snippet' => [
136 'type' => 'parameterized',
137 'description' => 'Maximum number of characters to show in text snippets',
138 'default_value' => -1, // -1 means no limit
139 'min_value' => -1,
140 'max_value' => 320,
141 'search_engines' => ['google']
142 ],
143 'max-image-preview' => [
144 'type' => 'parameterized',
145 'description' => 'Maximum size of image preview',
146 'default_value' => 'large',
147 'allowed_values' => ['none', 'standard', 'large'],
148 'search_engines' => ['google']
149 ],
150 'max-video-preview' => [
151 'type' => 'parameterized',
152 'description' => 'Maximum duration of video preview in seconds',
153 'default_value' => -1, // -1 means no limit
154 'min_value' => -1,
155 'max_value' => 3600, // 1 hour
156 'search_engines' => ['google']
157 ],
158 'unavailable_after' => [
159 'type' => 'date',
160 'description' => 'Date after which the page should not be indexed',
161 'format' => 'RFC 850',
162 'search_engines' => ['google']
163 ]
164 ];
165
166 /**
167 * Search engine specific robots directives
168 *
169 * @since 1.0.0
170 * @var array
171 */
172 private array $search_engine_specific = [
173 'google' => ['googlebot'],
174 'bing' => ['bingbot', 'msnbot'],
175 'yahoo' => ['slurp'],
176 'yandex' => ['yandexbot'],
177 'baidu' => ['baiduspider'],
178 'duckduckgo' => ['duckduckbot']
179 ];
180
181 /**
182 * Constructor
183 *
184 * @since 1.0.0
185 */
186 public function __construct() {
187 parent::__construct('robots_meta');
188 }
189
190 /**
191 * Generate robots meta tags for content
192 *
193 * @since 1.0.0
194 *
195 * @param array $data Content data for robots generation
196 * @param string $context Context type ('site', 'post', 'page', etc.)
197 * @return array Generated robots meta tags
198 */
199 public function generate_robots_meta(array $data, string $context = 'site'): array {
200 $robots_meta = [];
201
202 // Get robots settings for context
203 $settings = $this->get_settings($context, $data['context_id'] ?? null);
204
205 // Generate main robots directive
206 $main_directive = $this->build_main_robots_directive($settings, $context, $data);
207 if (!empty($main_directive)) {
208 $robots_meta['robots'] = $main_directive;
209 }
210
211 // Generate search engine specific directives
212 $specific_directives = $this->build_search_engine_specific_directives($settings, $context, $data);
213 $robots_meta = array_merge($robots_meta, $specific_directives);
214
215 // Generate canonical URL
216 $canonical_url = $this->generate_canonical_url($data, $context, $settings);
217 if (!empty($canonical_url)) {
218 $robots_meta['canonical'] = $canonical_url;
219 }
220
221 // Add other meta directives
222 $other_meta = $this->generate_other_meta_directives($settings, $context, $data);
223 $robots_meta = array_merge($robots_meta, $other_meta);
224
225 return $robots_meta;
226 }
227
228 /**
229 * Validate robots meta settings
230 *
231 * @since 1.0.0
232 *
233 * @param array $robots_meta Robots meta to validate
234 * @return array Validation results with errors, warnings, and suggestions
235 */
236 public function validate_robots_meta(array $robots_meta): array {
237 $validation = [
238 'valid' => true,
239 'errors' => [],
240 'warnings' => [],
241 'suggestions' => [],
242 'score' => 100
243 ];
244
245 // Validate main robots directive
246 if (isset($robots_meta['robots'])) {
247 $robots_validation = $this->validate_robots_directive($robots_meta['robots']);
248 $validation = array_merge_recursive($validation, $robots_validation);
249 }
250
251 // Validate canonical URL
252 if (isset($robots_meta['canonical'])) {
253 $canonical_validation = $this->validate_canonical_url($robots_meta['canonical']);
254 $validation = array_merge_recursive($validation, $canonical_validation);
255 }
256
257 // Validate advanced directives
258 foreach ($this->advanced_directives as $directive => $spec) {
259 if (isset($robots_meta[$directive])) {
260 $directive_validation = $this->validate_advanced_directive($directive, $robots_meta[$directive], $spec);
261 $validation = array_merge_recursive($validation, $directive_validation);
262 }
263 }
264
265 // Check for conflicting directives
266 $conflict_validation = $this->check_directive_conflicts($robots_meta);
267 $validation = array_merge_recursive($validation, $conflict_validation);
268
269 // Calculate overall score
270 $validation['score'] = $this->calculate_robots_score($validation);
271
272 // Add SEO suggestions
273 $validation = $this->add_robots_seo_suggestions($robots_meta, $validation);
274
275 return $validation;
276 }
277
278 /**
279 * Generate canonical URL for content
280 *
281 * @since 1.0.0
282 *
283 * @param array $data Content data
284 * @param string $context Context type
285 * @param array $settings Robots settings
286 * @return string Canonical URL
287 */
288 public function generate_canonical_url(array $data, string $context, array $settings = []): string {
289 // Check if custom canonical is set
290 if (!empty($settings['custom_canonical'])) {
291 return $this->validate_and_clean_url($settings['custom_canonical']);
292 }
293
294 // Auto-generate canonical based on context
295 switch ($context) {
296 case 'site':
297 return home_url();
298 case 'post':
299 case 'page':
300 case 'product':
301 if (!empty($data['url'])) {
302 return $this->clean_canonical_url($data['url']);
303 }
304 break;
305 }
306
307 // Fallback to current URL
308 return $this->get_current_canonical_url();
309 }
310
311 /**
312 * Check for duplicate content and suggest canonical URLs
313 *
314 * @since 1.0.0
315 *
316 * @param string $url URL to check
317 * @param string $context Context type
318 * @return array Duplicate content analysis
319 */
320 public function analyze_duplicate_content(string $url, string $context): array {
321 $analysis = [
322 'has_duplicates' => false,
323 'duplicate_urls' => [],
324 'suggested_canonical' => $url,
325 'recommendations' => []
326 ];
327
328 // Check for common duplicate patterns
329 $duplicate_patterns = $this->detect_duplicate_patterns($url);
330 if (!empty($duplicate_patterns)) {
331 $analysis['has_duplicates'] = true;
332 $analysis['duplicate_urls'] = $duplicate_patterns;
333 $analysis['suggested_canonical'] = $this->suggest_canonical_from_duplicates($duplicate_patterns);
334 $analysis['recommendations'] = $this->get_duplicate_content_recommendations($duplicate_patterns);
335 }
336
337 return $analysis;
338 }
339
340 /**
341 * Validate SEO settings (implements interface)
342 *
343 * @since 1.0.0
344 *
345 * @param array $settings Settings array to validate
346 * @return array Validation results
347 */
348 public function validate_settings(array $settings): array {
349 $validation = [
350 'valid' => true,
351 'errors' => [],
352 'warnings' => [],
353 'suggestions' => []
354 ];
355
356 // Validate robots index setting
357 if (isset($settings['robots_index'])) {
358 if (!in_array($settings['robots_index'], ['index', 'noindex'], true)) {
359 $validation['errors'][] = 'robots_index must be either "index" or "noindex"';
360 $validation['valid'] = false;
361 }
362 }
363
364 // Validate robots follow setting
365 if (isset($settings['robots_follow'])) {
366 if (!in_array($settings['robots_follow'], ['follow', 'nofollow'], true)) {
367 $validation['errors'][] = 'robots_follow must be either "follow" or "nofollow"';
368 $validation['valid'] = false;
369 }
370 }
371
372 // Validate advanced directives
373 foreach ($this->advanced_directives as $directive => $spec) {
374 if (isset($settings[$directive])) {
375 $directive_validation = $this->validate_advanced_directive($directive, $settings[$directive], $spec);
376 if (!$directive_validation['valid']) {
377 $validation['errors'] = array_merge($validation['errors'], $directive_validation['errors']);
378 $validation['valid'] = false;
379 }
380 $validation['warnings'] = array_merge($validation['warnings'], $directive_validation['warnings']);
381 }
382 }
383
384 // Validate canonical URL
385 if (isset($settings['custom_canonical']) && !empty($settings['custom_canonical'])) {
386 if (!filter_var($settings['custom_canonical'], FILTER_VALIDATE_URL)) {
387 $validation['errors'][] = 'custom_canonical must be a valid URL';
388 $validation['valid'] = false;
389 }
390 }
391
392 // Check for conflicting settings
393 if (isset($settings['robots_index'], $settings['robots_follow'])) {
394 if ($settings['robots_index'] === 'noindex' && $settings['robots_follow'] === 'follow') {
395 $validation['warnings'][] = 'Using "noindex, follow" - consider using "noindex, nofollow" for better crawl budget optimization';
396 }
397 }
398
399 return $validation;
400 }
401
402 /**
403 * Get output data for frontend rendering (implements interface)
404 *
405 * @since 1.0.0
406 *
407 * @param string $context_type The context type
408 * @param int|null $context_id Optional. Context ID
409 * @return array Output data ready for frontend rendering
410 */
411 public function get_output_data(string $context_type, ?int $context_id): array {
412 $settings = $this->get_settings($context_type, $context_id);
413 $output = [
414 'robots_meta' => [],
415 'meta_tags' => [],
416 'enabled' => false
417 ];
418
419 // Check if robots meta is enabled
420 if (empty($settings['robots_enabled'])) {
421 return $output;
422 }
423
424 $output['enabled'] = true;
425
426 // Extract content data
427 $content_data = $this->extract_robots_content_data($context_type, $context_id);
428
429 // Generate robots meta
430 $output['robots_meta'] = $this->generate_robots_meta($content_data, $context_type);
431
432 // Convert to HTML meta tags
433 $output['meta_tags'] = $this->convert_robots_to_meta_tags($output['robots_meta']);
434
435 return $output;
436 }
437
438 /**
439 * Get default settings for a context type (implements interface)
440 *
441 * @since 1.0.0
442 *
443 * @param string $context_type The context type to get defaults for
444 * @return array Default settings array
445 */
446 public function get_default_settings(string $context_type): array {
447 $defaults = [
448 'robots_enabled' => true,
449 'robots_index' => 'index',
450 'robots_follow' => 'follow',
451 'robots_archive' => 'archive',
452 'robots_snippet' => 'snippet',
453 'max_snippet' => -1,
454 'max_image_preview' => 'large',
455 'max_video_preview' => -1,
456 'custom_canonical' => '',
457 'auto_canonical' => true,
458 'noindex_search' => true,
459 'noindex_archives' => false
460 ];
461
462 // Context-specific defaults
463 switch ($context_type) {
464 case 'site':
465 // Site-wide defaults are already set above
466 break;
467 case 'post':
468 // Posts should generally be indexed
469 $defaults['robots_index'] = 'index';
470 break;
471 case 'page':
472 // Pages should generally be indexed
473 $defaults['robots_index'] = 'index';
474 break;
475 case 'product':
476 // Products should definitely be indexed
477 $defaults['robots_index'] = 'index';
478 $defaults['max_image_preview'] = 'large'; // Important for product images
479 break;
480 }
481
482 return $defaults;
483 }
484
485 /**
486 * Get settings schema definition (implements interface)
487 *
488 * @since 1.0.0
489 *
490 * @param string $context_type The context type to get schema for
491 * @return array Settings schema definition
492 */
493 public function get_settings_schema(string $context_type): array {
494 return [
495 'robots_enabled' => [
496 'type' => 'boolean',
497 'title' => 'Enable Robots Meta',
498 'description' => 'Generate robots meta tags for search engine crawling control',
499 'default' => true
500 ],
501 'robots_index' => [
502 'type' => 'string',
503 'title' => 'Index Directive',
504 'description' => 'Control whether search engines should index this content',
505 'enum' => ['index', 'noindex'],
506 'default' => 'index'
507 ],
508 'robots_follow' => [
509 'type' => 'string',
510 'title' => 'Follow Directive',
511 'description' => 'Control whether search engines should follow links on this content',
512 'enum' => ['follow', 'nofollow'],
513 'default' => 'follow'
514 ],
515 'robots_archive' => [
516 'type' => 'string',
517 'title' => 'Archive Directive',
518 'description' => 'Control whether search engines should show cached versions',
519 'enum' => ['archive', 'noarchive'],
520 'default' => 'archive'
521 ],
522 'robots_snippet' => [
523 'type' => 'string',
524 'title' => 'Snippet Directive',
525 'description' => 'Control whether search engines should show text snippets',
526 'enum' => ['snippet', 'nosnippet'],
527 'default' => 'snippet'
528 ],
529 'max_snippet' => [
530 'type' => 'integer',
531 'title' => 'Max Snippet Length',
532 'description' => 'Maximum number of characters in text snippets (-1 for no limit)',
533 'minimum' => -1,
534 'maximum' => 320,
535 'default' => -1
536 ],
537 'max_image_preview' => [
538 'type' => 'string',
539 'title' => 'Max Image Preview',
540 'description' => 'Maximum size of image preview in search results',
541 'enum' => ['none', 'standard', 'large'],
542 'default' => 'large'
543 ],
544 'max_video_preview' => [
545 'type' => 'integer',
546 'title' => 'Max Video Preview',
547 'description' => 'Maximum duration of video preview in seconds (-1 for no limit)',
548 'minimum' => -1,
549 'maximum' => 3600,
550 'default' => -1
551 ],
552 'custom_canonical' => [
553 'type' => 'string',
554 'title' => 'Custom Canonical URL',
555 'description' => 'Custom canonical URL for this content (overrides auto-generated)',
556 'format' => 'uri',
557 'default' => ''
558 ],
559 'auto_canonical' => [
560 'type' => 'boolean',
561 'title' => 'Auto-generate Canonical',
562 'description' => 'Automatically generate canonical URLs',
563 'default' => true
564 ],
565 'noindex_search' => [
566 'type' => 'boolean',
567 'title' => 'Noindex Search Results',
568 'description' => 'Prevent indexing of search result pages',
569 'default' => true
570 ],
571 'noindex_archives' => [
572 'type' => 'boolean',
573 'title' => 'Noindex Archive Pages',
574 'description' => 'Prevent indexing of archive pages (categories, tags, etc.)',
575 'default' => false
576 ]
577 ];
578 }
579
580 /**
581 * Extract content data for robots meta generation
582 *
583 * @since 1.0.0
584 *
585 * @param string $context_type The context type
586 * @param int|null $context_id Optional. Context ID
587 * @return array Extracted content data
588 */
589 private function extract_robots_content_data(string $context_type, ?int $context_id): array {
590 $data = [
591 'context_type' => $context_type,
592 'context_id' => $context_id,
593 'url' => '',
594 'is_search' => false,
595 'is_archive' => false,
596 'is_404' => false,
597 'is_private' => false,
598 'post_status' => '',
599 'post_password' => false
600 ];
601
602 if ($context_type === 'site') {
603 $data['url'] = home_url();
604 } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'], true)) {
605 $post = get_post($context_id);
606 if ($post) {
607 $data['url'] = get_permalink($post);
608 $data['post_status'] = $post->post_status;
609 $data['post_password'] = !empty($post->post_password);
610 $data['is_private'] = $post->post_status === 'private';
611 }
612 }
613
614 // Detect special page types
615 if (is_search()) {
616 $data['is_search'] = true;
617 }
618 if (is_archive() || is_category() || is_tag() || is_tax()) {
619 $data['is_archive'] = true;
620 }
621 if (is_404()) {
622 $data['is_404'] = true;
623 }
624
625 return $data;
626 }
627
628 /**
629 * Build main robots directive
630 *
631 * @since 1.0.0
632 *
633 * @param array $settings Robots settings
634 * @param string $context Context type
635 * @param array $data Content data
636 * @return string Robots directive
637 */
638 private function build_main_robots_directive(array $settings, string $context, array $data): string {
639 $directives = [];
640
641 // Determine index directive
642 $index_directive = $this->determine_index_directive($settings, $context, $data);
643 if ($index_directive) {
644 $directives[] = $index_directive;
645 }
646
647 // Determine follow directive
648 $follow_directive = $this->determine_follow_directive($settings, $context, $data);
649 if ($follow_directive) {
650 $directives[] = $follow_directive;
651 }
652
653 // Add other directives
654 $other_directives = $this->get_other_robots_directives($settings, $context, $data);
655 $directives = array_merge($directives, $other_directives);
656
657 // Add advanced directives
658 $advanced_directives = $this->get_advanced_robots_directives($settings);
659 $directives = array_merge($directives, $advanced_directives);
660
661 return implode(', ', array_filter($directives));
662 }
663
664 /**
665 * Determine index directive based on settings and context
666 *
667 * @since 1.0.0
668 *
669 * @param array $settings Robots settings
670 * @param string $context Context type
671 * @param array $data Content data
672 * @return string Index directive
673 */
674 private function determine_index_directive(array $settings, string $context, array $data): string {
675 // Check for explicit noindex conditions
676 if ($data['is_search'] && !empty($settings['noindex_search'])) {
677 return 'noindex';
678 }
679
680 if ($data['is_archive'] && !empty($settings['noindex_archives'])) {
681 return 'noindex';
682 }
683
684 if ($data['is_404']) {
685 return 'noindex';
686 }
687
688 if ($data['is_private'] || $data['post_password']) {
689 return 'noindex';
690 }
691
692 if ($data['post_status'] === 'draft' || $data['post_status'] === 'pending') {
693 return 'noindex';
694 }
695
696 // Use setting or default
697 return $settings['robots_index'] ?? 'index';
698 }
699
700 /**
701 * Determine follow directive based on settings and context
702 *
703 * @since 1.0.0
704 *
705 * @param array $settings Robots settings
706 * @param string $context Context type
707 * @param array $data Content data
708 * @return string Follow directive
709 */
710 private function determine_follow_directive(array $settings, string $context, array $data): string {
711 // If noindex, consider nofollow for crawl budget optimization
712 $index_directive = $this->determine_index_directive($settings, $context, $data);
713 if ($index_directive === 'noindex') {
714 // For noindex pages, default to nofollow unless explicitly set to follow
715 if (!isset($settings['robots_follow']) || $settings['robots_follow'] !== 'follow') {
716 return 'nofollow';
717 }
718 }
719
720 // Use setting or default
721 return $settings['robots_follow'] ?? 'follow';
722 }
723
724 /**
725 * Get other robots directives
726 *
727 * @since 1.0.0
728 *
729 * @param array $settings Robots settings
730 * @param string $context Context type
731 * @param array $data Content data
732 * @return array Other directives
733 */
734 private function get_other_robots_directives(array $settings, string $context, array $data): array {
735 $directives = [];
736
737 // Archive directive
738 if (isset($settings['robots_archive']) && $settings['robots_archive'] === 'noarchive') {
739 $directives[] = 'noarchive';
740 }
741
742 // Snippet directive
743 if (isset($settings['robots_snippet']) && $settings['robots_snippet'] === 'nosnippet') {
744 $directives[] = 'nosnippet';
745 }
746
747 // Image index directive
748 if (!empty($settings['noimageindex'])) {
749 $directives[] = 'noimageindex';
750 }
751
752 // Video index directive
753 if (!empty($settings['novideoindex'])) {
754 $directives[] = 'novideoindex';
755 }
756
757 // Translation directive
758 if (!empty($settings['notranslate'])) {
759 $directives[] = 'notranslate';
760 }
761
762 return $directives;
763 }
764
765 /**
766 * Get advanced robots directives with parameters
767 *
768 * @since 1.0.0
769 *
770 * @param array $settings Robots settings
771 * @return array Advanced directives
772 */
773 private function get_advanced_robots_directives(array $settings): array {
774 $directives = [];
775
776 // Max snippet
777 if (isset($settings['max_snippet']) && $settings['max_snippet'] !== -1) {
778 $directives[] = 'max-snippet:' . (int) $settings['max_snippet'];
779 }
780
781 // Max image preview
782 if (isset($settings['max_image_preview']) && $settings['max_image_preview'] !== 'large') {
783 $directives[] = 'max-image-preview:' . $settings['max_image_preview'];
784 }
785
786 // Max video preview
787 if (isset($settings['max_video_preview']) && $settings['max_video_preview'] !== -1) {
788 $directives[] = 'max-video-preview:' . (int) $settings['max_video_preview'];
789 }
790
791 // Unavailable after
792 if (!empty($settings['unavailable_after'])) {
793 $directives[] = 'unavailable_after:' . $settings['unavailable_after'];
794 }
795
796 return $directives;
797 }
798
799 /**
800 * Build search engine specific directives
801 *
802 * @since 1.0.0
803 *
804 * @param array $settings Robots settings
805 * @param string $context Context type
806 * @param array $data Content data
807 * @return array Search engine specific directives
808 */
809 private function build_search_engine_specific_directives(array $settings, string $context, array $data): array {
810 $specific_directives = [];
811
812 // Check if any search engine specific settings are configured
813 foreach ($this->search_engine_specific as $engine => $bots) {
814 $engine_settings = $settings[$engine] ?? [];
815 if (!empty($engine_settings)) {
816 foreach ($bots as $bot) {
817 $directive = $this->build_main_robots_directive($engine_settings, $context, $data);
818 if (!empty($directive)) {
819 $specific_directives[$bot] = $directive;
820 }
821 }
822 }
823 }
824
825 return $specific_directives;
826 }
827
828 /**
829 * Generate other meta directives
830 *
831 * @since 1.0.0
832 *
833 * @param array $settings Robots settings
834 * @param string $context Context type
835 * @param array $data Content data
836 * @return array Other meta directives
837 */
838 private function generate_other_meta_directives(array $settings, string $context, array $data): array {
839 $meta = [];
840
841 // Refresh directive for dynamic content
842 if (!empty($settings['refresh_interval'])) {
843 $meta['refresh'] = (int) $settings['refresh_interval'];
844 }
845
846 // Rating directive for content rating
847 if (!empty($settings['rating'])) {
848 $meta['rating'] = $settings['rating'];
849 }
850
851 return $meta;
852 }
853
854 /**
855 * Clean canonical URL
856 *
857 * @since 1.0.0
858 *
859 * @param string $url URL to clean
860 * @return string Cleaned canonical URL
861 */
862 private function clean_canonical_url(string $url): string {
863 // Remove query parameters that don't affect content
864 $parsed_url = wp_parse_url($url);
865
866 if (!$parsed_url) {
867 return $url;
868 }
869
870 // Rebuild URL without unwanted query parameters
871 $clean_url = $parsed_url['scheme'] . '://' . $parsed_url['host'];
872
873 if (!empty($parsed_url['port']) && $parsed_url['port'] !== 80 && $parsed_url['port'] !== 443) {
874 $clean_url .= ':' . $parsed_url['port'];
875 }
876
877 if (!empty($parsed_url['path'])) {
878 $clean_url .= $parsed_url['path'];
879 }
880
881 // Only include specific query parameters that affect content
882 if (!empty($parsed_url['query'])) {
883 parse_str($parsed_url['query'], $query_params);
884 $allowed_params = ['p', 'page_id', 'cat', 'tag', 's']; // WordPress core parameters
885 $filtered_params = array_intersect_key($query_params, array_flip($allowed_params));
886
887 if (!empty($filtered_params)) {
888 $clean_url .= '?' . http_build_query($filtered_params);
889 }
890 }
891
892 return $clean_url;
893 }
894
895 /**
896 * Get current canonical URL
897 *
898 * @since 1.0.0
899 *
900 * @return string Current canonical URL
901 */
902 private function get_current_canonical_url(): string {
903 if (is_admin()) {
904 return home_url();
905 }
906
907 global $wp;
908 $current_url = home_url(add_query_arg([], $wp->request));
909 return $this->clean_canonical_url($current_url);
910 }
911
912 /**
913 * Validate and clean URL
914 *
915 * @since 1.0.0
916 *
917 * @param string $url URL to validate and clean
918 * @return string Validated and cleaned URL
919 */
920 private function validate_and_clean_url(string $url): string {
921 if (!filter_var($url, FILTER_VALIDATE_URL)) {
922 return '';
923 }
924
925 return $this->clean_canonical_url($url);
926 }
927
928 /**
929 * Validate robots directive
930 *
931 * @since 1.0.0
932 *
933 * @param string $directive Robots directive to validate
934 * @return array Validation results
935 */
936 private function validate_robots_directive(string $directive): array {
937 $validation = [
938 'valid' => true,
939 'errors' => [],
940 'warnings' => [],
941 'suggestions' => []
942 ];
943
944 if (empty($directive)) {
945 $validation['warnings'][] = 'Empty robots directive';
946 return $validation;
947 }
948
949 $directives = array_map('trim', explode(',', $directive));
950 $known_directives = array_keys($this->robots_directives);
951 $advanced_directives = array_keys($this->advanced_directives);
952
953 foreach ($directives as $single_directive) {
954 // Check for parameterized directives
955 if (strpos($single_directive, ':') !== false) {
956 [$directive_name, $directive_value] = explode(':', $single_directive, 2);
957 $directive_name = trim($directive_name);
958
959 if (!in_array($directive_name, $advanced_directives, true)) {
960 $validation['warnings'][] = "Unknown advanced directive: {$directive_name}";
961 }
962 } else {
963 // Check basic directives
964 if (!in_array($single_directive, $known_directives, true)) {
965 $validation['warnings'][] = "Unknown robots directive: {$single_directive}";
966 }
967 }
968 }
969
970 // Check for conflicting directives
971 if (in_array('index', $directives, true) && in_array('noindex', $directives, true)) {
972 $validation['errors'][] = 'Conflicting directives: index and noindex cannot be used together';
973 $validation['valid'] = false;
974 }
975
976 if (in_array('follow', $directives, true) && in_array('nofollow', $directives, true)) {
977 $validation['errors'][] = 'Conflicting directives: follow and nofollow cannot be used together';
978 $validation['valid'] = false;
979 }
980
981 return $validation;
982 }
983
984 /**
985 * Validate canonical URL
986 *
987 * @since 1.0.0
988 *
989 * @param string $canonical_url Canonical URL to validate
990 * @return array Validation results
991 */
992 private function validate_canonical_url(string $canonical_url): array {
993 $validation = [
994 'valid' => true,
995 'errors' => [],
996 'warnings' => [],
997 'suggestions' => []
998 ];
999
1000 if (empty($canonical_url)) {
1001 $validation['warnings'][] = 'No canonical URL specified';
1002 return $validation;
1003 }
1004
1005 if (!filter_var($canonical_url, FILTER_VALIDATE_URL)) {
1006 $validation['errors'][] = 'Invalid canonical URL format';
1007 $validation['valid'] = false;
1008 return $validation;
1009 }
1010
1011 // Check if canonical URL is accessible
1012 $parsed_url = wp_parse_url($canonical_url);
1013 $current_host = wp_parse_url(home_url(), PHP_URL_HOST);
1014
1015 if ($parsed_url['host'] !== $current_host) {
1016 $validation['warnings'][] = 'Canonical URL points to external domain - ensure this is intentional';
1017 }
1018
1019 // Check for common canonical URL issues
1020 if (strpos($canonical_url, '?') !== false) {
1021 $validation['suggestions'][] = 'Consider removing query parameters from canonical URL for cleaner URLs';
1022 }
1023
1024 if (substr($canonical_url, -1) !== '/' && !pathinfo($canonical_url, PATHINFO_EXTENSION)) {
1025 $validation['suggestions'][] = 'Consider adding trailing slash to canonical URL for consistency';
1026 }
1027
1028 return $validation;
1029 }
1030
1031 /**
1032 * Validate advanced directive
1033 *
1034 * @since 1.0.0
1035 *
1036 * @param string $directive_name Directive name
1037 * @param mixed $directive_value Directive value
1038 * @param array $spec Directive specification
1039 * @return array Validation results
1040 */
1041 private function validate_advanced_directive(string $directive_name, $directive_value, array $spec): array {
1042 $validation = [
1043 'valid' => true,
1044 'errors' => [],
1045 'warnings' => [],
1046 'suggestions' => []
1047 ];
1048
1049 switch ($spec['type']) {
1050 case 'parameterized':
1051 if (isset($spec['allowed_values'])) {
1052 if (!in_array($directive_value, $spec['allowed_values'], true)) {
1053 $validation['errors'][] = "{$directive_name} must be one of: " . implode(', ', $spec['allowed_values']);
1054 $validation['valid'] = false;
1055 }
1056 } elseif (isset($spec['min_value'], $spec['max_value'])) {
1057 $value = (int) $directive_value;
1058 if ($value < $spec['min_value'] || $value > $spec['max_value']) {
1059 $validation['errors'][] = "{$directive_name} must be between {$spec['min_value']} and {$spec['max_value']}";
1060 $validation['valid'] = false;
1061 }
1062 }
1063 break;
1064 case 'date':
1065 if (!strtotime($directive_value)) {
1066 $validation['errors'][] = "{$directive_name} must be a valid date";
1067 $validation['valid'] = false;
1068 }
1069 break;
1070 }
1071
1072 return $validation;
1073 }
1074
1075 /**
1076 * Check for directive conflicts
1077 *
1078 * @since 1.0.0
1079 *
1080 * @param array $robots_meta Robots meta array
1081 * @return array Validation results
1082 */
1083 private function check_directive_conflicts(array $robots_meta): array {
1084 $validation = [
1085 'valid' => true,
1086 'errors' => [],
1087 'warnings' => [],
1088 'suggestions' => []
1089 ];
1090
1091 if (isset($robots_meta['robots'])) {
1092 $directives = array_map('trim', explode(',', $robots_meta['robots']));
1093
1094 // Check for logical conflicts
1095 if (in_array('noindex', $directives, true) && in_array('nosnippet', $directives, true)) {
1096 $validation['suggestions'][] = 'nosnippet is redundant when noindex is used';
1097 }
1098
1099 if (in_array('noindex', $directives, true) && in_array('noarchive', $directives, true)) {
1100 $validation['suggestions'][] = 'noarchive is redundant when noindex is used';
1101 }
1102 }
1103
1104 return $validation;
1105 }
1106
1107 /**
1108 * Calculate robots score based on validation
1109 *
1110 * @since 1.0.0
1111 *
1112 * @param array $validation Validation results
1113 * @return int Robots score (0-100)
1114 */
1115 private function calculate_robots_score(array $validation): int {
1116 $score = 100;
1117
1118 // Deduct points for errors and warnings
1119 $score -= count($validation['errors']) * 20;
1120 $score -= count($validation['warnings']) * 10;
1121
1122 return max(0, $score);
1123 }
1124
1125 /**
1126 * Add robots SEO suggestions
1127 *
1128 * @since 1.0.0
1129 *
1130 * @param array $robots_meta Robots meta array
1131 * @param array $validation Current validation results
1132 * @return array Updated validation results
1133 */
1134 private function add_robots_seo_suggestions(array $robots_meta, array $validation): array {
1135 // Suggest canonical URL if missing
1136 if (!isset($robots_meta['canonical'])) {
1137 $validation['suggestions'][] = 'Add canonical URL to prevent duplicate content issues';
1138 }
1139
1140 // Suggest max-image-preview for content with images
1141 if (!isset($robots_meta['max-image-preview'])) {
1142 $validation['suggestions'][] = 'Consider setting max-image-preview:large for better image visibility in search results';
1143 }
1144
1145 // 2025 SEO best practices
1146 if (isset($robots_meta['robots'])) {
1147 $directives = array_map('trim', explode(',', $robots_meta['robots']));
1148
1149 if (!in_array('index', $directives, true) && !in_array('noindex', $directives, true)) {
1150 $validation['suggestions'][] = 'Explicitly specify index or noindex directive for clarity';
1151 }
1152 }
1153
1154 return $validation;
1155 }
1156
1157 /**
1158 * Convert robots meta to HTML meta tags
1159 *
1160 * @since 1.0.0
1161 *
1162 * @param array $robots_meta Robots meta array
1163 * @return array HTML meta tags
1164 */
1165 private function convert_robots_to_meta_tags(array $robots_meta): array {
1166 $meta_tags = [];
1167
1168 foreach ($robots_meta as $name => $content) {
1169 if (!empty($content)) {
1170 if ($name === 'canonical') {
1171 $meta_tags[] = [
1172 'rel' => 'canonical',
1173 'href' => esc_url($content)
1174 ];
1175 } else {
1176 $meta_tags[] = [
1177 'name' => esc_attr($name),
1178 'content' => esc_attr($content)
1179 ];
1180 }
1181 }
1182 }
1183
1184 return $meta_tags;
1185 }
1186
1187 /**
1188 * Detect duplicate content patterns
1189 *
1190 * @since 1.0.0
1191 *
1192 * @param string $url URL to check for duplicates
1193 * @return array Duplicate URL patterns
1194 */
1195 private function detect_duplicate_patterns(string $url): array {
1196 $duplicates = [];
1197 $parsed_url = wp_parse_url($url);
1198
1199 if (!$parsed_url) {
1200 return $duplicates;
1201 }
1202
1203 $base_url = $parsed_url['scheme'] . '://' . $parsed_url['host'] . ($parsed_url['path'] ?? '');
1204
1205 // Check for common duplicate patterns
1206 $patterns = [
1207 $base_url . '/', // With trailing slash
1208 rtrim($base_url, '/'), // Without trailing slash
1209 $base_url . '?utm_source=test', // With tracking parameters
1210 $base_url . '#section', // With fragment
1211 $base_url . '?ref=homepage' // With referrer parameters
1212 ];
1213
1214 foreach ($patterns as $pattern) {
1215 if ($pattern !== $url) {
1216 $duplicates[] = $pattern;
1217 }
1218 }
1219
1220 return $duplicates;
1221 }
1222
1223 /**
1224 * Suggest canonical from duplicates
1225 *
1226 * @since 1.0.0
1227 *
1228 * @param array $duplicates Array of duplicate URLs
1229 * @return string Suggested canonical URL
1230 */
1231 private function suggest_canonical_from_duplicates(array $duplicates): string {
1232 if (empty($duplicates)) {
1233 return '';
1234 }
1235
1236 // Prefer URLs without query parameters and with trailing slashes
1237 $scored_urls = [];
1238
1239 foreach ($duplicates as $url) {
1240 $score = 0;
1241
1242 // Prefer URLs without query parameters
1243 if (strpos($url, '?') === false) {
1244 $score += 10;
1245 }
1246
1247 // Prefer URLs with trailing slashes for directories
1248 if (substr($url, -1) === '/' && !pathinfo($url, PATHINFO_EXTENSION)) {
1249 $score += 5;
1250 }
1251
1252 // Prefer HTTPS
1253 if (strpos($url, 'https://') === 0) {
1254 $score += 3;
1255 }
1256
1257 $scored_urls[$url] = $score;
1258 }
1259
1260 arsort($scored_urls);
1261 return array_key_first($scored_urls);
1262 }
1263
1264 /**
1265 * Get duplicate content recommendations
1266 *
1267 * @since 1.0.0
1268 *
1269 * @param array $duplicates Array of duplicate URLs
1270 * @return array Recommendations
1271 */
1272 private function get_duplicate_content_recommendations(array $duplicates): array {
1273 $recommendations = [];
1274
1275 if (count($duplicates) > 1) {
1276 $recommendations[] = 'Set up 301 redirects from duplicate URLs to the canonical version';
1277 $recommendations[] = 'Use consistent URL structure across your site';
1278 $recommendations[] = 'Implement proper canonical tags on all pages';
1279 }
1280
1281 foreach ($duplicates as $url) {
1282 if (strpos($url, '?') !== false) {
1283 $recommendations[] = 'Remove unnecessary query parameters from URLs';
1284 break;
1285 }
1286 }
1287
1288 return array_unique($recommendations);
1289 }
1290 }
1291