PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.5.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.5.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-site-identity-manager.php

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

4,070 lines 148.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Site Identity Manager Class
5 *
6 * Comprehensive site identity management with title formats, separators,
7 * breadcrumb navigation, robots.txt management, and site identity optimization.
8 * Implements 2025 SEO best practices with real industry-standard algorithms.
9 *
10 * @package ThinkRank
11 * @subpackage SEO
12 * @since 1.0.0
13 */
14
15 declare(strict_types=1);
16
17 namespace ThinkRank\SEO;
18
19 // Prevent direct access
20 if (!defined('ABSPATH')) {
21 exit;
22 }
23
24 // Ensure dependencies are loaded
25 if (!class_exists('ThinkRank\\SEO\\Abstract_SEO_Manager')) {
26 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-abstract-seo-manager.php';
27 }
28
29 if (!interface_exists('ThinkRank\\SEO\\Interfaces\\SEO_Manager_Interface')) {
30 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/interfaces/class-seo-manager-interface.php';
31 }
32
33 /**
34 * Site Identity Manager Class
35 *
36 * Manages all aspects of site identity including title formats, breadcrumbs,
37 * robots.txt, and global SEO settings with context-aware optimization.
38 *
39 * @since 1.0.0
40 */
41 class Site_Identity_Manager extends Abstract_SEO_Manager {
42
43 /**
44 * WordPress filesystem instance
45 *
46 * @since 1.0.0
47 * @var \WP_Filesystem_Base|null
48 */
49 private $filesystem = null;
50
51 /**
52 * Title format templates with dynamic placeholders
53 *
54 * @since 1.0.0
55 * @var array
56 */
57 private array $title_templates = [
58 'default' => '%title% %separator% %sitename%',
59 'reverse' => '%sitename% %separator% %title%',
60 'title_only' => '%title%',
61 'sitename_only' => '%sitename%',
62 'custom' => '%title% %separator% %sitename% %separator% %tagline%',
63 'category' => '%title% %separator% %category% %separator% %sitename%',
64 'author' => '%title% %separator% %author% %separator% %sitename%',
65 'date' => '%title% %separator% %date% %separator% %sitename%',
66 'search' => 'Search Results for "%searchterm%" %separator% %sitename%',
67 '404' => 'Page Not Found %separator% %sitename%'
68 ];
69
70 /**
71 * Available title separators with their specifications
72 *
73 * @since 1.0.0
74 * @var array
75 */
76 public static array $title_separators = [
77 'pipe' => [
78 'symbol' => '|',
79 'name' => 'Pipe',
80 'description' => 'Vertical bar separator (most common)',
81 'seo_score' => 10
82 ],
83 'dash' => [
84 'symbol' => '-',
85 'name' => 'Dash',
86 'description' => 'Hyphen separator (clean and readable)',
87 'seo_score' => 9
88 ],
89 'bullet' => [
90 'symbol' => '',
91 'name' => 'Bullet',
92 'description' => 'Bullet point separator (modern)',
93 'seo_score' => 8
94 ],
95 'colon' => [
96 'symbol' => ':',
97 'name' => 'Colon',
98 'description' => 'Colon separator (formal)',
99 'seo_score' => 7
100 ],
101 'greater' => [
102 'symbol' => '>',
103 'name' => 'Greater Than',
104 'description' => 'Arrow-like separator (hierarchical)',
105 'seo_score' => 6
106 ],
107 'tilde' => [
108 'symbol' => '~',
109 'name' => 'Tilde',
110 'description' => 'Wave separator (unique)',
111 'seo_score' => 5
112 ]
113 ];
114
115 /**
116 * Get the currently active title separator symbol
117 *
118 * @since 1.0.0
119 * @return string Separator symbol
120 */
121 public static function get_active_separator_symbol(): string {
122 $manager = new self();
123 $settings = $manager->get_settings('site');
124 $separator_key = $settings['title_separator'] ?? 'pipe';
125
126 return self::$title_separators[$separator_key]['symbol'] ?? '|';
127 }
128
129 /**
130 * Breadcrumb types and their configurations
131 *
132 * @since 1.0.0
133 * @var array
134 */
135 private array $breadcrumb_types = [
136 'hierarchical' => [
137 'name' => 'Hierarchical',
138 'description' => 'Based on page hierarchy and categories',
139 'schema_type' => 'BreadcrumbList',
140 'seo_value' => 10
141 ],
142 'taxonomy' => [
143 'name' => 'Taxonomy-based',
144 'description' => 'Based on post categories and tags',
145 'schema_type' => 'BreadcrumbList',
146 'seo_value' => 9
147 ],
148 'path' => [
149 'name' => 'URL Path',
150 'description' => 'Based on URL structure',
151 'schema_type' => 'BreadcrumbList',
152 'seo_value' => 8
153 ],
154 'custom' => [
155 'name' => 'Custom',
156 'description' => 'Manually defined breadcrumb structure',
157 'schema_type' => 'BreadcrumbList',
158 'seo_value' => 7
159 ]
160 ];
161
162 /**
163 * Robots.txt directives and their specifications
164 *
165 * @since 1.0.0
166 * @var array
167 */
168 private array $robots_directives = [
169 'user_agent' => [
170 'required' => true,
171 'description' => 'Specifies which web crawler the rules apply to',
172 'examples' => ['*', 'Googlebot', 'Bingbot', 'Yandexbot']
173 ],
174 'disallow' => [
175 'required' => false,
176 'description' => 'Specifies paths that should not be crawled',
177 'examples' => ['/admin/', '/wp-admin/', '/wp-includes/', '/private/']
178 ],
179 'allow' => [
180 'required' => false,
181 'description' => 'Specifies paths that should be crawled (overrides disallow)',
182 'examples' => ['/wp-admin/admin-ajax.php', '/wp-content/uploads/']
183 ],
184 'sitemap' => [
185 'required' => false,
186 'description' => 'Specifies the location of XML sitemaps',
187 'examples' => ['/sitemap.xml', '/sitemap_index.xml']
188 ],
189 'crawl_delay' => [
190 'required' => false,
191 'description' => 'Specifies delay between requests (in seconds)',
192 'examples' => ['1', '5', '10']
193 ]
194 ];
195
196 /**
197 * Site identity elements configuration
198 *
199 * @since 1.0.0
200 * @var array
201 */
202 private array $identity_elements = [
203 'logo' => [
204 'type' => 'image',
205 'required' => false,
206 'description' => 'Site logo for branding and schema markup',
207 'recommended_size' => '600x60',
208 'max_size' => '2MB'
209 ],
210 'favicon' => [
211 'type' => 'image',
212 'required' => false,
213 'description' => 'Site favicon for browser tabs',
214 'recommended_size' => '32x32',
215 'formats' => ['ico', 'png']
216 ],
217 'apple_touch_icon' => [
218 'type' => 'image',
219 'required' => false,
220 'description' => 'Apple touch icon for iOS devices',
221 'recommended_size' => '180x180',
222 'format' => 'png'
223 ],
224 'site_name' => [
225 'type' => 'text',
226 'required' => true,
227 'description' => 'Official site name for branding',
228 'max_length' => 60
229 ],
230 'tagline' => [
231 'type' => 'text',
232 'required' => false,
233 'description' => 'Site tagline or slogan',
234 'max_length' => 160
235 ],
236 'description' => [
237 'type' => 'text',
238 'required' => false,
239 'description' => 'Site description for meta tags',
240 'max_length' => 160
241 ]
242 ];
243
244 /**
245 * Constructor
246 *
247 * @since 1.0.0
248 */
249 /**
250 * The square derivatives wp_site_icon() asks for.
251 *
252 * Core generates these only through its own Site Icon crop flow, so an
253 * image chosen as a ThinkRank favicon straight from the media library has
254 * none of them and every sizes="" declaration is a near miss (#571).
255 *
256 * @since 2.3.1
257 * @var int[]
258 */
259 public const ICON_SIZES = [32, 180, 192, 270];
260
261 /**
262 * Transient holding resolved icon URLs, keyed by configured URL and size.
263 *
264 * The site-icon filter runs in wp_head on every FRONT-END request, and
265 * resolving a URL to its attachment costs an uncached postmeta query. The
266 * mapping only changes when the icon setting does, so it is cached here and
267 * dropped on save.
268 *
269 * @since 2.3.1
270 * @var string
271 */
272 public const ICON_URL_TRANSIENT = 'thinkrank_site_icon_urls';
273
274 /**
275 * Marker for the one-time derivative backfill on existing installs.
276 *
277 * @since 2.3.1
278 * @var string
279 */
280 public const ICON_BACKFILL_OPTION = 'thinkrank_site_icon_sizes_backfilled';
281
282 /**
283 * Whether the icon-derivative listener has been registered this request.
284 *
285 * Static because `thinkrank_seo_settings_saved` is a global hook — one
286 * listener serves every instance, and this class is constructed on the
287 * front end as well as in admin.
288 *
289 * @since 2.3.1
290 * @var bool
291 */
292 private static bool $icon_sizes_listener_registered = false;
293
294 public function __construct() {
295 parent::__construct('site_identity');
296
297 if (!self::$icon_sizes_listener_registered) {
298 self::$icon_sizes_listener_registered = true;
299 add_action('thinkrank_seo_settings_saved', [$this, 'generate_icon_sizes_on_save'], 10, 2);
300 // Admin only: resizing is not front-end work, and admin traffic is
301 // enough to run a one-time backfill promptly.
302 add_action('admin_init', [self::class, 'maybe_backfill_icon_sizes']);
303 }
304 }
305
306 /**
307 * Build the icon derivatives for a newly chosen favicon.
308 *
309 * Runs on save, which is the only moment the choice changes and the only
310 * place image work belongs — resolving a size on the front end must stay a
311 * lookup. Failure is silent by design: a missing derivative degrades to the
312 * next best file, so a site whose host cannot resize still renders an icon.
313 *
314 * @since 2.3.1
315 *
316 * @param string $manager_type Settings category that was saved.
317 * @param array $settings The settings that were written.
318 * @return void
319 */
320 public function generate_icon_sizes_on_save(string $manager_type, array $settings): void {
321 if ('site_identity' !== $manager_type) {
322 return;
323 }
324
325 // The choice, or the derivatives behind it, may have just changed.
326 delete_transient(self::ICON_URL_TRANSIENT);
327
328 foreach (['favicon_url', 'apple_touch_icon_url'] as $key) {
329 if (empty($settings[$key]) || !is_string($settings[$key])) {
330 continue;
331 }
332
333 $attachment_id = self::icon_attachment_id($settings[$key]);
334
335 if ($attachment_id) {
336 self::ensure_icon_sizes($attachment_id);
337 }
338 }
339
340 // Dropped again after the resizes finish. Resizing is not instant, and a
341 // front-end request arriving mid-generation would otherwise repopulate
342 // the transient with the pre-derivative URLs and pin them for the full
343 // TTL — leaving the sizes= declarations untrue until the next save.
344 delete_transient(self::ICON_URL_TRANSIENT);
345 }
346
347 /**
348 * Build the derivatives for a site that configured its icons before this
349 * existed.
350 *
351 * generate_icon_sizes_on_save() only fires on a settings write, so every
352 * site with an icon already chosen would keep serving whatever
353 * wp_get_attachment_image_url() could find — in practice the 150x150
354 * thumbnail behind a sizes="32x32" declaration — until someone happened to
355 * re-save Site Identity. That is the bug this is meant to fix, so the
356 * derivatives are built once on upgrade instead of waiting for a save.
357 *
358 * Guarded by its own option rather than the plugin version so it runs once
359 * and stays cheap: the check is a single autoloaded read on requests after
360 * the first.
361 *
362 * @since 2.3.1
363 *
364 * @return void
365 */
366 public static function maybe_backfill_icon_sizes(): void {
367 if (get_option(self::ICON_BACKFILL_OPTION)) {
368 return;
369 }
370
371 // Written before the work, not after: a host that cannot resize must
372 // not retry on every admin request forever.
373 update_option(self::ICON_BACKFILL_OPTION, time(), true);
374
375 $settings = (new self())->get_settings('site');
376
377 if (!is_array($settings)) {
378 return;
379 }
380
381 foreach (['favicon_url', 'apple_touch_icon_url'] as $key) {
382 if (empty($settings[$key]) || !is_string($settings[$key])) {
383 continue;
384 }
385
386 $attachment_id = self::icon_attachment_id($settings[$key]);
387
388 if ($attachment_id) {
389 self::ensure_icon_sizes($attachment_id);
390 }
391 }
392
393 delete_transient(self::ICON_URL_TRANSIENT);
394 }
395
396 /**
397 * Attachment ID behind a configured icon URL, or 0 when it is not ours.
398 *
399 * attachment_url_to_postid() matches _wp_attached_file, which holds the
400 * ORIGINAL upload path, so the URL of a generated derivative
401 * (`logo-512.png`) returns 0 — and that is exactly what the media picker
402 * hands back when the user chooses a size. Strip the dimension suffix and
403 * try the original once.
404 *
405 * Shared with SEO_Manager's site-icon filter so both sides of the feature
406 * agree on which attachment a configured URL means.
407 *
408 * @since 2.3.1
409 *
410 * @param string $url Configured icon URL.
411 * @return int Attachment ID, or 0.
412 */
413 public static function icon_attachment_id(string $url): int {
414 $attachment_id = (int) attachment_url_to_postid($url);
415
416 if ($attachment_id) {
417 return $attachment_id;
418 }
419
420 $original = preg_replace('/-\d+x\d+(?=\.[a-zA-Z0-9]+$)/', '', $url);
421
422 if (is_string($original) && $original !== $url) {
423 return (int) attachment_url_to_postid($original);
424 }
425
426 return 0;
427 }
428
429 /**
430 * Which ICON_SIZES derivatives this attachment still needs.
431 *
432 * Split out from the generation so the decision can be asserted on its
433 * own: whether a size is skipped because it already exists or because it
434 * would upscale is invisible once both answers are "nothing was built".
435 *
436 * A source is measured by its SHORTER edge — a 400x40 banner cannot yield
437 * a true 192x192 — and anything reporting no dimensions at all (SVGs) is
438 * left alone.
439 *
440 * @since 2.3.1
441 *
442 * @param array $meta Attachment metadata.
443 * @return array<string, array{width: int, height: int, crop: bool}> Sizes to build.
444 */
445 public static function missing_icon_sizes(array $meta): array {
446 $source = min((int) ($meta['width'] ?? 0), (int) ($meta['height'] ?? 0));
447
448 if ($source < 1) {
449 return [];
450 }
451
452 $wanted = [];
453 foreach (self::ICON_SIZES as $size) {
454 // Never upscale: a stretched source behind an accurate sizes=""
455 // label is worse than the honest near miss it would replace.
456 if (isset($meta['sizes']["site_icon-{$size}"]) || $size > $source) {
457 continue;
458 }
459
460 $wanted["site_icon-{$size}"] = ['width' => $size, 'height' => $size, 'crop' => true];
461 }
462
463 return $wanted;
464 }
465
466 /**
467 * Generate whatever ICON_SIZES derivatives this attachment is missing.
468 *
469 * Only the missing ones, and never one larger than the source: upscaling a
470 * small favicon would put a blurrier file behind an accurate sizes="" label
471 * than the honest near-miss it replaced.
472 *
473 * @since 2.3.1
474 *
475 * @param int $attachment_id Attachment to build derivatives for.
476 * @return string[] Size names generated, empty when there was nothing to do.
477 */
478 public static function ensure_icon_sizes(int $attachment_id): array {
479 $meta = wp_get_attachment_metadata($attachment_id);
480
481 if (!is_array($meta)) {
482 return [];
483 }
484
485 $wanted = self::missing_icon_sizes($meta);
486
487 if (empty($wanted)) {
488 return [];
489 }
490
491 $file = get_attached_file($attachment_id);
492
493 if (!$file || !file_exists($file)) {
494 return [];
495 }
496
497 $editor = wp_get_image_editor($file);
498
499 if (is_wp_error($editor)) {
500 return [];
501 }
502
503 $generated = $editor->multi_resize($wanted);
504
505 if (empty($generated)) {
506 return [];
507 }
508
509 $meta['sizes'] = array_merge($meta['sizes'] ?? [], $generated);
510 wp_update_attachment_metadata($attachment_id, $meta);
511
512 return array_keys($generated);
513 }
514
515 /**
516 * Initialize WordPress filesystem
517 *
518 * @since 1.0.0
519 * @return bool True if filesystem is initialized, false otherwise
520 */
521 private function init_filesystem(): bool {
522 if ($this->filesystem !== null) {
523 return true;
524 }
525
526 global $wp_filesystem;
527
528 if (!function_exists('WP_Filesystem')) {
529 require_once ABSPATH . 'wp-admin/includes/file.php';
530 }
531
532 $credentials = request_filesystem_credentials('', '', false, false, null);
533 if (!WP_Filesystem($credentials)) {
534 return false;
535 }
536
537 $this->filesystem = $wp_filesystem;
538 return true;
539 }
540
541 /**
542 * Check if directory is writable using WP_Filesystem
543 *
544 * @since 1.0.0
545 * @param string $path Directory path to check
546 * @return bool True if writable, false otherwise
547 */
548 private function is_directory_writable(string $path): bool {
549 if (!$this->init_filesystem()) {
550 return false;
551 }
552
553 return $this->filesystem->is_writable($path);
554 }
555
556 /**
557 * Check if file is writable using WP_Filesystem
558 *
559 * @since 1.0.0
560 * @param string $file File path to check
561 * @return bool True if writable, false otherwise
562 */
563 private function is_file_writable(string $file): bool {
564 if (!$this->init_filesystem()) {
565 return false;
566 }
567
568 return $this->filesystem->is_writable($file);
569 }
570 public function generate_title(string $template_name = 'default', array $data = [], string $context = 'site'): string {
571 // Get template
572 $template = $this->title_templates[$template_name] ?? $this->title_templates['default'];
573
574 // Get site settings
575 $settings = $this->get_settings('site');
576 $separator = $this->get_title_separator($settings['title_separator'] ?? 'pipe');
577
578 // Prepare placeholder data
579 $placeholders = $this->prepare_title_placeholders($data, $context, $settings);
580
581 // Replace placeholders
582 $title = $this->replace_title_placeholders($template, $placeholders, $separator);
583
584 // Clean and optimize title
585 $title = $this->optimize_title($title, $context);
586
587 return $title;
588 }
589
590 /**
591 * Generate breadcrumb navigation with schema markup
592 *
593 * @since 1.0.0
594 *
595 * @param string $type Breadcrumb type
596 * @param array $options Breadcrumb options
597 * @return array Breadcrumb data with schema markup
598 */
599 public function generate_breadcrumbs(string $type = 'hierarchical', array $options = []): array {
600 $breadcrumbs = [
601 'items' => [],
602 'schema' => [],
603 'html' => '',
604 'type' => $type,
605 'count' => 0
606 ];
607
608 // Get breadcrumb settings
609 $settings = $this->get_settings('site');
610 $breadcrumb_settings = $settings['breadcrumbs'] ?? [];
611
612 // Generate breadcrumb items based on type
613 switch ($type) {
614 case 'hierarchical':
615 $breadcrumbs['items'] = $this->generate_hierarchical_breadcrumbs($options);
616 break;
617 case 'taxonomy':
618 $breadcrumbs['items'] = $this->generate_taxonomy_breadcrumbs($options);
619 break;
620 case 'path':
621 $breadcrumbs['items'] = $this->generate_path_breadcrumbs($options);
622 break;
623 case 'custom':
624 $breadcrumbs['items'] = $this->generate_custom_breadcrumbs($options);
625 break;
626 }
627
628 // Generate schema markup
629 $breadcrumbs['schema'] = $this->generate_breadcrumb_schema($breadcrumbs['items']);
630
631 // Generate HTML output
632 $breadcrumbs['html'] = $this->generate_breadcrumb_html($breadcrumbs['items'], $breadcrumb_settings);
633
634 // Set count
635 $breadcrumbs['count'] = count($breadcrumbs['items']);
636
637 return $breadcrumbs;
638 }
639
640 /**
641 * Generate and manage robots.txt content
642 *
643 * @since 1.0.0
644 *
645 * @param array $custom_rules Optional custom rules to add
646 * @return array Robots.txt data and validation
647 */
648 public function generate_robots_txt(array $custom_rules = []): array {
649 $robots_data = [
650 'content' => '',
651 'rules' => [],
652 'validation' => [],
653 'file_exists' => false,
654 'writable' => false
655 ];
656
657 // Check if robots.txt file exists and is writable
658 $robots_file = ABSPATH . 'robots.txt';
659 $robots_data['file_exists'] = file_exists($robots_file);
660 $robots_data['writable'] = $this->is_directory_writable(dirname($robots_file));
661
662 // Get site settings
663 $settings = $this->get_settings('site');
664
665 // Generate default rules (pass full settings so sitemap_url is available)
666 $default_rules = $this->generate_default_robots_rules($settings);
667
668 // Merge with custom rules
669 $all_rules = array_merge($default_rules, $custom_rules);
670
671 // Validate rules
672 $robots_data['validation'] = $this->validate_robots_rules($all_rules);
673
674 // Generate robots.txt content
675 $robots_data['content'] = $this->build_robots_txt_content($all_rules);
676 $robots_data['rules'] = $all_rules;
677
678 return $robots_data;
679 }
680
681 /**
682 * Resolve the robots.txt that should actually be served.
683 *
684 * The Robots.txt textarea (`robots_txt_content`) is the source of truth the
685 * admin sees and edits; per the UI, an empty value means "auto-generate".
686 * Both the virtual `robots_txt` filter and the physical file are rendered
687 * through here so what is served always matches what the textarea shows —
688 * previously the served output was regenerated from rules and silently
689 * ignored any manual edit.
690 *
691 * @since 1.20.0
692 * @return string Robots.txt body, always newline-terminated.
693 */
694 public function render_robots_txt(): string {
695 $settings = $this->get_settings('site');
696
697 // A site-wide crawl block — "Allow Search Engines" off, or WordPress's
698 // "Discourage search engines" (Settings → Reading, blog_public=0) — must
699 // win over any custom robots.txt content. Otherwise a stored override
700 // that permits crawling would silently defeat the block on every serving
701 // and persistence path. When blocked, force the generated output, which
702 // resolves to `User-agent: * / Disallow: /` via generate_default_robots_rules().
703 $allow_search = $settings['allow_search_engines'] ?? true;
704 $fully_blocked = empty($allow_search) || !get_option('blog_public');
705
706 $custom = trim((string) ($settings['robots_txt_content'] ?? ''));
707 // A user edit may still carry the old header if it was stored before the
708 // header/body split — strip it so we don't emit two headers.
709 $body = ($custom !== '' && !$fully_blocked)
710 ? $this->strip_robots_header($custom)
711 : trim($this->generate_robots_txt()['content']);
712
713 // The per-agent AI directives are machine-owned, so they are composed
714 // here rather than stored: the textarea holds the user's body, with
715 // the fenced block stripped out of every read and re-applied on every
716 // render. A site-wide block already disallows everyone, so adding the
717 // per-agent group there would be noise restating the same refusal.
718 if (!$fully_blocked) {
719 $body = $this->apply_ai_crawler_block($body, $settings);
720 }
721
722 if ($body === '') {
723 return '';
724 }
725
726 return $this->robots_txt_header() . $body . "\n";
727 }
728
729 /**
730 * Resolve the robots.txt actually served to crawlers, with its origin.
731 *
732 * Lets an API/MCP consumer see the effective output without crawling the
733 * URL. Mirrors serving precedence: a physical robots.txt in the web root is
734 * served verbatim by the web server; otherwise the rendered content (custom
735 * override or generated defaults) is served through the `robots_txt` filter.
736 *
737 * @since 1.20.0
738 * @return array{content: string, is_default: bool, source: string} Effective
739 * robots.txt, whether it is ThinkRank's generated default (vs. a
740 * custom override), and where it originates from.
741 */
742 public function get_effective_robots_txt(): array {
743 // A real file in the web root wins — the web server serves it directly.
744 $robots_file = ABSPATH . 'robots.txt';
745 if (file_exists($robots_file) && is_readable($robots_file)) {
746 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading a public web-root file; WP_Filesystem is not available on front-end requests.
747 return [
748 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reads a local file the plugin just located; WP_Filesystem would need credentials on some hosts.
749 'content' => (string) file_get_contents($robots_file),
750 'is_default' => false,
751 'source' => 'file',
752 ];
753 }
754
755 $settings = $this->get_settings('site');
756
757 // Management disabled — WordPress serves its own core default.
758 if (empty($settings['robots_txt_enabled'])) {
759 return [
760 'content' => '',
761 'is_default' => true,
762 'source' => 'wordpress',
763 ];
764 }
765
766 // A non-empty stored override replaces the generated defaults.
767 $custom = trim((string) ($settings['robots_txt_content'] ?? ''));
768
769 return [
770 'content' => $this->render_robots_txt(),
771 'is_default' => $custom === '',
772 'source' => $custom === '' ? 'generated' : 'custom',
773 ];
774 }
775
776 /**
777 * Describe how /robots.txt is actually delivered, and whether that still
778 * matches the saved settings.
779 *
780 * The admin screen edits settings, but a physical robots.txt in the web root
781 * is served directly by the web server and bypasses the `robots_txt` filter
782 * entirely. When those two drift, the editor is showing content no crawler
783 * ever sees — the conflict this exists to surface.
784 *
785 * @since 1.31.0
786 *
787 * @return array{content: string, source: string, is_default: bool, in_sync: bool, out_of_sync_reason: string, url: string}
788 * The served content and its origin, whether it still reflects the
789 * body the editor is showing, why it does not when it does not
790 * ('file_drift' or 'crawl_blocked'), and the public URL it is
791 * served from.
792 */
793 public function get_robots_txt_delivery(): array {
794 $effective = $this->get_effective_robots_txt();
795 $settings = $this->get_settings('site');
796
797 // Compare bodies, not raw strings: the auto-generated header carries a
798 // regeneration timestamp that always differs and means nothing here.
799 // The AI crawler block is composed at render time on both sides, so it
800 // is identical by construction and comparing it would only ever report
801 // a false drift the admin cannot act on.
802 $served = $this->strip_ai_crawler_block($this->strip_robots_header($effective['content']));
803
804 // Measure against the body the editor is displaying — get_served_robots_body()
805 // — not against render_robots_txt(). Two things made the old comparison
806 // report "in sync" while the screen showed rules no crawler receives:
807 // a physical file was compared to a freshly rendered body rather than
808 // to the stored override the textarea shows, and a site-wide crawl
809 // block makes render_robots_txt() return the generated "Disallow: /"
810 // on both sides of the comparison, so it always matched.
811 $expected = $this->get_served_robots_body();
812
813 // Management off: WordPress serves its own default and the editor is not
814 // claiming anything is live, so there is nothing to be out of sync with.
815 $managed = !empty($settings['robots_txt_enabled']);
816 $in_sync = !$managed || $served === $expected;
817
818 $reason = '';
819 if (!$in_sync) {
820 // A crawl block is a deliberate override, not a stale file, and the
821 // admin needs to be told which of the two they are looking at.
822 $blocked = empty($settings['allow_search_engines'] ?? true) || !get_option('blog_public');
823 $reason = $blocked ? 'crawl_blocked' : 'file_drift';
824 }
825
826 return [
827 'content' => $effective['content'],
828 'source' => $effective['source'],
829 'is_default' => $effective['is_default'],
830 'in_sync' => $in_sync,
831 'out_of_sync_reason' => $reason,
832 'url' => home_url('/robots.txt'),
833 ];
834 }
835
836 /**
837 * Keep the physical robots.txt file in step with the saved settings.
838 *
839 * When management is enabled the physical file is the source of truth the
840 * web server serves, so this makes sure it exists and matches the effective
841 * content — creating it if missing. When management is disabled it removes
842 * any existing file so WordPress serves its default again. Callers invoke
843 * this after saving robots settings so a plain Save both creates and
844 * refreshes the file without a separate "Generate" step.
845 *
846 * @since 1.20.0
847 * @return bool True if the file was written or removed as intended.
848 */
849 public function sync_robots_txt_file(): bool {
850 $robots_file = ABSPATH . 'robots.txt';
851 $settings = $this->get_settings('site');
852
853 // Management turned off: drop any existing file so WordPress serves its
854 // default again, rather than leaving a stale ThinkRank file behind.
855 if (empty($settings['robots_txt_enabled'])) {
856 if (file_exists($robots_file) && $this->init_filesystem()) {
857 $this->filesystem->delete($robots_file);
858 }
859 return true;
860 }
861
862 $content = $this->render_robots_txt();
863 if ($content === '') {
864 return false;
865 }
866
867 // Run the effective content through the standard robots_txt filter so
868 // lines added by other integrations (ThinkRank Pro's News/Video
869 // Publisher Sitemaps at priority 999, and any third-party plugin) are
870 // baked into the physical file. A physical robots.txt bypasses core's
871 // do_robots()/robots_txt filter entirely, so without this those lines
872 // are silently dropped. ThinkRank's own filter_robots_txt callback just
873 // re-returns this same content (it calls render_robots_txt(), which does
874 // not re-apply the filter), so there is no recursion or double-append.
875 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML/core hook, not ours to name.
876 $content = (string) apply_filters('robots_txt', $content, (bool) get_option('blog_public'));
877 if ($content === '') {
878 return false;
879 }
880
881 // write_robots_txt() creates the file when absent and overwrites it
882 // otherwise, so this covers both first-time creation and re-sync.
883 $result = $this->write_robots_txt($content);
884 return !empty($result['success']);
885 }
886
887 /**
888 * Write robots.txt content to filesystem
889 *
890 * @since 1.0.0
891 *
892 * @param string $content Robots.txt content to write
893 * @return array Write operation result
894 */
895 public function write_robots_txt(string $content): array {
896 $result = [
897 'success' => false,
898 'message' => '',
899 'file_path' => '',
900 'permissions' => []
901 ];
902
903 $robots_file = ABSPATH . 'robots.txt';
904
905 // Security: Validate file path to prevent path traversal attacks
906 $real_robots_file = realpath(dirname($robots_file)) . DIRECTORY_SEPARATOR . basename($robots_file);
907 $allowed_dir = realpath(ABSPATH);
908
909 if (!$allowed_dir || strpos(dirname($real_robots_file), $allowed_dir) !== 0) {
910 $result['message'] = 'Invalid file path detected for security reasons.';
911 return $result;
912 }
913
914 $result['file_path'] = $robots_file;
915
916 // Check directory permissions
917 $result['permissions'] = [
918 'directory_writable' => $this->is_directory_writable(ABSPATH),
919 'file_exists' => file_exists($robots_file),
920 'file_writable' => file_exists($robots_file) ? $this->is_file_writable($robots_file) : null
921 ];
922
923 // Check if we can write to the directory
924 if (!$result['permissions']['directory_writable']) {
925 $result['message'] = 'WordPress root directory is not writable. Please check file permissions.';
926 return $result;
927 }
928
929 // Check if existing file is writable (if it exists)
930 if ($result['permissions']['file_exists'] && !$result['permissions']['file_writable']) {
931 $result['message'] = 'Existing robots.txt file is not writable. Please check file permissions.';
932 return $result;
933 }
934
935 try {
936 // Write new content using WP_Filesystem
937 if (!$this->init_filesystem()) {
938 $result['message'] = 'Could not initialize WordPress filesystem.';
939 return $result;
940 }
941
942 $write_success = $this->filesystem->put_contents($robots_file, $content, FS_CHMOD_FILE);
943
944 if ($write_success) {
945 $result['success'] = true;
946 $result['message'] = 'Robots.txt file written successfully.';
947 $result['bytes_written'] = strlen($content);
948 } else {
949 $result['message'] = 'Failed to write robots.txt file.';
950 }
951 } catch (\Exception $e) {
952 $result['message'] = 'Error writing robots.txt file: ' . $e->getMessage();
953 }
954
955 return $result;
956 }
957
958 /**
959 * Optimize site identity data with comprehensive analysis
960 *
961 * @since 1.0.0
962 *
963 * @param array $identity_data Site identity data to optimize
964 * @param array $options Optimization options including section focus
965 * @return array Optimized identity data with validation
966 */
967 public function optimize_site_identity(array $identity_data, array $options = []): array {
968 $optimization = [
969 'optimized_data' => [],
970 'validation' => [],
971 'suggestions' => [],
972 'warnings' => [],
973 'improvements' => [],
974 'score' => 0,
975 'section_scores' => []
976 ];
977
978 // Determine optimization focus
979 $focus = $options['focus'] ?? 'all';
980
981 // Section-specific optimization
982 if ($focus === 'title_formats' || $focus === 'all') {
983 $title_optimization = $this->optimize_title_formats($identity_data);
984 $optimization['section_scores']['title_formats'] = $title_optimization['score'];
985 $optimization['suggestions'] = array_merge($optimization['suggestions'], $title_optimization['suggestions']);
986 $optimization['warnings'] = array_merge($optimization['warnings'], $title_optimization['warnings']);
987 }
988
989 if ($focus === 'breadcrumbs' || $focus === 'all') {
990 $breadcrumb_optimization = $this->optimize_breadcrumbs($identity_data);
991 $optimization['section_scores']['breadcrumbs'] = $breadcrumb_optimization['score'];
992 $optimization['suggestions'] = array_merge($optimization['suggestions'], $breadcrumb_optimization['suggestions']);
993 $optimization['warnings'] = array_merge($optimization['warnings'], $breadcrumb_optimization['warnings']);
994 }
995
996 if ($focus === 'robots_txt' || $focus === 'all') {
997 $robots_optimization = $this->optimize_robots_txt($identity_data);
998 $optimization['section_scores']['robots_txt'] = $robots_optimization['score'];
999 $optimization['suggestions'] = array_merge($optimization['suggestions'], $robots_optimization['suggestions']);
1000 $optimization['warnings'] = array_merge($optimization['warnings'], $robots_optimization['warnings']);
1001 }
1002
1003 if ($focus === 'site_assets' || $focus === 'all') {
1004 $assets_optimization = $this->optimize_site_assets($identity_data);
1005 $optimization['section_scores']['site_assets'] = $assets_optimization['score'];
1006 $optimization['suggestions'] = array_merge($optimization['suggestions'], $assets_optimization['suggestions']);
1007 $optimization['warnings'] = array_merge($optimization['warnings'], $assets_optimization['warnings']);
1008 }
1009
1010 // Legacy element-by-element optimization for basic site info
1011 if ($focus === 'site_info' || $focus === 'all') {
1012 foreach ($this->identity_elements as $element => $config) {
1013 if (isset($identity_data[$element])) {
1014 $element_optimization = $this->optimize_identity_element(
1015 $element,
1016 $identity_data[$element],
1017 $config
1018 );
1019
1020 $optimization['optimized_data'][$element] = $element_optimization['optimized_value'];
1021 $optimization['validation'][$element] = $element_optimization['validation'];
1022 $optimization['suggestions'] = array_merge(
1023 $optimization['suggestions'],
1024 $element_optimization['suggestions']
1025 );
1026 }
1027 }
1028 }
1029
1030 // Calculate overall optimization score
1031 if (!empty($optimization['section_scores'])) {
1032 $optimization['score'] = (int) round(array_sum($optimization['section_scores']) / count($optimization['section_scores']));
1033 } else {
1034 $optimization['score'] = $this->calculate_identity_score($optimization['validation']);
1035 }
1036
1037 // Store optimization results in seo_analysis table
1038 $this->store_optimization_results($optimization, $focus);
1039
1040 return $optimization;
1041 }
1042
1043 /**
1044 * Optimize title formats with enhanced rules
1045 *
1046 * @since 1.0.0
1047 *
1048 * @param array $settings Title format settings
1049 * @return array Optimization results
1050 */
1051 public function optimize_title_formats(array $settings): array {
1052 $optimization = [
1053 'score' => 100,
1054 'suggestions' => [],
1055 'warnings' => [],
1056 'improvements' => []
1057 ];
1058
1059 // Check separator choice (applies to every context template).
1060 $separator = $settings['title_separator'] ?? 'pipe';
1061 $separator_data = self::$title_separators[$separator] ?? null;
1062 if ($separator_data) {
1063 $seo_score = $separator_data['seo_score'] ?? 5;
1064 if ($seo_score < 8) {
1065 $optimization['suggestions'][] = "Consider using '|' or '-' separators for better SEO performance";
1066 $optimization['score'] -= (10 - $seo_score);
1067 }
1068 }
1069
1070 // Analyze the per-context templates the Title Formats UI actually edits
1071 // and the front end actually renders — not the legacy `title_template`
1072 // enum, which this screen never sets.
1073 $context_labels = [
1074 'homepage_title' => 'Homepage',
1075 'post_title' => 'Post',
1076 'page_title' => 'Page',
1077 'category_title' => 'Category',
1078 'tag_title' => 'Tag',
1079 'author_title' => 'Author',
1080 'search_title' => 'Search',
1081 'archive_title' => 'Archive',
1082 ];
1083
1084 $configured = 0;
1085 foreach ($context_labels as $key => $label) {
1086 $template = isset($settings[$key]) ? trim((string) $settings[$key]) : '';
1087 if ($template === '') {
1088 continue; // Unconfigured — the front end falls back for this context.
1089 }
1090 $configured++;
1091
1092 // Brand recognition: the title should carry the site name.
1093 if (strpos($template, '%site_title%') === false && strpos($template, '%site_name%') === false) {
1094 $optimization['suggestions'][] = "{$label} title has no site name — add %site_title% for brand recognition";
1095 $optimization['score'] -= 5;
1096 }
1097
1098 // Length check against the ~60-char guideline, measured on the
1099 // resolved title for THIS context (with representative sample data).
1100 $sample_length = strlen($this->generate_sample_title($settings, $key));
1101 if ($sample_length > 60) {
1102 $optimization['warnings'][] = "{$label} title renders about {$sample_length} characters (over the 60-character limit)";
1103 $optimization['score'] -= 10;
1104 } elseif ($sample_length > 0 && $sample_length < 20) {
1105 $optimization['suggestions'][] = "{$label} title renders only about {$sample_length} characters — consider adding more context";
1106 $optimization['score'] -= 3;
1107 }
1108 }
1109
1110 // No context templates set at all — ThinkRank won't control any titles.
1111 if ($configured === 0) {
1112 $optimization['suggestions'][] = 'No title formats are configured — set templates so ThinkRank controls your page titles';
1113 $optimization['score'] -= 10;
1114 }
1115
1116 $optimization['score'] = max(0, min(100, $optimization['score']));
1117
1118 return $optimization;
1119 }
1120
1121 /**
1122 * Optimize breadcrumb settings with UX best practices
1123 *
1124 * @since 1.0.0
1125 *
1126 * @param array $settings Breadcrumb settings
1127 * @return array Optimization results
1128 */
1129 public function optimize_breadcrumbs(array $settings): array {
1130 $optimization = [
1131 'score' => 100,
1132 'suggestions' => [],
1133 'warnings' => [],
1134 'improvements' => []
1135 ];
1136
1137 // Check if breadcrumbs are enabled
1138 if (!($settings['breadcrumbs_enabled'] ?? true)) {
1139 $optimization['suggestions'][] = 'Enable breadcrumbs to improve user navigation and SEO (recommended by Google)';
1140 $optimization['score'] = 20; // Major penalty for disabled breadcrumbs
1141 return $optimization;
1142 }
1143
1144 // Validate breadcrumb type
1145 $type = $settings['breadcrumb_type'] ?? 'hierarchical';
1146 $type_scores = [
1147 'hierarchical' => 100,
1148 'category_based' => 90,
1149 'simple' => 70,
1150 'custom' => 80
1151 ];
1152
1153 $type_score = $type_scores[$type] ?? 60;
1154 $optimization['score'] = min($optimization['score'], $type_score);
1155
1156 if ($type === 'simple') {
1157 $optimization['suggestions'][] = 'Consider hierarchical breadcrumbs for better site structure representation';
1158 }
1159
1160 // Validate separator choice
1161 $separator = $settings['breadcrumb_separator'] ?? '';
1162 $separator_ux = [
1163 '' => ['score' => 100, 'note' => 'Clear directional indicator'],
1164 '>' => ['score' => 95, 'note' => 'Simple and effective'],
1165 '/' => ['score' => 85, 'note' => 'Familiar but can confuse with URLs'],
1166 '|' => ['score' => 75, 'note' => 'Less intuitive for navigation'],
1167 '»' => ['score' => 90, 'note' => 'Distinctive double arrow']
1168 ];
1169
1170 $sep_data = $separator_ux[$separator] ?? ['score' => 50, 'note' => 'Unusual choice'];
1171 $optimization['score'] = min($optimization['score'], $sep_data['score']);
1172
1173 if ($sep_data['score'] < 95) {
1174 $optimization['suggestions'][] = "Separator '{$separator}': {$sep_data['note']}";
1175 }
1176
1177 // Validate home text
1178 $home_text = $settings['breadcrumb_home_text'] ?? 'Home';
1179 if (empty($home_text)) {
1180 $optimization['warnings'][] = 'Empty home text reduces accessibility for screen readers';
1181 $optimization['score'] -= 15;
1182 } elseif (strlen($home_text) > 20) {
1183 $optimization['suggestions'][] = 'Keep home text concise (current: ' . strlen($home_text) . ' chars)';
1184 $optimization['score'] -= 5;
1185 }
1186
1187 // Check prefix usage
1188 $prefix = $settings['breadcrumb_prefix'] ?? '';
1189 if (!empty($prefix) && strlen($prefix) > 50) {
1190 $optimization['suggestions'][] = 'Breadcrumb prefix is quite long (' . strlen($prefix) . ' chars) - consider shortening';
1191 $optimization['score'] -= 5;
1192 }
1193
1194 // Current page display
1195 if (!($settings['show_current_page'] ?? true)) {
1196 $optimization['suggestions'][] = 'Show current page in breadcrumbs for better user orientation';
1197 $optimization['score'] -= 10;
1198 }
1199
1200 return $optimization;
1201 }
1202
1203 /**
1204 * Optimize robots.txt settings with technical SEO best practices
1205 *
1206 * @since 1.0.0
1207 *
1208 * @param array $settings Robots.txt settings
1209 * @return array Optimization results
1210 */
1211 public function optimize_robots_txt(array $settings): array {
1212 $optimization = [
1213 'score' => 100,
1214 'suggestions' => [],
1215 'warnings' => [],
1216 'improvements' => []
1217 ];
1218
1219 // Check if robots.txt management is enabled
1220 if (!($settings['robots_txt_enabled'] ?? true)) {
1221 $optimization['suggestions'][] = 'Enable robots.txt management for better SEO control and automated updates';
1222 $optimization['score'] = 30;
1223 return $optimization;
1224 }
1225
1226 // Critical: Search engine access
1227 if (!($settings['allow_search_engines'] ?? true)) {
1228 $optimization['warnings'][] = 'CRITICAL: Search engines are blocked - your site will not be indexed by Google, Bing, etc.';
1229 $optimization['score'] = 10; // Severe penalty
1230 }
1231
1232 // Sitemap URL validation (now from sitemap settings)
1233 $sitemap_urls = $this->get_sitemap_urls_for_robots();
1234 if (empty($sitemap_urls)) {
1235 $optimization['suggestions'][] = 'Enable sitemap generation to include sitemap URLs in robots.txt';
1236 $optimization['score'] -= 15;
1237 } else {
1238 // Validate first sitemap accessibility (representative check)
1239 $first_sitemap = $sitemap_urls[0];
1240 $sitemap_response = wp_remote_head($first_sitemap, ['timeout' => 10]);
1241 if (is_wp_error($sitemap_response) || wp_remote_retrieve_response_code($sitemap_response) !== 200) {
1242 $optimization['warnings'][] = 'Primary sitemap URL is not accessible - check sitemap generation';
1243 $optimization['score'] -= 10;
1244 }
1245 }
1246
1247 // File system permissions
1248 $robots_file = ABSPATH . 'robots.txt';
1249 $robots_dir = dirname($robots_file);
1250
1251 if (!$this->is_directory_writable($robots_dir)) {
1252 $optimization['warnings'][] = 'WordPress root directory is not writable - robots.txt cannot be managed automatically';
1253 $optimization['score'] -= 15;
1254 } elseif (file_exists($robots_file) && !$this->is_file_writable($robots_file)) {
1255 $optimization['warnings'][] = 'Existing robots.txt file is not writable - cannot update automatically';
1256 $optimization['score'] -= 10;
1257 }
1258
1259 // Content analysis
1260 $custom_content = $settings['robots_txt_content'] ?? '';
1261 if (!empty($custom_content)) {
1262 // Check for dangerous patterns
1263 if (preg_match('/User-agent:\s*\*\s*\n\s*Disallow:\s*\/\s*$/m', $custom_content)) {
1264 $optimization['warnings'][] = 'Blocking all content for all crawlers - this will prevent search engine indexing';
1265 $optimization['score'] -= 30;
1266 }
1267
1268 // Check for sitemap declaration in content
1269 if (!empty($sitemap_urls) && strpos($custom_content, 'Sitemap:') === false) {
1270 $optimization['suggestions'][] = 'Sitemap URLs are automatically included in generated robots.txt';
1271 $optimization['score'] -= 5;
1272 }
1273 }
1274
1275 return $optimization;
1276 }
1277
1278 /**
1279 * Optimize site assets (logo, favicon, apple touch icon)
1280 *
1281 * @since 1.0.0
1282 *
1283 * @param array $settings Site assets settings
1284 * @return array Optimization results
1285 */
1286 public function optimize_site_assets(array $settings): array {
1287 $optimization = [
1288 'score' => 100,
1289 'suggestions' => [],
1290 'warnings' => [],
1291 'improvements' => []
1292 ];
1293
1294 // Check site logo
1295 $logo_url = $settings['logo_url'] ?? '';
1296 if (empty($logo_url)) {
1297 $optimization['suggestions'][] = 'Add a site logo for better branding and professional appearance';
1298 $optimization['score'] -= 20;
1299 } else {
1300 // Validate logo URL and dimensions
1301 if (!filter_var($logo_url, FILTER_VALIDATE_URL)) {
1302 $optimization['warnings'][] = 'Logo URL format is invalid';
1303 $optimization['score'] -= 15;
1304 }
1305 }
1306
1307 // Check favicon
1308 $favicon_url = $settings['favicon_url'] ?? '';
1309 if (empty($favicon_url)) {
1310 $optimization['suggestions'][] = 'Add a favicon for better browser tab identification';
1311 $optimization['score'] -= 15;
1312 }
1313
1314 // Check Apple touch icon
1315 $apple_icon_url = $settings['apple_touch_icon_url'] ?? '';
1316 if (empty($apple_icon_url)) {
1317 $optimization['suggestions'][] = 'Add an Apple touch icon for better iOS device experience';
1318 $optimization['score'] -= 10;
1319 }
1320
1321 // Additional logo analysis for local images
1322 if (!empty($logo_url) && filter_var($logo_url, FILTER_VALIDATE_URL)) {
1323 $attachment_id = attachment_url_to_postid($logo_url);
1324 if ($attachment_id) {
1325 $image_meta = wp_get_attachment_metadata($attachment_id);
1326 $width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0;
1327 $height = isset($image_meta['height']) ? (int) $image_meta['height'] : 0;
1328
1329 // SVG logos store 0x0 metadata — no dimension/ratio analysis
1330 // is possible (and dividing by 0 is fatal).
1331 if ($image_meta && $width > 0 && $height > 0) {
1332 if ($width < 112 || $height < 112) {
1333 $optimization['warnings'][] = "Logo dimensions ({$width}x{$height}) are below recommended minimum (112x112)";
1334 $optimization['score'] -= 10;
1335 }
1336
1337 if ($width > 1920 || $height > 1920) {
1338 $optimization['suggestions'][] = "Logo dimensions ({$width}x{$height}) are very large - consider optimizing for faster loading";
1339 $optimization['score'] -= 5;
1340 }
1341
1342 // Aspect ratio check
1343 $ratio = $width / $height;
1344 if ($ratio < 0.5 || $ratio > 2.0) {
1345 $optimization['suggestions'][] = 'Logo aspect ratio should be between 1:2 and 2:1 for optimal display';
1346 $optimization['score'] -= 5;
1347 }
1348 }
1349 }
1350 }
1351
1352 return $optimization;
1353 }
1354
1355 /**
1356 * Optimize local SEO settings for better local search visibility
1357 *
1358 * @since 1.0.0
1359 *
1360 * @param array $settings Local SEO settings
1361 * @return array Optimization results
1362 */
1363 public function optimize_local_seo(array $settings): array {
1364 $optimization = [
1365 'score' => 100,
1366 'suggestions' => [],
1367 'warnings' => [],
1368 'improvements' => [],
1369 'optimized_data' => []
1370 ];
1371
1372 // Check if local SEO is enabled
1373 if (empty($settings['local_seo_enabled'])) {
1374 $optimization['warnings'][] = 'Local SEO is disabled - enable it to improve local search visibility';
1375 $optimization['score'] -= 20;
1376 return $optimization;
1377 }
1378
1379 // Validate business name (required for local SEO)
1380 if (empty($settings['business_name'])) {
1381 $optimization['warnings'][] = 'Business name is required for local SEO';
1382 $optimization['score'] -= 25;
1383 } else {
1384 // Optimize business name
1385 $optimized_name = $this->optimize_business_name($settings['business_name']);
1386 if ($optimized_name !== $settings['business_name']) {
1387 $optimization['optimized_data']['business_name'] = $optimized_name;
1388 $optimization['suggestions'][] = 'Business name optimized for better local search visibility';
1389 }
1390 }
1391
1392 // Validate complete address (NAP consistency)
1393 $address_score = $this->validate_business_address($settings, $optimization);
1394 $optimization['score'] -= (100 - $address_score);
1395
1396 // Validate phone number
1397 if (empty($settings['business_phone'])) {
1398 $optimization['warnings'][] = 'Business phone number is missing - important for local SEO and NAP consistency';
1399 $optimization['score'] -= 15;
1400 } else {
1401 $optimized_phone = $this->optimize_phone_number($settings['business_phone']);
1402 if ($optimized_phone !== $settings['business_phone']) {
1403 $optimization['optimized_data']['business_phone'] = $optimized_phone;
1404 $optimization['suggestions'][] = 'Phone number formatted for better consistency';
1405 }
1406 }
1407
1408 // Validate business hours
1409 if (empty($settings['business_hours']) || !is_array($settings['business_hours'])) {
1410 $optimization['suggestions'][] = 'Add business hours to improve local search visibility and customer experience';
1411 $optimization['score'] -= 10;
1412 } else {
1413 $hours_validation = $this->validate_business_hours($settings['business_hours']);
1414 if (!$hours_validation['valid']) {
1415 $optimization['warnings'] = array_merge($optimization['warnings'], $hours_validation['warnings']);
1416 $optimization['score'] -= $hours_validation['penalty'];
1417 }
1418 }
1419
1420 // Check for geo-coordinates
1421 if (empty($settings['business_latitude']) || empty($settings['business_longitude'])) {
1422 $optimization['suggestions'][] = 'Add latitude and longitude coordinates for precise location targeting';
1423 $optimization['score'] -= 10;
1424 } else {
1425 // Validate coordinates
1426 if (!$this->validate_coordinates($settings['business_latitude'], $settings['business_longitude'])) {
1427 $optimization['warnings'][] = 'Invalid latitude or longitude coordinates';
1428 $optimization['score'] -= 15;
1429 }
1430 }
1431
1432 // Business type validation
1433 if (empty($settings['business_type'])) {
1434 $optimization['suggestions'][] = 'Select a specific business type for better schema markup';
1435 $optimization['score'] -= 5;
1436 }
1437
1438 // Email validation
1439 if (!empty($settings['business_email']) && !is_email($settings['business_email'])) {
1440 $optimization['warnings'][] = 'Business email format is invalid';
1441 $optimization['score'] -= 10;
1442 }
1443
1444 // Local SEO best practices
1445 $this->add_local_seo_best_practices($optimization, $settings);
1446
1447 return $optimization;
1448 }
1449
1450 /**
1451 * Optimize business name for local SEO
1452 *
1453 * @param string $business_name Original business name
1454 * @return string Optimized business name
1455 */
1456 private function optimize_business_name(string $business_name): string {
1457 // Remove excessive punctuation and normalize spacing
1458 $optimized = preg_replace('/[^\w\s\-&.,]/', '', $business_name);
1459 $optimized = preg_replace('/\s+/', ' ', $optimized);
1460 $optimized = trim($optimized);
1461
1462 // Ensure proper capitalization
1463 $optimized = ucwords(strtolower($optimized));
1464
1465 return $optimized;
1466 }
1467
1468 /**
1469 * Validate business address components
1470 *
1471 * @param array $settings Business settings
1472 * @param array &$optimization Optimization results (passed by reference)
1473 * @return int Address completeness score (0-100)
1474 */
1475 private function validate_business_address(array $settings, array &$optimization): int {
1476 $score = 100;
1477 $required_fields = ['business_address', 'business_city', 'business_state', 'business_country'];
1478 $missing_fields = [];
1479
1480 foreach ($required_fields as $field) {
1481 if (empty($settings[$field])) {
1482 $missing_fields[] = str_replace('business_', '', $field);
1483 $score -= 20;
1484 }
1485 }
1486
1487 if (!empty($missing_fields)) {
1488 $optimization['warnings'][] = 'Missing address components: ' . implode(', ', $missing_fields) . ' - important for NAP consistency';
1489 }
1490
1491 // Postal code is recommended but not required
1492 if (empty($settings['business_postal_code'])) {
1493 $optimization['suggestions'][] = 'Add postal code for more precise location targeting';
1494 $score -= 5;
1495 }
1496
1497 return max(0, $score);
1498 }
1499
1500 /**
1501 * Optimize phone number format for consistency
1502 *
1503 * @param string $phone_number Original phone number
1504 * @return string Optimized phone number
1505 */
1506 private function optimize_phone_number(string $phone_number): string {
1507 // Remove all non-numeric characters except + for international numbers
1508 $cleaned = preg_replace('/[^\d+]/', '', $phone_number);
1509
1510 // If it's a US number (10 digits), format as (XXX) XXX-XXXX
1511 if (preg_match('/^(\d{10})$/', $cleaned, $matches)) {
1512 return '(' . substr($matches[1], 0, 3) . ') ' . substr($matches[1], 3, 3) . '-' . substr($matches[1], 6);
1513 }
1514
1515 // If it's a US number with country code, format as +1 (XXX) XXX-XXXX
1516 if (preg_match('/^1(\d{10})$/', $cleaned, $matches)) {
1517 return '+1 (' . substr($matches[1], 0, 3) . ') ' . substr($matches[1], 3, 3) . '-' . substr($matches[1], 6);
1518 }
1519
1520 // For international numbers, keep the + and return as-is
1521 return $cleaned;
1522 }
1523
1524 /**
1525 * Validate business hours format and completeness
1526 *
1527 * @param array $business_hours Business hours array
1528 * @return array Validation results
1529 */
1530 private function validate_business_hours(array $business_hours): array {
1531 $validation = [
1532 'valid' => true,
1533 'warnings' => [],
1534 'penalty' => 0
1535 ];
1536
1537 $days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
1538 $open_days = 0;
1539
1540 foreach ($days as $day) {
1541 if (!isset($business_hours[$day])) {
1542 continue;
1543 }
1544
1545 $day_data = $business_hours[$day];
1546
1547 if (empty($day_data['closed'])) {
1548 $open_days++;
1549
1550 // Validate time format
1551 if (empty($day_data['open']) || empty($day_data['close'])) {
1552 $validation['warnings'][] = "Missing opening or closing time for {$day}";
1553 $validation['penalty'] += 2;
1554 } else {
1555 // Validate time format (HH:MM)
1556 if (!preg_match('/^\d{2}:\d{2}$/', $day_data['open']) || !preg_match('/^\d{2}:\d{2}$/', $day_data['close'])) {
1557 $validation['warnings'][] = "Invalid time format for {$day} (use HH:MM format)";
1558 $validation['penalty'] += 2;
1559 }
1560 }
1561 }
1562 }
1563
1564 if ($open_days === 0) {
1565 $validation['warnings'][] = 'No business hours specified - all days marked as closed';
1566 $validation['penalty'] += 10;
1567 }
1568
1569 if ($validation['penalty'] > 0) {
1570 $validation['valid'] = false;
1571 }
1572
1573 return $validation;
1574 }
1575
1576 /**
1577 * Validate latitude and longitude coordinates
1578 *
1579 * @param string $latitude Latitude coordinate
1580 * @param string $longitude Longitude coordinate
1581 * @return bool True if coordinates are valid
1582 */
1583 private function validate_coordinates(string $latitude, string $longitude): bool {
1584 $lat = floatval($latitude);
1585 $lng = floatval($longitude);
1586
1587 // Validate latitude range (-90 to 90)
1588 if ($lat < -90 || $lat > 90) {
1589 return false;
1590 }
1591
1592 // Validate longitude range (-180 to 180)
1593 if ($lng < -180 || $lng > 180) {
1594 return false;
1595 }
1596
1597 return true;
1598 }
1599
1600 /**
1601 * Add local SEO best practices suggestions
1602 *
1603 * @param array &$optimization Optimization results (passed by reference)
1604 * @param array $settings Business settings
1605 * @return void
1606 */
1607 private function add_local_seo_best_practices(array &$optimization, array $settings): void {
1608 // Check for Google My Business integration
1609 if (empty($settings['google_my_business_url'])) {
1610 $optimization['suggestions'][] = 'Consider adding your Google My Business profile URL for better local visibility';
1611 }
1612
1613 // Check for social media profiles
1614 $social_platforms = ['facebook_url', 'twitter_url', 'instagram_url', 'linkedin_url'];
1615 $has_social = false;
1616 foreach ($social_platforms as $platform) {
1617 if (!empty($settings[$platform])) {
1618 $has_social = true;
1619 break;
1620 }
1621 }
1622
1623 if (!$has_social) {
1624 $optimization['suggestions'][] = 'Add social media profiles to improve local business credibility';
1625 }
1626
1627 // Check for business description
1628 if (empty($settings['business_description'])) {
1629 $optimization['suggestions'][] = 'Add a business description for better context in local search results';
1630 }
1631
1632 // Service area suggestions
1633 if (empty($settings['service_areas'])) {
1634 $optimization['suggestions'][] = 'Define service areas if your business serves multiple locations';
1635 }
1636 }
1637
1638 /**
1639 * Generate a sample rendered title for a given context template, so the
1640 * optimizer can measure the length users will actually see.
1641 *
1642 * Resolves the per-context template (e.g. `post_title`) with representative
1643 * sample values for the same variable tokens the front-end renderer fills
1644 * in (see SEO_Manager::get_title_placeholders()).
1645 *
1646 * @since 1.0.0
1647 *
1648 * @param array $settings Title format settings
1649 * @param string $context_key Per-context template key (e.g. 'post_title')
1650 * @return string Resolved sample title (empty string when the template is unset)
1651 */
1652 private function generate_sample_title(array $settings, string $context_key = 'post_title'): string {
1653 $template = isset($settings[$context_key]) ? trim((string) $settings[$context_key]) : '';
1654 if ($template === '') {
1655 return '';
1656 }
1657
1658 $separator = $settings['title_separator'] ?? 'pipe';
1659 $separator_symbol = self::$title_separators[$separator]['symbol'] ?? '|';
1660
1661 $site_name = $settings['site_name'] ?? '';
1662 if ($site_name === '') {
1663 $site_name = get_bloginfo('name') ?: 'Your Site Name';
1664 }
1665 $site_description = $settings['site_description'] ?? '';
1666 if ($site_description === '') {
1667 $site_description = get_bloginfo('description') ?: 'Your Site Description';
1668 }
1669 $tagline = $settings['tagline'] ?? '';
1670 if ($tagline === '') {
1671 $tagline = $site_description;
1672 }
1673
1674 // Representative sample values for the variable tokens the front end
1675 // substitutes per request. Keys mirror get_title_placeholders().
1676 $sample_data = [
1677 '%site_title%' => $site_name,
1678 '%site_name%' => $site_name,
1679 '%site_description%' => $site_description,
1680 '%tagline%' => $tagline,
1681 '%sep%' => ' ' . $separator_symbol . ' ',
1682 '%separator%' => ' ' . $separator_symbol . ' ',
1683 '%post_title%' => 'How to Optimize Your Website for Better SEO Results',
1684 '%page_title%' => 'About Our Company',
1685 '%category_title%' => 'SEO Tips',
1686 '%category%' => 'SEO Tips',
1687 '%tag_title%' => 'On-Page SEO',
1688 '%tag%' => 'On-Page SEO',
1689 '%author_name%' => 'Jane Doe',
1690 '%author%' => 'Jane Doe',
1691 '%search_term%' => 'keyword research',
1692 '%search_phrase%' => 'keyword research',
1693 '%archive_title%' => 'July 2026',
1694 '%date%' => gmdate('F Y'),
1695 ];
1696
1697 $title = str_replace(array_keys($sample_data), array_values($sample_data), $template);
1698
1699 // Collapse whitespace left by any empty/unresolved tokens, then trim.
1700 $title = preg_replace('/\s+/', ' ', $title);
1701
1702 return trim($title);
1703 }
1704
1705 /**
1706 * Store optimization results in seo_analysis table
1707 *
1708 * @since 1.0.0
1709 *
1710 * @param array $optimization Optimization results
1711 * @param string $focus Optimization focus section
1712 * @return void
1713 */
1714 private function store_optimization_results(array $optimization, string $focus): void {
1715 global $wpdb;
1716
1717 $table_name = $wpdb->prefix . 'thinkrank_seo_analysis';
1718
1719 // Only store if we have meaningful results
1720 if (empty($optimization['suggestions']) && empty($optimization['warnings'])) {
1721 return;
1722 }
1723
1724 $analysis_type = 'site_identity_rule_optimization';
1725 if ($focus !== 'all') {
1726 $analysis_type .= '_' . $focus;
1727 }
1728
1729 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Site identity analysis storage requires direct database access
1730 $wpdb->insert(
1731 $table_name,
1732 [
1733 'context_type' => 'site',
1734 'context_id' => null,
1735 'analysis_type' => $analysis_type,
1736 'analysis_data' => wp_json_encode($optimization),
1737 'score' => $optimization['score'],
1738 'status' => 'completed',
1739 'recommendations' => wp_json_encode($optimization['suggestions']),
1740 'validation_errors' => wp_json_encode($optimization['warnings']),
1741 'analyzed_by' => get_current_user_id()
1742 ],
1743 ['%s', '%d', '%s', '%s', '%d', '%s', '%s', '%s', '%d']
1744 );
1745 }
1746
1747 /**
1748 * Validate SEO settings (implements interface)
1749 *
1750 * @since 1.0.0
1751 *
1752 * @param array $settings Settings array to validate
1753 * @param string $tab_context Optional tab context for specific validation
1754 * @return array Validation results
1755 */
1756 public function validate_settings(array $settings, string $tab_context = ''): array {
1757 // If tab context is provided, use tab-specific validation
1758 if (!empty($tab_context)) {
1759 return $this->get_tab_specific_validation($settings, $tab_context);
1760 }
1761
1762 // Default comprehensive validation for backward compatibility
1763 $validation = [
1764 'valid' => true,
1765 'errors' => [],
1766 'warnings' => [],
1767 'suggestions' => [],
1768 'score' => 100
1769 ];
1770
1771 // Validate title template
1772 if (isset($settings['title_template'])) {
1773 if (!isset($this->title_templates[$settings['title_template']])) {
1774 $validation['errors'][] = 'Invalid title template specified';
1775 $validation['valid'] = false;
1776 }
1777 }
1778
1779 // Validate title separator
1780 if (isset($settings['title_separator'])) {
1781 if (!isset(self::$title_separators[$settings['title_separator']])) {
1782 $validation['errors'][] = __('Invalid title separator specified.', 'thinkrank');
1783 $validation['valid'] = false;
1784 }
1785 }
1786
1787 // Validate site name
1788 if (isset($settings['site_name'])) {
1789 if (empty($settings['site_name'])) {
1790 $validation['errors'][] = 'Site name is required';
1791 $validation['valid'] = false;
1792 } elseif (strlen($settings['site_name']) > 60) {
1793 $validation['warnings'][] = 'Site name is longer than 60 characters, may be truncated';
1794 }
1795 }
1796
1797 // Validate site description
1798 if (isset($settings['site_description']) && !empty($settings['site_description'])) {
1799 if (strlen($settings['site_description']) > 160) {
1800 $validation['warnings'][] = 'Site description is longer than 160 characters, may be truncated';
1801 } elseif (strlen($settings['site_description']) < 120) {
1802 $validation['suggestions'][] = 'Consider making site description longer (120-160 characters)';
1803 }
1804 }
1805
1806 // Validate logo URL
1807 if (isset($settings['logo_url']) && !empty($settings['logo_url'])) {
1808 if (!filter_var($settings['logo_url'], FILTER_VALIDATE_URL)) {
1809 $validation['errors'][] = 'Logo URL must be a valid URL';
1810 $validation['valid'] = false;
1811 }
1812 }
1813
1814 // Validate breadcrumb settings
1815 if (isset($settings['breadcrumb_type'])) {
1816 if (!isset($this->breadcrumb_types[$settings['breadcrumb_type']])) {
1817 $validation['errors'][] = 'Invalid breadcrumb type specified';
1818 $validation['valid'] = false;
1819 }
1820 }
1821
1822 // Validate robots.txt settings
1823 if (isset($settings['robots_txt_enabled']) && $settings['robots_txt_enabled']) {
1824 if (!$this->is_directory_writable(ABSPATH)) {
1825 $validation['warnings'][] = 'WordPress root directory is not writable, robots.txt cannot be automatically managed';
1826 }
1827 }
1828
1829 // Validate local SEO settings if enabled
1830 if (isset($settings['local_seo_enabled']) && $settings['local_seo_enabled']) {
1831 $local_seo_validation = $this->validate_local_seo_settings($settings);
1832 $validation['errors'] = array_merge($validation['errors'], $local_seo_validation['errors']);
1833 $validation['warnings'] = array_merge($validation['warnings'], $local_seo_validation['warnings']);
1834 $validation['suggestions'] = array_merge($validation['suggestions'], $local_seo_validation['suggestions']);
1835
1836 if (!$local_seo_validation['valid']) {
1837 $validation['valid'] = false;
1838 }
1839 }
1840
1841 // Calculate validation score
1842 $validation['score'] = $this->calculate_validation_score($validation);
1843
1844 // Add detailed field validation breakdown for generic validation
1845 $validation['field_details'] = $this->get_detailed_field_validation($settings, '');
1846
1847 return $validation;
1848 }
1849
1850 /**
1851 * Get tab-specific validation
1852 *
1853 * @since 1.0.0
1854 *
1855 * @param array $settings Settings array to validate
1856 * @param string $tab_context Tab context for specific validation
1857 * @return array Tab-specific validation results
1858 */
1859 private function get_tab_specific_validation(array $settings, string $tab_context): array {
1860 $validation = [
1861 'valid' => true,
1862 'errors' => [],
1863 'warnings' => [],
1864 'suggestions' => [],
1865 'score' => 100
1866 ];
1867
1868 // Get tab-specific field details
1869 $field_details = $this->get_detailed_field_validation($settings, $tab_context);
1870
1871 // Convert field details to validation format
1872 foreach ($field_details as $field) {
1873 switch ($field['status']) {
1874 case 'error':
1875 $validation['errors'][] = $field['label'];
1876 $validation['valid'] = false;
1877 $validation['score'] -= 20;
1878 break;
1879 case 'warning':
1880 $validation['warnings'][] = $field['label'];
1881 $validation['score'] -= 10;
1882 break;
1883 case 'suggestion':
1884 $validation['suggestions'][] = $field['label'];
1885 $validation['score'] -= 5;
1886 break;
1887 }
1888 }
1889
1890 // Ensure score doesn't go below 0
1891 $validation['score'] = max(0, $validation['score']);
1892
1893 // Add field details for frontend display
1894 $validation['field_details'] = $field_details;
1895
1896 return $validation;
1897 }
1898
1899 /**
1900 * Get detailed field validation breakdown
1901 *
1902 * @since 1.0.0
1903 *
1904 * @param array $settings Settings array to validate
1905 * @param string $tab_context Tab context for specific validation
1906 * @return array Detailed field validation results
1907 */
1908 private function get_detailed_field_validation(array $settings, string $tab_context = ''): array {
1909 $field_details = [];
1910
1911 // Return tab-specific validation based on context
1912 switch ($tab_context) {
1913 case 'local-seo':
1914 return $this->get_business_info_validation($settings);
1915 case 'hero-section':
1916 return $this->get_hero_section_validation($settings);
1917 case 'title-formats':
1918 return $this->get_title_formats_validation($settings);
1919 case 'breadcrumbs':
1920 return $this->get_breadcrumbs_validation($settings);
1921 default:
1922 // Default basic info validation
1923 return $this->get_basic_info_validation($settings);
1924 }
1925 }
1926
1927 /**
1928 * Get Business Info specific validation
1929 *
1930 * @since 1.0.0
1931 *
1932 * @param array $settings Settings array to validate
1933 * @return array Business Info validation results
1934 */
1935 private function get_business_info_validation(array $settings): array {
1936 $field_details = [];
1937
1938 // Check if Local SEO is enabled
1939 if (empty($settings['local_seo_enabled'])) {
1940 $field_details[] = [
1941 'field' => 'local_seo_enabled',
1942 'label' => 'Local SEO is disabled. Enable to configure business information.',
1943 'status' => 'warning',
1944 'icon' => ''
1945 ];
1946 return $field_details;
1947 }
1948
1949 // Business Name validation
1950 if (!empty($settings['business_name'])) {
1951 $field_details[] = [
1952 'field' => 'business_name',
1953 'label' => 'Business name is properly configured.',
1954 'status' => 'valid',
1955 'icon' => ''
1956 ];
1957 } else {
1958 $field_details[] = [
1959 'field' => 'business_name',
1960 'label' => 'Business name is required for local SEO.',
1961 'status' => 'error',
1962 'icon' => ''
1963 ];
1964 }
1965
1966 // Business Type validation
1967 if (!empty($settings['business_type']) && $settings['business_type'] !== 'LocalBusiness') {
1968 $field_details[] = [
1969 'field' => 'business_type',
1970 'label' => 'Business type is selected for proper schema markup.',
1971 'status' => 'valid',
1972 'icon' => ''
1973 ];
1974 } else {
1975 $field_details[] = [
1976 'field' => 'business_type',
1977 'label' => 'Specific business type selection recommended for better schema markup.',
1978 'status' => 'suggestion',
1979 'icon' => ''
1980 ];
1981 }
1982
1983 // Address validation (NAP consistency)
1984 $address_fields = ['business_address', 'business_city', 'business_state', 'business_country'];
1985 $address_complete = true;
1986 foreach ($address_fields as $field) {
1987 if (empty($settings[$field])) {
1988 $address_complete = false;
1989 break;
1990 }
1991 }
1992
1993 if ($address_complete) {
1994 $field_details[] = [
1995 'field' => 'business_address',
1996 'label' => 'Complete business address is configured for NAP consistency.',
1997 'status' => 'valid',
1998 'icon' => ''
1999 ];
2000 } else {
2001 $field_details[] = [
2002 'field' => 'business_address',
2003 'label' => 'Complete address (street, city, state, country) required for local SEO.',
2004 'status' => 'error',
2005 'icon' => ''
2006 ];
2007 }
2008
2009 // Phone validation
2010 if (!empty($settings['business_phone'])) {
2011 if ($this->validate_phone_format($settings['business_phone'])) {
2012 $field_details[] = [
2013 'field' => 'business_phone',
2014 'label' => 'Business phone number is properly formatted.',
2015 'status' => 'valid',
2016 'icon' => ''
2017 ];
2018 } else {
2019 $field_details[] = [
2020 'field' => 'business_phone',
2021 'label' => 'Business phone number format could be improved.',
2022 'status' => 'warning',
2023 'icon' => ''
2024 ];
2025 }
2026 } else {
2027 $field_details[] = [
2028 'field' => 'business_phone',
2029 'label' => 'Business phone number is important for local SEO and customer contact.',
2030 'status' => 'warning',
2031 'icon' => ''
2032 ];
2033 }
2034
2035 // Email validation
2036 if (!empty($settings['business_email'])) {
2037 if (is_email($settings['business_email'])) {
2038 $field_details[] = [
2039 'field' => 'business_email',
2040 'label' => 'Business email address is valid.',
2041 'status' => 'valid',
2042 'icon' => ''
2043 ];
2044 } else {
2045 $field_details[] = [
2046 'field' => 'business_email',
2047 'label' => 'Business email address format is invalid.',
2048 'status' => 'error',
2049 'icon' => ''
2050 ];
2051 }
2052 } else {
2053 $field_details[] = [
2054 'field' => 'business_email',
2055 'label' => 'Business email address recommended for contact information.',
2056 'status' => 'suggestion',
2057 'icon' => ''
2058 ];
2059 }
2060
2061 // Coordinates validation
2062 if (!empty($settings['business_latitude']) && !empty($settings['business_longitude'])) {
2063 if ($this->validate_coordinates($settings['business_latitude'], $settings['business_longitude'])) {
2064 $field_details[] = [
2065 'field' => 'business_coordinates',
2066 'label' => 'Business coordinates are properly configured for precise location.',
2067 'status' => 'valid',
2068 'icon' => ''
2069 ];
2070 } else {
2071 $field_details[] = [
2072 'field' => 'business_coordinates',
2073 'label' => 'Business coordinates appear to be invalid.',
2074 'status' => 'error',
2075 'icon' => ''
2076 ];
2077 }
2078 } else {
2079 $field_details[] = [
2080 'field' => 'business_coordinates',
2081 'label' => 'Business coordinates recommended for precise location targeting.',
2082 'status' => 'suggestion',
2083 'icon' => ''
2084 ];
2085 }
2086
2087 return $field_details;
2088 }
2089
2090 /**
2091 * Get Basic Info validation (default)
2092 *
2093 * @since 1.0.0
2094 *
2095 * @param array $settings Settings array to validate
2096 * @return array Basic Info validation results
2097 */
2098 private function get_basic_info_validation(array $settings): array {
2099 $field_details = [];
2100
2101 // Site Name validation
2102 if (!empty($settings['site_name'])) {
2103 $field_details[] = [
2104 'field' => 'site_name',
2105 'label' => 'Site name is properly configured.',
2106 'status' => 'valid',
2107 'icon' => ''
2108 ];
2109 } else {
2110 $field_details[] = [
2111 'field' => 'site_name',
2112 'label' => 'Site name is required.',
2113 'status' => 'error',
2114 'icon' => ''
2115 ];
2116 }
2117
2118 // Site Description validation
2119 if (!empty($settings['site_description'])) {
2120 $length = strlen($settings['site_description']);
2121 if ($length >= 120 && $length <= 160) {
2122 $field_details[] = [
2123 'field' => 'site_description',
2124 'label' => 'Site description is properly configured.',
2125 'status' => 'valid',
2126 'icon' => ''
2127 ];
2128 } else {
2129 $field_details[] = [
2130 'field' => 'site_description',
2131 'label' => 'Site description length could be optimized (120-160 characters recommended).',
2132 'status' => 'warning',
2133 'icon' => ''
2134 ];
2135 }
2136 } else {
2137 $field_details[] = [
2138 'field' => 'site_description',
2139 'label' => 'Site description is recommended for better SEO.',
2140 'status' => 'warning',
2141 'icon' => ''
2142 ];
2143 }
2144
2145 // Tagline validation
2146 if (!empty($settings['tagline'])) {
2147 $field_details[] = [
2148 'field' => 'tagline',
2149 'label' => 'Site tagline is configured.',
2150 'status' => 'valid',
2151 'icon' => ''
2152 ];
2153 } else {
2154 $field_details[] = [
2155 'field' => 'tagline',
2156 'label' => 'Site tagline recommended for better branding.',
2157 'status' => 'suggestion',
2158 'icon' => ''
2159 ];
2160 }
2161
2162 // Default Meta Description validation
2163 if (!empty($settings['default_meta_description'])) {
2164 $length = strlen($settings['default_meta_description']);
2165 if ($length >= 120 && $length <= 160) {
2166 $field_details[] = [
2167 'field' => 'default_meta_description',
2168 'label' => 'Default meta description is properly configured.',
2169 'status' => 'valid',
2170 'icon' => ''
2171 ];
2172 } else {
2173 $field_details[] = [
2174 'field' => 'default_meta_description',
2175 'label' => 'Default meta description length could be optimized (120-160 characters recommended).',
2176 'status' => 'warning',
2177 'icon' => ''
2178 ];
2179 }
2180 } else {
2181 $field_details[] = [
2182 'field' => 'default_meta_description',
2183 'label' => 'Default meta description recommended for pages without specific descriptions.',
2184 'status' => 'suggestion',
2185 'icon' => ''
2186 ];
2187 }
2188
2189 return $field_details;
2190 }
2191
2192 /**
2193 * Get Hero Section validation
2194 *
2195 * @since 1.0.0
2196 *
2197 * @param array $settings Settings array to validate
2198 * @return array Hero Section validation results
2199 */
2200 private function get_hero_section_validation(array $settings): array {
2201 $field_details = [];
2202
2203 // Hero Title validation
2204 if (!empty($settings['hero_title'])) {
2205 $field_details[] = [
2206 'field' => 'hero_title',
2207 'label' => 'Hero title is configured.',
2208 'status' => 'valid',
2209 'icon' => ''
2210 ];
2211 } else {
2212 $field_details[] = [
2213 'field' => 'hero_title',
2214 'label' => 'Hero title recommended for better homepage presentation.',
2215 'status' => 'suggestion',
2216 'icon' => ''
2217 ];
2218 }
2219
2220 // Hero Subtitle validation (correct field name)
2221 if (!empty($settings['hero_subtitle'])) {
2222 $field_details[] = [
2223 'field' => 'hero_subtitle',
2224 'label' => 'Hero subtitle is configured.',
2225 'status' => 'valid',
2226 'icon' => ''
2227 ];
2228 } else {
2229 $field_details[] = [
2230 'field' => 'hero_subtitle',
2231 'label' => 'Hero subtitle recommended for better user engagement.',
2232 'status' => 'suggestion',
2233 'icon' => ''
2234 ];
2235 }
2236
2237 // CTA Text validation
2238 if (!empty($settings['hero_cta_text'])) {
2239 $field_details[] = [
2240 'field' => 'hero_cta_text',
2241 'label' => 'Call-to-action text is configured.',
2242 'status' => 'valid',
2243 'icon' => ''
2244 ];
2245 } else {
2246 $field_details[] = [
2247 'field' => 'hero_cta_text',
2248 'label' => 'Call-to-action text recommended for better conversion.',
2249 'status' => 'suggestion',
2250 'icon' => ''
2251 ];
2252 }
2253
2254 // CTA URL validation
2255 if (!empty($settings['hero_cta_url'])) {
2256 if (filter_var($settings['hero_cta_url'], FILTER_VALIDATE_URL) || strpos($settings['hero_cta_url'], '/') === 0) {
2257 $field_details[] = [
2258 'field' => 'hero_cta_url',
2259 'label' => 'Call-to-action URL is properly configured.',
2260 'status' => 'valid',
2261 'icon' => ''
2262 ];
2263 } else {
2264 $field_details[] = [
2265 'field' => 'hero_cta_url',
2266 'label' => 'Call-to-action URL format appears invalid.',
2267 'status' => 'warning',
2268 'icon' => ''
2269 ];
2270 }
2271 } else {
2272 $field_details[] = [
2273 'field' => 'hero_cta_url',
2274 'label' => 'Call-to-action URL recommended for better conversion.',
2275 'status' => 'suggestion',
2276 'icon' => ''
2277 ];
2278 }
2279
2280 // Hero Background Image validation
2281 if (!empty($settings['hero_background_image'])) {
2282 $field_details[] = [
2283 'field' => 'hero_background_image',
2284 'label' => 'Hero background image is configured.',
2285 'status' => 'valid',
2286 'icon' => ''
2287 ];
2288 } else {
2289 $field_details[] = [
2290 'field' => 'hero_background_image',
2291 'label' => 'Hero background image recommended for visual appeal.',
2292 'status' => 'suggestion',
2293 'icon' => ''
2294 ];
2295 }
2296
2297 // Site Logo validation (from Site Assets section)
2298 if (!empty($settings['logo_url'])) {
2299 if (filter_var($settings['logo_url'], FILTER_VALIDATE_URL)) {
2300 $field_details[] = [
2301 'field' => 'logo_url',
2302 'label' => 'Site logo is properly configured.',
2303 'status' => 'valid',
2304 'icon' => ''
2305 ];
2306 } else {
2307 $field_details[] = [
2308 'field' => 'logo_url',
2309 'label' => 'Site logo URL format appears invalid.',
2310 'status' => 'warning',
2311 'icon' => ''
2312 ];
2313 }
2314 } else {
2315 $field_details[] = [
2316 'field' => 'logo_url',
2317 'label' => 'Site logo recommended for branding and schema markup.',
2318 'status' => 'suggestion',
2319 'icon' => ''
2320 ];
2321 }
2322
2323 // Favicon validation
2324 if (!empty($settings['favicon_url'])) {
2325 $field_details[] = [
2326 'field' => 'favicon_url',
2327 'label' => 'Favicon is configured.',
2328 'status' => 'valid',
2329 'icon' => ''
2330 ];
2331 } else {
2332 $field_details[] = [
2333 'field' => 'favicon_url',
2334 'label' => 'Favicon recommended for browser tab identification.',
2335 'status' => 'suggestion',
2336 'icon' => ''
2337 ];
2338 }
2339
2340 // Apple Touch Icon validation
2341 if (!empty($settings['apple_touch_icon_url'])) {
2342 $field_details[] = [
2343 'field' => 'apple_touch_icon_url',
2344 'label' => 'Apple touch icon is configured.',
2345 'status' => 'valid',
2346 'icon' => ''
2347 ];
2348 } else {
2349 $field_details[] = [
2350 'field' => 'apple_touch_icon_url',
2351 'label' => 'Apple touch icon recommended for iOS devices.',
2352 'status' => 'suggestion',
2353 'icon' => ''
2354 ];
2355 }
2356
2357 return $field_details;
2358 }
2359
2360 /**
2361 * Get Title Formats validation
2362 *
2363 * @since 1.0.0
2364 *
2365 * @param array $settings Settings array to validate
2366 * @return array Title Formats validation results
2367 */
2368 private function get_title_formats_validation(array $settings): array {
2369 $field_details = [];
2370
2371 // Title Separator validation
2372 if (!empty($settings['title_separator'])) {
2373 $field_details[] = [
2374 'field' => 'title_separator',
2375 'label' => 'Title separator is properly configured.',
2376 'status' => 'valid',
2377 'icon' => ''
2378 ];
2379 } else {
2380 $field_details[] = [
2381 'field' => 'title_separator',
2382 'label' => 'Title separator is required.',
2383 'status' => 'error',
2384 'icon' => ''
2385 ];
2386 }
2387
2388 // Homepage Title validation
2389 if (!empty($settings['homepage_title'])) {
2390 $field_details[] = [
2391 'field' => 'homepage_title',
2392 'label' => 'Homepage title format is configured.',
2393 'status' => 'valid',
2394 'icon' => ''
2395 ];
2396 } else {
2397 $field_details[] = [
2398 'field' => 'homepage_title',
2399 'label' => 'Homepage title format recommended.',
2400 'status' => 'suggestion',
2401 'icon' => ''
2402 ];
2403 }
2404
2405 // Post Title validation
2406 if (!empty($settings['post_title'])) {
2407 $field_details[] = [
2408 'field' => 'post_title',
2409 'label' => 'Post title format is configured.',
2410 'status' => 'valid',
2411 'icon' => ''
2412 ];
2413 } else {
2414 $field_details[] = [
2415 'field' => 'post_title',
2416 'label' => 'Post title format recommended.',
2417 'status' => 'suggestion',
2418 'icon' => ''
2419 ];
2420 }
2421
2422 // Page Title validation
2423 if (!empty($settings['page_title'])) {
2424 $field_details[] = [
2425 'field' => 'page_title',
2426 'label' => 'Page title format is configured.',
2427 'status' => 'valid',
2428 'icon' => ''
2429 ];
2430 } else {
2431 $field_details[] = [
2432 'field' => 'page_title',
2433 'label' => 'Page title format recommended.',
2434 'status' => 'suggestion',
2435 'icon' => ''
2436 ];
2437 }
2438
2439 // Category Title validation
2440 if (!empty($settings['category_title'])) {
2441 $field_details[] = [
2442 'field' => 'category_title',
2443 'label' => 'Category title format is configured.',
2444 'status' => 'valid',
2445 'icon' => ''
2446 ];
2447 } else {
2448 $field_details[] = [
2449 'field' => 'category_title',
2450 'label' => 'Category title format recommended.',
2451 'status' => 'suggestion',
2452 'icon' => ''
2453 ];
2454 }
2455
2456 // Search Title validation
2457 if (!empty($settings['search_title'])) {
2458 $field_details[] = [
2459 'field' => 'search_title',
2460 'label' => 'Search title format is configured.',
2461 'status' => 'valid',
2462 'icon' => ''
2463 ];
2464 } else {
2465 $field_details[] = [
2466 'field' => 'search_title',
2467 'label' => 'Search title format recommended.',
2468 'status' => 'suggestion',
2469 'icon' => ''
2470 ];
2471 }
2472
2473 return $field_details;
2474 }
2475
2476 /**
2477 * Get Breadcrumbs validation
2478 *
2479 * @since 1.0.0
2480 *
2481 * @param array $settings Settings array to validate
2482 * @return array Breadcrumbs validation results
2483 */
2484 private function get_breadcrumbs_validation(array $settings): array {
2485 $field_details = [];
2486
2487 // Breadcrumbs enabled validation
2488 if (!empty($settings['breadcrumbs_enabled'])) {
2489 $field_details[] = [
2490 'field' => 'breadcrumbs_enabled',
2491 'label' => 'Breadcrumbs are enabled for better navigation.',
2492 'status' => 'valid',
2493 'icon' => ''
2494 ];
2495
2496 // Only validate other fields if breadcrumbs are enabled
2497 // Breadcrumb Type validation
2498 if (!empty($settings['breadcrumb_type'])) {
2499 $field_details[] = [
2500 'field' => 'breadcrumb_type',
2501 'label' => 'Breadcrumb type is properly configured.',
2502 'status' => 'valid',
2503 'icon' => ''
2504 ];
2505 } else {
2506 $field_details[] = [
2507 'field' => 'breadcrumb_type',
2508 'label' => 'Breadcrumb type selection is required.',
2509 'status' => 'error',
2510 'icon' => ''
2511 ];
2512 }
2513
2514 // Home Text validation
2515 if (!empty($settings['breadcrumb_home_text'])) {
2516 $field_details[] = [
2517 'field' => 'breadcrumb_home_text',
2518 'label' => 'Home breadcrumb text is configured.',
2519 'status' => 'valid',
2520 'icon' => ''
2521 ];
2522 } else {
2523 $field_details[] = [
2524 'field' => 'breadcrumb_home_text',
2525 'label' => 'Home breadcrumb text recommended for clarity.',
2526 'status' => 'suggestion',
2527 'icon' => ''
2528 ];
2529 }
2530
2531 // Breadcrumb Separator validation
2532 if (!empty($settings['breadcrumb_separator'])) {
2533 $field_details[] = [
2534 'field' => 'breadcrumb_separator',
2535 'label' => 'Breadcrumb separator is configured.',
2536 'status' => 'valid',
2537 'icon' => ''
2538 ];
2539 } else {
2540 $field_details[] = [
2541 'field' => 'breadcrumb_separator',
2542 'label' => 'Breadcrumb separator recommended for better formatting.',
2543 'status' => 'suggestion',
2544 'icon' => ''
2545 ];
2546 }
2547
2548 // Breadcrumb Prefix validation (optional)
2549 if (!empty($settings['breadcrumb_prefix'])) {
2550 $field_details[] = [
2551 'field' => 'breadcrumb_prefix',
2552 'label' => 'Breadcrumb prefix is configured.',
2553 'status' => 'valid',
2554 'icon' => ''
2555 ];
2556 } else {
2557 $field_details[] = [
2558 'field' => 'breadcrumb_prefix',
2559 'label' => 'Breadcrumb prefix is optional but can improve user guidance.',
2560 'status' => 'suggestion',
2561 'icon' => ''
2562 ];
2563 }
2564
2565 // Show Current Page validation
2566 $field_details[] = [
2567 'field' => 'show_current_page',
2568 'label' => isset($settings['show_current_page']) ?
2569 'Current page display preference is configured.' :
2570 'Current page display preference is set to default.',
2571 'status' => 'valid',
2572 'icon' => ''
2573 ];
2574 } else {
2575 $field_details[] = [
2576 'field' => 'breadcrumbs_enabled',
2577 'label' => 'Breadcrumbs recommended for better user experience and SEO.',
2578 'status' => 'suggestion',
2579 'icon' => ''
2580 ];
2581 }
2582
2583 return $field_details;
2584 }
2585
2586 /**
2587 * Validate local SEO settings
2588 *
2589 * @since 1.0.0
2590 *
2591 * @param array $settings Settings array to validate
2592 * @return array Local SEO validation results
2593 */
2594 private function validate_local_seo_settings(array $settings): array {
2595 $validation = [
2596 'valid' => true,
2597 'errors' => [],
2598 'warnings' => [],
2599 'suggestions' => []
2600 ];
2601
2602 // Business name is what makes the LocalBusiness schema useful, but it
2603 // cannot be a blocking error: the toggle is what reveals the business
2604 // fields, so requiring the name up front makes enabling Local SEO
2605 // impossible. The frontend already skips the output while the name is
2606 // empty (see Seo_Manager::output_local_seo_meta_tags()).
2607 if (empty($settings['business_name'])) {
2608 $validation['warnings'][] = 'Business name is missing - required before local business schema is output';
2609 } elseif (strlen($settings['business_name']) > 100) {
2610 $validation['warnings'][] = 'Business name is very long, consider shortening for better display';
2611 }
2612
2613 // Validate business address components (NAP consistency)
2614 $required_address_fields = [
2615 'business_address' => 'Business address',
2616 'business_city' => 'Business city',
2617 'business_state' => 'Business state/province',
2618 'business_country' => 'Business country'
2619 ];
2620
2621 foreach ($required_address_fields as $field => $label) {
2622 if (empty($settings[$field])) {
2623 $validation['warnings'][] = "{$label} is missing - important for NAP consistency and local search";
2624 }
2625 }
2626
2627 // Validate postal code (recommended)
2628 if (empty($settings['business_postal_code'])) {
2629 $validation['suggestions'][] = 'Add postal code for more precise location targeting';
2630 }
2631
2632 // Validate phone number
2633 if (empty($settings['business_phone'])) {
2634 $validation['warnings'][] = 'Business phone number is missing - important for local SEO and customer contact';
2635 } elseif (!$this->validate_phone_format($settings['business_phone'])) {
2636 $validation['suggestions'][] = 'Phone number format could be improved for consistency';
2637 }
2638
2639 // Validate email address
2640 if (!empty($settings['business_email']) && !is_email($settings['business_email'])) {
2641 $validation['errors'][] = 'Business email address format is invalid';
2642 $validation['valid'] = false;
2643 }
2644
2645 // Validate coordinates if provided
2646 if (!empty($settings['business_latitude']) || !empty($settings['business_longitude'])) {
2647 if (empty($settings['business_latitude']) || empty($settings['business_longitude'])) {
2648 $validation['warnings'][] = 'Both latitude and longitude are required for geo-location';
2649 } elseif (!$this->validate_coordinates($settings['business_latitude'], $settings['business_longitude'])) {
2650 $validation['errors'][] = 'Invalid latitude or longitude coordinates';
2651 $validation['valid'] = false;
2652 }
2653 } else {
2654 $validation['suggestions'][] = 'Add latitude and longitude coordinates for precise location targeting';
2655 }
2656
2657 // Validate business hours
2658 if (!empty($settings['business_hours']) && is_array($settings['business_hours'])) {
2659 $hours_validation = $this->validate_business_hours($settings['business_hours']);
2660 if (!$hours_validation['valid']) {
2661 $validation['warnings'] = array_merge($validation['warnings'], $hours_validation['warnings']);
2662 }
2663 } else {
2664 $validation['suggestions'][] = 'Add business hours to improve local search visibility';
2665 }
2666
2667 // Validate business type
2668 if (empty($settings['business_type'])) {
2669 $validation['suggestions'][] = 'Select a specific business type for better schema markup';
2670 }
2671
2672 return $validation;
2673 }
2674
2675 /**
2676 * Validate phone number format
2677 *
2678 * @since 1.0.0
2679 *
2680 * @param string $phone_number Phone number to validate
2681 * @return bool True if format is acceptable
2682 */
2683 private function validate_phone_format(string $phone_number): bool {
2684 // Remove all non-numeric characters except + for international numbers
2685 $cleaned = preg_replace('/[^\d+]/', '', $phone_number);
2686
2687 // Check for common valid formats
2688 return (
2689 preg_match('/^\d{10}$/', $cleaned) || // 10 digits (US)
2690 preg_match('/^1\d{10}$/', $cleaned) || // 1 + 10 digits (US with country code)
2691 preg_match('/^\+\d{7,15}$/', $cleaned) // International format
2692 );
2693 }
2694
2695 /**
2696 * Get output data for frontend rendering (implements interface)
2697 *
2698 * @since 1.0.0
2699 *
2700 * @param string $context_type The context type
2701 * @param int|null $context_id Optional. Context ID
2702 * @return array Output data ready for frontend rendering
2703 */
2704 public function get_output_data(string $context_type, ?int $context_id): array {
2705 $settings = $this->get_settings($context_type, $context_id);
2706
2707 $output = [
2708 'title' => '',
2709 'breadcrumbs' => [],
2710 'identity' => [],
2711 'robots_txt' => [],
2712 'enabled' => $settings['enabled'] ?? true
2713 ];
2714
2715 if (!$output['enabled']) {
2716 return $output;
2717 }
2718
2719 // Generate title for current context
2720 $title_data = $this->extract_title_data($context_type, $context_id);
2721 $output['title'] = $this->generate_title(
2722 $settings['title_template'] ?? 'default',
2723 $title_data,
2724 $context_type
2725 );
2726
2727 // Generate breadcrumbs if enabled
2728 if (!empty($settings['breadcrumbs_enabled'])) {
2729 $breadcrumb_options = [
2730 'context_type' => $context_type,
2731 'context_id' => $context_id
2732 ];
2733 $output['breadcrumbs'] = $this->generate_breadcrumbs(
2734 $settings['breadcrumb_type'] ?? 'hierarchical',
2735 $breadcrumb_options
2736 );
2737 }
2738
2739 // Get site identity data
2740 $output['identity'] = $this->get_site_identity_data($settings);
2741
2742 // Get robots.txt data if enabled
2743 if (!empty($settings['robots_txt_enabled'])) {
2744 $output['robots_txt'] = $this->generate_robots_txt($settings['custom_robots_rules'] ?? []);
2745 }
2746
2747 return $output;
2748 }
2749
2750 /**
2751 * Keys the Site Identity screens store beyond the 16 defaults.
2752 *
2753 * Title formats, breadcrumb configuration, the hero fields, the business
2754 * block and the wizard's identity fields are all real settings written by
2755 * this manager, none of which get_default_settings() names — it seeds only
2756 * the values a fresh install needs. Gating on defaults alone would stop
2757 * every one of them saving (#452).
2758 *
2759 * @since 2.0.1
2760 *
2761 * @return string[]
2762 */
2763 protected function additional_setting_keys(): array {
2764 return [
2765 // Title formats, one per context.
2766 'homepage_title', 'post_title', 'page_title', 'category_title',
2767 'tag_title', 'author_title', 'search_title', 'archive_title',
2768 // Breadcrumbs.
2769 'breadcrumb_prefix', 'show_current_page', 'breadcrumb_use_seo_title',
2770 // Identity, as written by the setup wizard and the importers.
2771 'alternate_name', 'identity_type', 'represents',
2772 'default_meta_description', 'default_social_image',
2773 'social_media_accounts',
2774 // Schema toggles that live on this screen.
2775 'organization_schema', 'knowledge_graph',
2776 // Robots rules composed by the Robots.txt panel.
2777 'custom_robots_rules',
2778 // Per-agent AI crawler allow/block map (#657).
2779 'ai_crawler_rules',
2780 // Hero section.
2781 'hero_title', 'hero_subtitle', 'hero_cta_text', 'hero_cta_url',
2782 'hero_background_image',
2783 // Local SEO / business details.
2784 'local_seo_enabled', 'business_type', 'business_name',
2785 'business_address', 'business_city', 'business_state',
2786 'business_postal_code', 'business_country', 'business_phone',
2787 'business_email', 'business_latitude', 'business_longitude',
2788 'business_price_range', 'business_hours',
2789 ];
2790 }
2791
2792 /**
2793 * Sanitize settings, normalising the AI crawler rule map.
2794 *
2795 * The generic array sanitizer keeps the shape but says nothing about the
2796 * values: a payload could store `ai_crawler_rules[gptbot] = "maybe"`, or a
2797 * slug no crawler answers to, and both would round-trip through every
2798 * later response. Normalising here rather than in the REST handler puts it
2799 * on the one path every writer shares — the settings route, the robots
2800 * route and the MCP abilities all land in save_settings() (#657).
2801 *
2802 * @since 2.5.0
2803 *
2804 * @param array $settings Settings to sanitize.
2805 * @param string $context_type Context type.
2806 * @return array Sanitized settings.
2807 */
2808 protected function sanitize_settings(array $settings, string $context_type = 'site'): array {
2809 $sanitized = parent::sanitize_settings($settings, $context_type);
2810
2811 if (array_key_exists('ai_crawler_rules', $sanitized)) {
2812 $sanitized['ai_crawler_rules'] = AI_Crawlers::normalize_rules($sanitized['ai_crawler_rules']);
2813 }
2814
2815 return $sanitized;
2816 }
2817
2818 /**
2819 * Get default settings for a context type (implements interface)
2820 *
2821 * @since 1.0.0
2822 *
2823 * @param string $context_type The context type to get defaults for
2824 * @return array Default settings array
2825 */
2826 public function get_default_settings(string $context_type): array {
2827 $defaults = [
2828 'enabled' => true,
2829 'title_template' => 'default',
2830 'title_separator' => 'pipe',
2831 'site_name' => get_bloginfo('name'),
2832 'site_description' => get_bloginfo('description'),
2833 'tagline' => get_bloginfo('description'),
2834 'breadcrumbs_enabled' => true,
2835 'breadcrumb_type' => 'hierarchical',
2836 'breadcrumb_home_text' => 'Home',
2837 'breadcrumb_separator' => '>',
2838 'robots_txt_enabled' => true,
2839 'allow_search_engines' => true,
2840 'robots_txt_content' => '',
2841 // Empty map = every AI crawler allowed. Defaults must stay
2842 // permissive so an upgrade never starts blocking a crawler a site
2843 // was happily serving (#657).
2844 'ai_crawler_rules' => [],
2845 'logo_url' => '',
2846 'favicon_url' => '',
2847 'apple_touch_icon_url' => ''
2848 ];
2849
2850 // Context-specific defaults
2851 switch ($context_type) {
2852 case 'site':
2853 // Site-wide defaults are already set above
2854 break;
2855 case 'post':
2856 $defaults['title_template'] = 'default';
2857 $defaults['breadcrumb_type'] = 'taxonomy';
2858 break;
2859 case 'page':
2860 $defaults['title_template'] = 'default';
2861 $defaults['breadcrumb_type'] = 'hierarchical';
2862 break;
2863 case 'product':
2864 $defaults['title_template'] = 'category';
2865 $defaults['breadcrumb_type'] = 'taxonomy';
2866 break;
2867 }
2868
2869 return $defaults;
2870 }
2871
2872 /**
2873 * Get settings schema definition (implements interface)
2874 *
2875 * @since 1.0.0
2876 *
2877 * @param string $context_type The context type to get schema for
2878 * @return array Settings schema definition
2879 */
2880 public function get_settings_schema(string $context_type): array {
2881 return [
2882 'enabled' => [
2883 'type' => 'boolean',
2884 'title' => 'Enable Site Identity',
2885 'description' => 'Enable site identity management features',
2886 'default' => true
2887 ],
2888 'title_template' => [
2889 'type' => 'string',
2890 'title' => 'Title Template',
2891 'description' => 'Template for generating page titles',
2892 'enum' => array_keys($this->title_templates),
2893 'default' => 'default'
2894 ],
2895 'title_separator' => [
2896 'type' => 'string',
2897 'title' => 'Title Separator',
2898 'description' => 'Character used to separate title elements',
2899 'enum' => array_keys(self::$title_separators),
2900 'default' => 'pipe'
2901 ],
2902 'site_name' => [
2903 'type' => 'string',
2904 'title' => 'Site Name',
2905 'description' => 'Official name of the website',
2906 'maxLength' => 60,
2907 'default' => get_bloginfo('name')
2908 ],
2909 'site_description' => [
2910 'type' => 'string',
2911 'title' => 'Site Description',
2912 'description' => 'Brief description of the website',
2913 'maxLength' => 160,
2914 'default' => get_bloginfo('description')
2915 ],
2916 'breadcrumbs_enabled' => [
2917 'type' => 'boolean',
2918 'title' => 'Enable Breadcrumbs',
2919 'description' => 'Enable breadcrumb navigation generation',
2920 'default' => true
2921 ],
2922 'breadcrumb_type' => [
2923 'type' => 'string',
2924 'title' => 'Breadcrumb Type',
2925 'description' => 'Type of breadcrumb navigation to generate',
2926 'enum' => array_keys($this->breadcrumb_types),
2927 'default' => 'hierarchical'
2928 ],
2929 'robots_txt_enabled' => [
2930 'type' => 'boolean',
2931 'title' => 'Enable Robots.txt Management',
2932 'description' => 'Enable automatic robots.txt generation and management',
2933 'default' => true
2934 ],
2935 'logo_url' => [
2936 'type' => 'string',
2937 'title' => 'Logo URL',
2938 'description' => 'URL of the site logo image',
2939 'format' => 'uri',
2940 'default' => ''
2941 ],
2942 'favicon_url' => [
2943 'type' => 'string',
2944 'title' => 'Favicon URL',
2945 'description' => 'URL of the site favicon',
2946 'format' => 'uri',
2947 'default' => ''
2948 ]
2949 ];
2950 }
2951
2952 /**
2953 * Prepare title placeholders for replacement
2954 *
2955 * @since 1.0.0
2956 *
2957 * @param array $data Content data
2958 * @param string $context Context type
2959 * @param array $settings Site settings
2960 * @return array Placeholder values
2961 */
2962 private function prepare_title_placeholders(array $data, string $context, array $settings): array {
2963 $placeholders = [
2964 '%title%' => $data['title'] ?? '',
2965 // `?:` rather than `??`: these are persisted as '' rather than left
2966 // unset, and '' is not null, so the null-coalesce never reached the
2967 // WordPress fallback (#398).
2968 '%sitename%' => ($settings['site_name'] ?? '') ?: get_bloginfo('name'),
2969 '%tagline%' => ($settings['tagline'] ?? '') ?: get_bloginfo('description'),
2970 '%separator%' => '', // Will be replaced with actual separator
2971 '%category%' => '',
2972 '%author%' => '',
2973 '%date%' => '',
2974 '%searchterm%' => ''
2975 ];
2976
2977 // Context-specific placeholders
2978 switch ($context) {
2979 case 'post':
2980 case 'page':
2981 case 'product':
2982 if (!empty($data['context_id'])) {
2983 $post = get_post($data['context_id']);
2984 if ($post) {
2985 $placeholders['%title%'] = get_the_title($post);
2986 $placeholders['%author%'] = get_the_author_meta('display_name', $post->post_author);
2987 $placeholders['%date%'] = get_the_date('F j, Y', $post);
2988
2989 // Get primary category
2990 $categories = get_the_category($post->ID);
2991 if (!empty($categories)) {
2992 $placeholders['%category%'] = $categories[0]->name;
2993 }
2994 }
2995 }
2996 break;
2997 case 'search':
2998 $placeholders['%searchterm%'] = get_search_query();
2999 break;
3000 }
3001
3002 return $placeholders;
3003 }
3004
3005 /**
3006 * Replace title placeholders with actual values
3007 *
3008 * @since 1.0.0
3009 *
3010 * @param string $template Title template
3011 * @param array $placeholders Placeholder values
3012 * @param string $separator Title separator
3013 * @return string Processed title
3014 */
3015 private function replace_title_placeholders(string $template, array $placeholders, string $separator): string {
3016 // Replace separator placeholder
3017 $placeholders['%separator%'] = $separator;
3018
3019 // Replace all placeholders
3020 $title = str_replace(array_keys($placeholders), array_values($placeholders), $template);
3021
3022 // Clean up empty placeholders and extra separators
3023 $title = preg_replace('/\s*' . preg_quote($separator, '/') . '\s*' . preg_quote($separator, '/') . '\s*/', ' ' . $separator . ' ', $title);
3024 $title = preg_replace('/^\s*' . preg_quote($separator, '/') . '\s*|\s*' . preg_quote($separator, '/') . '\s*$/', '', $title);
3025
3026 return trim($title);
3027 }
3028
3029 /**
3030 * Get title separator symbol
3031 *
3032 * @since 1.0.0
3033 *
3034 * @param string $separator_key Separator key
3035 * @return string Separator symbol
3036 */
3037 private function get_title_separator(string $separator_key): string {
3038 return self::$title_separators[$separator_key]['symbol'] ?? self::$title_separators['pipe']['symbol'];
3039 }
3040
3041 /**
3042 * Optimize title for SEO
3043 *
3044 * @since 1.0.0
3045 *
3046 * @param string $title Title to optimize
3047 * @param string $context Context type
3048 * @return string Optimized title
3049 */
3050 private function optimize_title(string $title, string $context): string {
3051 // Remove extra whitespace
3052 $title = preg_replace('/\s+/', ' ', $title);
3053 $title = trim($title);
3054
3055 // Ensure title is not too long (60 characters max for SEO)
3056 if (strlen($title) > 60) {
3057 // Try to truncate at word boundary
3058 $title = wp_trim_words($title, 8, '...');
3059 if (strlen($title) > 60) {
3060 $title = substr($title, 0, 57) . '...';
3061 }
3062 }
3063
3064 // Ensure title is not empty
3065 if (empty($title)) {
3066 $title = get_bloginfo('name');
3067 }
3068
3069 return $title;
3070 }
3071
3072 /**
3073 * Extract title data from context
3074 *
3075 * @since 1.0.0
3076 *
3077 * @param string $context_type Context type
3078 * @param int|null $context_id Context ID
3079 * @return array Title data
3080 */
3081 private function extract_title_data(string $context_type, ?int $context_id): array {
3082 $data = [
3083 'title' => '',
3084 'context_type' => $context_type,
3085 'context_id' => $context_id
3086 ];
3087
3088 switch ($context_type) {
3089 case 'site':
3090 $data['title'] = get_bloginfo('name');
3091 break;
3092 case 'post':
3093 case 'page':
3094 case 'product':
3095 if ($context_id) {
3096 $data['title'] = get_the_title($context_id);
3097 }
3098 break;
3099 case 'search':
3100 $data['title'] = 'Search Results';
3101 break;
3102 case '404':
3103 $data['title'] = 'Page Not Found';
3104 break;
3105 }
3106
3107 return $data;
3108 }
3109
3110 /**
3111 * Generate hierarchical breadcrumbs
3112 *
3113 * @since 1.0.0
3114 *
3115 * @param array $options Breadcrumb options
3116 * @return array Breadcrumb items
3117 */
3118 private function generate_hierarchical_breadcrumbs(array $options): array {
3119 $breadcrumbs = [];
3120
3121 // Add home breadcrumb
3122 $breadcrumbs[] = [
3123 'title' => 'Home',
3124 'url' => home_url(),
3125 'position' => 1
3126 ];
3127
3128 $context_type = $options['context_type'] ?? '';
3129 $context_id = $options['context_id'] ?? null;
3130
3131 if ($context_type === 'post' || $context_type === 'page' || $context_type === 'product') {
3132 if ($context_id) {
3133 $post = get_post($context_id);
3134 if ($post) {
3135 // Add parent pages for hierarchical content
3136 $ancestors = get_post_ancestors($post);
3137 $ancestors = array_reverse($ancestors);
3138
3139 $position = 2;
3140 foreach ($ancestors as $ancestor_id) {
3141 $breadcrumbs[] = [
3142 'title' => get_the_title($ancestor_id),
3143 'url' => get_permalink($ancestor_id),
3144 'position' => $position++
3145 ];
3146 }
3147
3148 // Add current page
3149 $breadcrumbs[] = [
3150 'title' => get_the_title($post),
3151 'url' => get_permalink($post),
3152 'position' => $position,
3153 'current' => true
3154 ];
3155 }
3156 }
3157 }
3158
3159 return $breadcrumbs;
3160 }
3161
3162 /**
3163 * Generate taxonomy-based breadcrumbs
3164 *
3165 * @since 1.0.0
3166 *
3167 * @param array $options Breadcrumb options
3168 * @return array Breadcrumb items
3169 */
3170 private function generate_taxonomy_breadcrumbs(array $options): array {
3171 $breadcrumbs = [];
3172
3173 // Add home breadcrumb
3174 $breadcrumbs[] = [
3175 'title' => 'Home',
3176 'url' => home_url(),
3177 'position' => 1
3178 ];
3179
3180 $context_type = $options['context_type'] ?? '';
3181 $context_id = $options['context_id'] ?? null;
3182
3183 if (($context_type === 'post' || $context_type === 'product') && $context_id) {
3184 $post = get_post($context_id);
3185 if ($post) {
3186 // Get primary category
3187 $categories = get_the_category($post->ID);
3188 if (!empty($categories)) {
3189 $primary_category = $categories[0];
3190
3191 // Add category hierarchy
3192 $category_ancestors = get_ancestors($primary_category->term_id, 'category');
3193 $category_ancestors = array_reverse($category_ancestors);
3194
3195 $position = 2;
3196 foreach ($category_ancestors as $ancestor_id) {
3197 $ancestor = get_category($ancestor_id);
3198 $breadcrumbs[] = [
3199 'title' => $ancestor->name,
3200 'url' => get_category_link($ancestor_id),
3201 'position' => $position++
3202 ];
3203 }
3204
3205 // Add primary category
3206 $breadcrumbs[] = [
3207 'title' => $primary_category->name,
3208 'url' => get_category_link($primary_category->term_id),
3209 'position' => $position++
3210 ];
3211 }
3212
3213 // Add current post
3214 $breadcrumbs[] = [
3215 'title' => get_the_title($post),
3216 'url' => get_permalink($post),
3217 'position' => $position,
3218 'current' => true
3219 ];
3220 }
3221 }
3222
3223 return $breadcrumbs;
3224 }
3225
3226 /**
3227 * Generate path-based breadcrumbs
3228 *
3229 * @since 1.0.0
3230 *
3231 * @param array $options Breadcrumb options
3232 * @return array Breadcrumb items
3233 */
3234 private function generate_path_breadcrumbs(array $options): array {
3235 $breadcrumbs = [];
3236
3237 // Add home breadcrumb
3238 $breadcrumbs[] = [
3239 'title' => 'Home',
3240 'url' => home_url(),
3241 'position' => 1
3242 ];
3243
3244 // Get current URL path
3245 $current_url = home_url(add_query_arg([]));
3246 $path = wp_parse_url($current_url, PHP_URL_PATH);
3247 $path_parts = array_filter(explode('/', trim($path, '/')));
3248
3249 $position = 2;
3250 $cumulative_path = '';
3251
3252 foreach ($path_parts as $part) {
3253 $cumulative_path .= '/' . $part;
3254 $url = home_url($cumulative_path);
3255
3256 // Try to get a meaningful title
3257 $title = ucwords(str_replace(['-', '_'], ' ', $part));
3258
3259 $breadcrumbs[] = [
3260 'title' => $title,
3261 'url' => $url,
3262 'position' => $position++,
3263 'current' => $cumulative_path === $path
3264 ];
3265 }
3266
3267 return $breadcrumbs;
3268 }
3269
3270 /**
3271 * Generate custom breadcrumbs
3272 *
3273 * @since 1.0.0
3274 *
3275 * @param array $options Breadcrumb options
3276 * @return array Breadcrumb items
3277 */
3278 private function generate_custom_breadcrumbs(array $options): array {
3279 // Return custom breadcrumbs if provided in options
3280 return $options['custom_breadcrumbs'] ?? [];
3281 }
3282
3283 /**
3284 * Generate breadcrumb schema markup
3285 *
3286 * @since 1.0.0
3287 *
3288 * @param array $breadcrumb_items Breadcrumb items
3289 * @return array Schema markup
3290 */
3291 private function generate_breadcrumb_schema(array $breadcrumb_items): array {
3292 $schema = [
3293 '@context' => 'https://schema.org',
3294 '@type' => 'BreadcrumbList',
3295 'itemListElement' => []
3296 ];
3297
3298 foreach ($breadcrumb_items as $item) {
3299 $schema['itemListElement'][] = [
3300 '@type' => 'ListItem',
3301 'position' => $item['position'],
3302 'name' => $item['title'],
3303 'item' => $item['url']
3304 ];
3305 }
3306
3307 return $schema;
3308 }
3309
3310 /**
3311 * Generate breadcrumb HTML
3312 *
3313 * @since 1.0.0
3314 *
3315 * @param array $breadcrumb_items Breadcrumb items
3316 * @param array $settings Breadcrumb settings
3317 * @return string HTML output
3318 */
3319 private function generate_breadcrumb_html(array $breadcrumb_items, array $settings): string {
3320 if (empty($breadcrumb_items)) {
3321 return '';
3322 }
3323
3324 $separator = $settings['separator'] ?? '>';
3325 $html = '<nav class="thinkrank-breadcrumbs" aria-label="Breadcrumb">';
3326 $html .= '<ol class="breadcrumb-list">';
3327
3328 foreach ($breadcrumb_items as $item) {
3329 $html .= '<li class="breadcrumb-item">';
3330
3331 if (!empty($item['current'])) {
3332 $html .= '<span class="breadcrumb-current" aria-current="page">' . esc_html($item['title']) . '</span>';
3333 } else {
3334 $html .= '<a href="' . esc_url($item['url']) . '">' . esc_html($item['title']) . '</a>';
3335 }
3336
3337 if ($item['position'] < count($breadcrumb_items)) {
3338 $html .= ' <span class="breadcrumb-separator">' . esc_html($separator) . '</span> ';
3339 }
3340
3341 $html .= '</li>';
3342 }
3343
3344 $html .= '</ol>';
3345 $html .= '</nav>';
3346
3347 return $html;
3348 }
3349
3350 /**
3351 * Generate default robots.txt rules
3352 *
3353 * @since 1.0.0
3354 *
3355 * @param array $settings Robots.txt settings
3356 * @return array Default rules
3357 */
3358 private function generate_default_robots_rules(array $settings): array {
3359 $rules = [];
3360
3361 // Full block: when the admin turns off "Allow Search Engines" or enables
3362 // WordPress's "Discourage search engines" (Settings → Reading, stored as
3363 // blog_public=0), serve a robots.txt that disallows everything rather
3364 // than the default per-path rules — otherwise the toggle has no effect.
3365 $allow_search = $settings['allow_search_engines'] ?? true;
3366 if (empty($allow_search) || !get_option('blog_public')) {
3367 $rules[] = ['directive' => 'user_agent', 'value' => '*'];
3368 $rules[] = ['directive' => 'disallow', 'value' => '/'];
3369 return $rules;
3370 }
3371
3372 // Default user agent rule
3373 $rules[] = [
3374 'directive' => 'user_agent',
3375 'value' => '*'
3376 ];
3377
3378 // WordPress core disallows.
3379 //
3380 // Deliberately minimal, matching Yoast/Rank Math defaults. We do NOT
3381 // block /wp-includes/, /wp-content/plugins/, or /wp-content/themes/:
3382 // those paths serve the CSS and JS Google must fetch to render pages,
3383 // and blocking them causes "blocked resource" warnings and can hurt
3384 // rankings. /wp-json/ is left crawlable for the same reason (embeds,
3385 // oEmbed, structured previews). Only wp-admin (bar admin-ajax) and the
3386 // handful of non-content endpoints below are disallowed.
3387 $default_disallows = [
3388 '/wp-admin/',
3389 '/xmlrpc.php',
3390 '/readme.html',
3391 '/license.txt',
3392 ];
3393
3394 // WooCommerce: keep cart/checkout/account and add-to-cart query URLs out
3395 // of the index to avoid crawl noise and duplicate/session URLs (parity
3396 // with Rank Math's WooCommerce robots defaults).
3397 if (class_exists('WooCommerce')) {
3398 $default_disallows[] = '/cart/';
3399 $default_disallows[] = '/checkout/';
3400 $default_disallows[] = '/my-account/';
3401 $default_disallows[] = '/*add-to-cart=*';
3402 }
3403
3404 foreach ($default_disallows as $disallow) {
3405 $rules[] = [
3406 'directive' => 'disallow',
3407 'value' => $disallow
3408 ];
3409 }
3410
3411 // Allow specific files
3412 $default_allows = [
3413 '/wp-admin/admin-ajax.php',
3414 '/wp-content/uploads/'
3415 ];
3416
3417 foreach ($default_allows as $allow) {
3418 $rules[] = [
3419 'directive' => 'allow',
3420 'value' => $allow
3421 ];
3422 }
3423
3424 // Add sitemap URLs from sitemap settings (auto-sync)
3425 $sitemap_urls = $this->get_sitemap_urls_for_robots();
3426
3427 foreach ($sitemap_urls as $sitemap_url) {
3428 if (!empty($sitemap_url)) {
3429 $rules[] = [
3430 'directive' => 'sitemap',
3431 'value' => $sitemap_url
3432 ];
3433 }
3434 }
3435
3436 // Add crawl delay if specified
3437 if (!empty($settings['crawl_delay'])) {
3438 $rules[] = [
3439 'directive' => 'crawl_delay',
3440 'value' => (int) $settings['crawl_delay']
3441 ];
3442 }
3443
3444 return $rules;
3445 }
3446
3447 /**
3448 * Get sitemap URLs from sitemap settings for robots.txt integration
3449 *
3450 * @since 1.0.0
3451 * @return array Array of sitemap URLs
3452 */
3453 private function get_sitemap_urls_for_robots(): array {
3454 try {
3455 // Get sitemap settings
3456 $sitemap_generator = new \ThinkRank\SEO\Sitemap_Generator();
3457 $sitemap_settings = $sitemap_generator->get_settings('site');
3458
3459 // If sitemap is disabled, return default
3460 if (empty($sitemap_settings['enabled'])) {
3461 return [home_url('/sitemap.xml')];
3462 }
3463
3464 $sitemap_urls = [];
3465 $site_url = home_url();
3466
3467 // Extract enabled sitemap URLs. When the index is enabled it is the
3468 // only entry worth advertising: every child sitemap is already
3469 // listed inside it, so naming them again in robots.txt is pure
3470 // redundancy and drifts out of date as soon as a post type is added.
3471 $index_url = '';
3472 if (!empty($sitemap_settings['sitemap_urls']) && is_array($sitemap_settings['sitemap_urls'])) {
3473 foreach ($sitemap_settings['sitemap_urls'] as $sitemap) {
3474 if (empty($sitemap['enabled']) || empty($sitemap['url'])) {
3475 continue;
3476 }
3477
3478 if (($sitemap['type'] ?? '') === 'index') {
3479 $index_url = $site_url . $sitemap['url'];
3480 continue;
3481 }
3482
3483 $sitemap_urls[] = $site_url . $sitemap['url'];
3484 }
3485 }
3486
3487 if ($index_url !== '') {
3488 // The index alone — it covers the children and, on a segmented
3489 // install, the local business sitemap too.
3490 return [$index_url];
3491 }
3492
3493 // Fallback to default if no URLs found
3494 if (empty($sitemap_urls)) {
3495 $sitemap_urls[] = home_url('/sitemap.xml');
3496 }
3497
3498 // No index on this install, so anything not already listed above has
3499 // no other discovery path — advertise it directly. The local
3500 // business sitemap and the sitemaps other plugins register both land
3501 // here for the same reason, so they go through one list (#104).
3502 $extra = [];
3503
3504 if (file_exists(ABSPATH . 'local-sitemap.xml')) {
3505 $extra[] = '/local-sitemap.xml';
3506 }
3507
3508 foreach (\ThinkRank\SEO\Sitemap_Generator::additional_sitemaps() as $path) {
3509 $extra[] = $path;
3510 }
3511
3512 foreach ($extra as $path) {
3513 $url = home_url($path);
3514 if (!in_array($url, $sitemap_urls, true)) {
3515 $sitemap_urls[] = $url;
3516 }
3517 }
3518
3519 return $sitemap_urls;
3520 } catch (\Exception $e) {
3521 // Fallback to default on error
3522 return [home_url('/sitemap.xml')];
3523 }
3524 }
3525
3526 /**
3527 * Validate robots.txt rules
3528 *
3529 * @since 1.0.0
3530 *
3531 * @param array $rules Rules to validate
3532 * @return array Validation results
3533 */
3534 private function validate_robots_rules(array $rules): array {
3535 $validation = [
3536 'valid' => true,
3537 'errors' => [],
3538 'warnings' => [],
3539 'suggestions' => []
3540 ];
3541
3542 $has_user_agent = false;
3543
3544 foreach ($rules as $rule) {
3545 $directive = $rule['directive'] ?? '';
3546 $value = $rule['value'] ?? '';
3547
3548 // Check if directive is valid
3549 if (!isset($this->robots_directives[$directive])) {
3550 $validation['errors'][] = "Unknown robots.txt directive: {$directive}";
3551 $validation['valid'] = false;
3552 continue;
3553 }
3554
3555 // Check for required user-agent
3556 if ($directive === 'user_agent') {
3557 $has_user_agent = true;
3558 }
3559
3560 // Validate directive-specific rules
3561 switch ($directive) {
3562 case 'disallow':
3563 case 'allow':
3564 if (!str_starts_with($value, '/')) {
3565 $validation['warnings'][] = "Path '{$value}' should start with '/'";
3566 }
3567 break;
3568 case 'sitemap':
3569 if (!filter_var($value, FILTER_VALIDATE_URL)) {
3570 $validation['errors'][] = "Invalid sitemap URL: {$value}";
3571 $validation['valid'] = false;
3572 }
3573 break;
3574 case 'crawl_delay':
3575 if (!is_numeric($value) || $value < 0) {
3576 $validation['errors'][] = "Crawl delay must be a positive number";
3577 $validation['valid'] = false;
3578 }
3579 break;
3580 }
3581 }
3582
3583 if (!$has_user_agent) {
3584 $validation['errors'][] = 'robots.txt must include at least one User-agent directive';
3585 $validation['valid'] = false;
3586 }
3587
3588 return $validation;
3589 }
3590
3591 /**
3592 * Build robots.txt content from rules
3593 *
3594 * @since 1.0.0
3595 *
3596 * @param array $rules Robots.txt rules
3597 * @return string Robots.txt content
3598 */
3599 private function build_robots_txt_content(array $rules): string {
3600 // Body only — no header. The "# generated by ThinkRank SEO" + timestamp
3601 // block is added at render time (see robots_txt_header), so it never
3602 // gets baked into the stored/editable content and can't show a stale
3603 // timestamp on every update.
3604 $content = '';
3605
3606 $current_user_agent = '';
3607 $sitemap_started = false;
3608
3609 foreach ($rules as $rule) {
3610 $directive = $rule['directive'] ?? '';
3611 $value = $rule['value'] ?? '';
3612
3613 switch ($directive) {
3614 case 'user_agent':
3615 if ($current_user_agent !== $value) {
3616 $content .= "\nUser-agent: {$value}\n";
3617 $current_user_agent = $value;
3618 }
3619 break;
3620 case 'disallow':
3621 $content .= "Disallow: {$value}\n";
3622 break;
3623 case 'allow':
3624 $content .= "Allow: {$value}\n";
3625 break;
3626 case 'crawl_delay':
3627 $content .= "Crawl-delay: {$value}\n";
3628 break;
3629 case 'sitemap':
3630 // One blank line separates the Sitemap block from the
3631 // preceding group, and none appear inside it. A blank line
3632 // terminates a record in the robots.txt grammar, so putting
3633 // one between every directive was invalid formatting.
3634 if (!$sitemap_started) {
3635 $content .= "\n";
3636 $sitemap_started = true;
3637 }
3638 $content .= "Sitemap: {$value}\n";
3639 break;
3640 }
3641 }
3642
3643 return ltrim($content, "\n");
3644 }
3645
3646 /**
3647 * Parse a robots.txt body back into the {directive, value} rule shape.
3648 *
3649 * generate_robots_txt() returns `rules` alongside `content`, but callers
3650 * replace `content` with the body actually being served (a stored override
3651 * or a physical file). The generated rules then described something the
3652 * response no longer contained. Re-deriving them from the served body keeps
3653 * the two halves of the payload describing the same document.
3654 *
3655 * @since 2.0.1
3656 *
3657 * @param string $content Robots.txt body (header optional).
3658 * @return array<int, array{directive: string, value: string}> Parsed rules.
3659 */
3660 public function parse_robots_txt_rules(string $content): array {
3661 $map = [
3662 'user-agent' => 'user_agent',
3663 'disallow' => 'disallow',
3664 'allow' => 'allow',
3665 'crawl-delay' => 'crawl_delay',
3666 'sitemap' => 'sitemap',
3667 ];
3668
3669 $rules = [];
3670
3671 foreach (preg_split('/\r\n|\r|\n/', $this->strip_robots_header($content)) as $line) {
3672 $line = trim($line);
3673
3674 // Blank lines separate groups and `#` starts a comment; neither is
3675 // a rule.
3676 if ($line === '' || str_starts_with($line, '#')) {
3677 continue;
3678 }
3679
3680 $parts = explode(':', $line, 2);
3681 if (count($parts) !== 2) {
3682 continue;
3683 }
3684
3685 $field = strtolower(trim($parts[0]));
3686 if (!isset($map[$field])) {
3687 continue;
3688 }
3689
3690 $rules[] = [
3691 'directive' => $map[$field],
3692 // Sitemap values are absolute URLs and contain the `:` the
3693 // limited explode above deliberately preserved.
3694 'value' => trim($parts[1]),
3695 ];
3696 }
3697
3698 return $rules;
3699 }
3700
3701 /**
3702 * Opening fence of the machine-owned AI crawler region.
3703 *
3704 * @since 2.5.0
3705 * @var string
3706 */
3707 public const AI_BLOCK_BEGIN = '# BEGIN ThinkRank AI crawlers';
3708
3709 /**
3710 * Closing fence of the machine-owned AI crawler region.
3711 *
3712 * @since 2.5.0
3713 * @var string
3714 */
3715 public const AI_BLOCK_END = '# END ThinkRank AI crawlers';
3716
3717 /**
3718 * Render the fenced AI crawler region for the current settings.
3719 *
3720 * One `User-agent:` / `Disallow: /` record per blocked crawler. Allowed
3721 * crawlers emit nothing at all: `Disallow:` with an empty value is the
3722 * robots.txt way of saying "allow everything", but writing eighteen such
3723 * records to say what silence already says would triple the file and
3724 * invite the reading that an unlisted crawler is therefore refused.
3725 *
3726 * @since 2.5.0
3727 *
3728 * @param array $settings Site settings.
3729 * @return string Fenced block, newline-terminated, or '' when nothing is blocked.
3730 */
3731 private function build_ai_crawler_block(array $settings): string {
3732 $blocked = AI_Crawlers::blocked_slugs($settings['ai_crawler_rules'] ?? []);
3733
3734 if (empty($blocked)) {
3735 return '';
3736 }
3737
3738 $agents = AI_Crawlers::all();
3739
3740 $lines = [
3741 self::AI_BLOCK_BEGIN,
3742 '# Managed by ThinkRank — edits between these lines are overwritten.',
3743 ];
3744
3745 foreach ($blocked as $slug) {
3746 $lines[] = '';
3747 $lines[] = 'User-agent: ' . $agents[$slug]['token'];
3748 $lines[] = 'Disallow: /';
3749 }
3750
3751 $lines[] = self::AI_BLOCK_END;
3752
3753 return implode("\n", $lines) . "\n";
3754 }
3755
3756 /**
3757 * Remove the fenced AI crawler region from a robots.txt body.
3758 *
3759 * Tolerates a missing closing fence rather than leaving the rest of the
3760 * file swallowed: a truncated write, or someone deleting the END line by
3761 * hand, would otherwise make every subsequent read drop everything below
3762 * the opening fence.
3763 *
3764 * @since 2.5.0
3765 *
3766 * @param string $body Robots.txt body.
3767 * @return string Body with the region removed.
3768 */
3769 public function strip_ai_crawler_block(string $body): string {
3770 if (false === strpos($body, self::AI_BLOCK_BEGIN)) {
3771 return $body;
3772 }
3773
3774 $pattern = '/\R*' . preg_quote(self::AI_BLOCK_BEGIN, '/')
3775 . '.*?(?:' . preg_quote(self::AI_BLOCK_END, '/') . '|\z)\R*/s';
3776
3777 return trim((string) preg_replace($pattern, "\n\n", $body, 1));
3778 }
3779
3780 /**
3781 * Put the current AI crawler region into a robots.txt body.
3782 *
3783 * Replaces an existing region in place so the block keeps its position in
3784 * a hand-ordered file, and appends when there is none. Everything outside
3785 * the fences is returned untouched — that is the whole point of fencing
3786 * it, since the body is also a free-text field the user edits.
3787 *
3788 * @since 2.5.0
3789 *
3790 * @param string $body Robots.txt body (fences optional).
3791 * @param array $settings Site settings.
3792 * @return string Body carrying the current region.
3793 */
3794 private function apply_ai_crawler_block(string $body, array $settings): string {
3795 $stripped = $this->strip_ai_crawler_block($body);
3796 $block = $this->build_ai_crawler_block($settings);
3797
3798 if ('' === $block) {
3799 return $stripped;
3800 }
3801
3802 if ('' === trim($stripped)) {
3803 return trim($block);
3804 }
3805
3806 return rtrim($stripped) . "\n\n" . trim($block);
3807 }
3808
3809 /**
3810 * The auto-generated header prepended to the served robots.txt.
3811 *
3812 * Kept separate from the body so it is only ever added at render time with
3813 * a fresh timestamp, never stored or shown in the editable textarea.
3814 *
3815 * @return string
3816 */
3817 private function robots_txt_header(): string {
3818 return "# Robots.txt generated by ThinkRank SEO\n"
3819 . "# " . gmdate('Y-m-d H:i:s') . " UTC\n\n";
3820 }
3821
3822 /**
3823 * Strip our auto-generated header from a robots.txt string.
3824 *
3825 * Used when surfacing existing content for editing so the header/timestamp
3826 * doesn't round-trip back into storage.
3827 *
3828 * @param string $content Raw robots.txt content.
3829 * @return string Body without the ThinkRank header.
3830 */
3831 private function strip_robots_header(string $content): string {
3832 $pattern = '/^# Robots\.txt generated by ThinkRank SEO\r?\n# [^\r\n]* UTC\r?\n\r?\n/';
3833 return trim((string) preg_replace($pattern, '', $content, 1));
3834 }
3835
3836 /**
3837 * The body that should populate the editor for the current site.
3838 *
3839 * Prefers what is actually being served: the physical file if one exists
3840 * (header stripped), otherwise the effective body. This is what the admin
3841 * screen shows so the textarea is never blank while /robots.txt has content.
3842 *
3843 * @return string
3844 */
3845 public function get_served_robots_body(): string {
3846 $settings = $this->get_settings('site');
3847
3848 // The AI block is stripped from every one of these paths. A physical
3849 // robots.txt we wrote carries it, and the stored override is whatever
3850 // the textarea last held — so without this the block round-trips into
3851 // the editor, gets saved as ordinary body text, and is then appended
3852 // to a second time on the next render.
3853 $custom = trim((string) ($settings['robots_txt_content'] ?? ''));
3854 if ($custom !== '') {
3855 return $this->strip_ai_crawler_block($this->strip_robots_header($custom));
3856 }
3857
3858 $robots_file = ABSPATH . 'robots.txt';
3859 if (file_exists($robots_file)) {
3860 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- an unreadable robots.txt is an expected state answered with an empty string.
3861 $raw = (string) @file_get_contents($robots_file);
3862 if ($raw !== '') {
3863 return $this->strip_ai_crawler_block($this->strip_robots_header($raw));
3864 }
3865 }
3866
3867 return $this->strip_ai_crawler_block(trim($this->generate_robots_txt()['content']));
3868 }
3869 private function get_site_identity_data(array $settings): array {
3870 return [
3871 'site_name' => $settings['site_name'] ?? get_bloginfo('name'),
3872 'site_description' => $settings['site_description'] ?? get_bloginfo('description'),
3873 'tagline' => $settings['tagline'] ?? get_bloginfo('description'),
3874 'logo_url' => $settings['logo_url'] ?? '',
3875 'favicon_url' => $settings['favicon_url'] ?? '',
3876 'apple_touch_icon_url' => $settings['apple_touch_icon_url'] ?? ''
3877 ];
3878 }
3879
3880 /**
3881 * Optimize individual identity element
3882 *
3883 * @since 1.0.0
3884 *
3885 * @param string $element Element name
3886 * @param mixed $value Element value
3887 * @param array $config Element configuration
3888 * @return array Optimization results
3889 */
3890 private function optimize_identity_element(string $element, $value, array $config): array {
3891 $optimization = [
3892 'optimized_value' => $value,
3893 'validation' => [
3894 'valid' => true,
3895 'errors' => [],
3896 'warnings' => []
3897 ],
3898 'suggestions' => []
3899 ];
3900
3901 switch ($config['type']) {
3902 case 'text':
3903 $optimization = $this->optimize_text_element($element, $value, $config, $optimization);
3904 break;
3905 case 'image':
3906 $optimization = $this->optimize_image_element($element, $value, $config, $optimization);
3907 break;
3908 }
3909
3910 return $optimization;
3911 }
3912
3913 /**
3914 * Optimize text identity element
3915 *
3916 * @since 1.0.0
3917 *
3918 * @param string $element Element name
3919 * @param string $value Element value
3920 * @param array $config Element configuration
3921 * @param array $optimization Current optimization
3922 * @return array Updated optimization
3923 */
3924 private function optimize_text_element(string $element, string $value, array $config, array $optimization): array {
3925 if (empty($value) && !empty($config['required'])) {
3926 $optimization['validation']['errors'][] = "{$element} is required";
3927 $optimization['validation']['valid'] = false;
3928 }
3929
3930 if (!empty($value) && isset($config['max_length'])) {
3931 if (strlen($value) > $config['max_length']) {
3932 $optimization['validation']['warnings'][] = "{$element} exceeds maximum length of {$config['max_length']} characters";
3933 $optimization['optimized_value'] = substr($value, 0, $config['max_length']);
3934 }
3935 }
3936
3937 // SEO-specific optimizations
3938 if ($element === 'site_name' && !empty($value)) {
3939 // Remove excessive punctuation
3940 $optimization['optimized_value'] = preg_replace('/[!@#$%^&*()]+/', '', $value);
3941 }
3942
3943 return $optimization;
3944 }
3945
3946 /**
3947 * Optimize image identity element
3948 *
3949 * @since 1.0.0
3950 *
3951 * @param string $element Element name
3952 * @param string $value Element value
3953 * @param array $config Element configuration
3954 * @param array $optimization Current optimization
3955 * @return array Updated optimization
3956 */
3957 private function optimize_image_element(string $element, string $value, array $config, array $optimization): array {
3958 if (empty($value)) {
3959 if (!empty($config['required'])) {
3960 $optimization['validation']['errors'][] = "{$element} is required";
3961 $optimization['validation']['valid'] = false;
3962 }
3963 return $optimization;
3964 }
3965
3966 // Validate URL
3967 if (!filter_var($value, FILTER_VALIDATE_URL)) {
3968 $optimization['validation']['errors'][] = "{$element} must be a valid URL";
3969 $optimization['validation']['valid'] = false;
3970 return $optimization;
3971 }
3972
3973 // Check if it's a local image
3974 $attachment_id = attachment_url_to_postid($value);
3975 if ($attachment_id) {
3976 $image_meta = wp_get_attachment_metadata($attachment_id);
3977
3978 if ($image_meta && isset($image_meta['width'], $image_meta['height'])) {
3979 // Check recommended size
3980 if (isset($config['recommended_size'])) {
3981 [$rec_width, $rec_height] = explode('x', $config['recommended_size']);
3982
3983 if ((int) $image_meta['width'] !== (int) $rec_width || (int) $image_meta['height'] !== (int) $rec_height) {
3984 $optimization['suggestions'][] = "Consider using {$config['recommended_size']} size for optimal {$element}";
3985 }
3986 }
3987
3988 // Check file size
3989 if (isset($config['max_size'])) {
3990 $file_path = get_attached_file($attachment_id);
3991 if ($file_path && file_exists($file_path)) {
3992 $file_size = filesize($file_path);
3993 $max_size_bytes = $this->parse_size_string($config['max_size']);
3994
3995 if ($file_size > $max_size_bytes) {
3996 $optimization['validation']['warnings'][] = "{$element} file size exceeds {$config['max_size']}";
3997 }
3998 }
3999 }
4000 }
4001 }
4002
4003 return $optimization;
4004 }
4005
4006 /**
4007 * Parse size string to bytes
4008 *
4009 * @since 1.0.0
4010 *
4011 * @param string $size_string Size string (e.g., '2MB', '500KB')
4012 * @return int Size in bytes
4013 */
4014 private function parse_size_string(string $size_string): int {
4015 $size_string = strtoupper(trim($size_string));
4016 $size = (int) $size_string;
4017
4018 if (strpos($size_string, 'KB') !== false) {
4019 return $size * 1024;
4020 } elseif (strpos($size_string, 'MB') !== false) {
4021 return $size * 1024 * 1024;
4022 } elseif (strpos($size_string, 'GB') !== false) {
4023 return $size * 1024 * 1024 * 1024;
4024 }
4025
4026 return $size;
4027 }
4028
4029 /**
4030 * Calculate identity optimization score
4031 *
4032 * @since 1.0.0
4033 *
4034 * @param array $validations Element validations
4035 * @return int Score (0-100)
4036 */
4037 private function calculate_identity_score(array $validations): int {
4038 $total_score = 0;
4039 $element_count = 0;
4040
4041 foreach ($validations as $validation) {
4042 $element_score = 100;
4043 $element_score -= count($validation['errors']) * 30;
4044 $element_score -= count($validation['warnings']) * 15;
4045
4046 $total_score += max(0, $element_score);
4047 $element_count++;
4048 }
4049
4050 return $element_count > 0 ? (int) round($total_score / $element_count) : 0;
4051 }
4052
4053 /**
4054 * Calculate validation score
4055 *
4056 * @since 1.0.0
4057 *
4058 * @param array $validation Validation results
4059 * @return int Score (0-100)
4060 */
4061 private function calculate_validation_score(array $validation): int {
4062 $score = 100;
4063 $score -= count($validation['errors']) * 20;
4064 $score -= count($validation['warnings']) * 10;
4065 $score -= count($validation['suggestions']) * 5;
4066
4067 return max(0, $score);
4068 }
4069 }
4070