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

3,913 lines 142.6 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 if ($body === '') {
714 return '';
715 }
716
717 return $this->robots_txt_header() . $body . "\n";
718 }
719
720 /**
721 * Resolve the robots.txt actually served to crawlers, with its origin.
722 *
723 * Lets an API/MCP consumer see the effective output without crawling the
724 * URL. Mirrors serving precedence: a physical robots.txt in the web root is
725 * served verbatim by the web server; otherwise the rendered content (custom
726 * override or generated defaults) is served through the `robots_txt` filter.
727 *
728 * @since 1.20.0
729 * @return array{content: string, is_default: bool, source: string} Effective
730 * robots.txt, whether it is ThinkRank's generated default (vs. a
731 * custom override), and where it originates from.
732 */
733 public function get_effective_robots_txt(): array {
734 // A real file in the web root wins — the web server serves it directly.
735 $robots_file = ABSPATH . 'robots.txt';
736 if (file_exists($robots_file) && is_readable($robots_file)) {
737 // 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.
738 return [
739 // 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.
740 'content' => (string) file_get_contents($robots_file),
741 'is_default' => false,
742 'source' => 'file',
743 ];
744 }
745
746 $settings = $this->get_settings('site');
747
748 // Management disabled — WordPress serves its own core default.
749 if (empty($settings['robots_txt_enabled'])) {
750 return [
751 'content' => '',
752 'is_default' => true,
753 'source' => 'wordpress',
754 ];
755 }
756
757 // A non-empty stored override replaces the generated defaults.
758 $custom = trim((string) ($settings['robots_txt_content'] ?? ''));
759
760 return [
761 'content' => $this->render_robots_txt(),
762 'is_default' => $custom === '',
763 'source' => $custom === '' ? 'generated' : 'custom',
764 ];
765 }
766
767 /**
768 * Describe how /robots.txt is actually delivered, and whether that still
769 * matches the saved settings.
770 *
771 * The admin screen edits settings, but a physical robots.txt in the web root
772 * is served directly by the web server and bypasses the `robots_txt` filter
773 * entirely. When those two drift, the editor is showing content no crawler
774 * ever sees — the conflict this exists to surface.
775 *
776 * @since 1.31.0
777 *
778 * @return array{content: string, source: string, is_default: bool, in_sync: bool, out_of_sync_reason: string, url: string}
779 * The served content and its origin, whether it still reflects the
780 * body the editor is showing, why it does not when it does not
781 * ('file_drift' or 'crawl_blocked'), and the public URL it is
782 * served from.
783 */
784 public function get_robots_txt_delivery(): array {
785 $effective = $this->get_effective_robots_txt();
786 $settings = $this->get_settings('site');
787
788 // Compare bodies, not raw strings: the auto-generated header carries a
789 // regeneration timestamp that always differs and means nothing here.
790 $served = $this->strip_robots_header($effective['content']);
791
792 // Measure against the body the editor is displaying — get_served_robots_body()
793 // — not against render_robots_txt(). Two things made the old comparison
794 // report "in sync" while the screen showed rules no crawler receives:
795 // a physical file was compared to a freshly rendered body rather than
796 // to the stored override the textarea shows, and a site-wide crawl
797 // block makes render_robots_txt() return the generated "Disallow: /"
798 // on both sides of the comparison, so it always matched.
799 $expected = $this->get_served_robots_body();
800
801 // Management off: WordPress serves its own default and the editor is not
802 // claiming anything is live, so there is nothing to be out of sync with.
803 $managed = !empty($settings['robots_txt_enabled']);
804 $in_sync = !$managed || $served === $expected;
805
806 $reason = '';
807 if (!$in_sync) {
808 // A crawl block is a deliberate override, not a stale file, and the
809 // admin needs to be told which of the two they are looking at.
810 $blocked = empty($settings['allow_search_engines'] ?? true) || !get_option('blog_public');
811 $reason = $blocked ? 'crawl_blocked' : 'file_drift';
812 }
813
814 return [
815 'content' => $effective['content'],
816 'source' => $effective['source'],
817 'is_default' => $effective['is_default'],
818 'in_sync' => $in_sync,
819 'out_of_sync_reason' => $reason,
820 'url' => home_url('/robots.txt'),
821 ];
822 }
823
824 /**
825 * Keep the physical robots.txt file in step with the saved settings.
826 *
827 * When management is enabled the physical file is the source of truth the
828 * web server serves, so this makes sure it exists and matches the effective
829 * content — creating it if missing. When management is disabled it removes
830 * any existing file so WordPress serves its default again. Callers invoke
831 * this after saving robots settings so a plain Save both creates and
832 * refreshes the file without a separate "Generate" step.
833 *
834 * @since 1.20.0
835 * @return bool True if the file was written or removed as intended.
836 */
837 public function sync_robots_txt_file(): bool {
838 $robots_file = ABSPATH . 'robots.txt';
839 $settings = $this->get_settings('site');
840
841 // Management turned off: drop any existing file so WordPress serves its
842 // default again, rather than leaving a stale ThinkRank file behind.
843 if (empty($settings['robots_txt_enabled'])) {
844 if (file_exists($robots_file) && $this->init_filesystem()) {
845 $this->filesystem->delete($robots_file);
846 }
847 return true;
848 }
849
850 $content = $this->render_robots_txt();
851 if ($content === '') {
852 return false;
853 }
854
855 // Run the effective content through the standard robots_txt filter so
856 // lines added by other integrations (ThinkRank Pro's News/Video
857 // Publisher Sitemaps at priority 999, and any third-party plugin) are
858 // baked into the physical file. A physical robots.txt bypasses core's
859 // do_robots()/robots_txt filter entirely, so without this those lines
860 // are silently dropped. ThinkRank's own filter_robots_txt callback just
861 // re-returns this same content (it calls render_robots_txt(), which does
862 // not re-apply the filter), so there is no recursion or double-append.
863 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML/core hook, not ours to name.
864 $content = (string) apply_filters('robots_txt', $content, (bool) get_option('blog_public'));
865 if ($content === '') {
866 return false;
867 }
868
869 // write_robots_txt() creates the file when absent and overwrites it
870 // otherwise, so this covers both first-time creation and re-sync.
871 $result = $this->write_robots_txt($content);
872 return !empty($result['success']);
873 }
874
875 /**
876 * Write robots.txt content to filesystem
877 *
878 * @since 1.0.0
879 *
880 * @param string $content Robots.txt content to write
881 * @return array Write operation result
882 */
883 public function write_robots_txt(string $content): array {
884 $result = [
885 'success' => false,
886 'message' => '',
887 'file_path' => '',
888 'permissions' => []
889 ];
890
891 $robots_file = ABSPATH . 'robots.txt';
892
893 // Security: Validate file path to prevent path traversal attacks
894 $real_robots_file = realpath(dirname($robots_file)) . DIRECTORY_SEPARATOR . basename($robots_file);
895 $allowed_dir = realpath(ABSPATH);
896
897 if (!$allowed_dir || strpos(dirname($real_robots_file), $allowed_dir) !== 0) {
898 $result['message'] = 'Invalid file path detected for security reasons.';
899 return $result;
900 }
901
902 $result['file_path'] = $robots_file;
903
904 // Check directory permissions
905 $result['permissions'] = [
906 'directory_writable' => $this->is_directory_writable(ABSPATH),
907 'file_exists' => file_exists($robots_file),
908 'file_writable' => file_exists($robots_file) ? $this->is_file_writable($robots_file) : null
909 ];
910
911 // Check if we can write to the directory
912 if (!$result['permissions']['directory_writable']) {
913 $result['message'] = 'WordPress root directory is not writable. Please check file permissions.';
914 return $result;
915 }
916
917 // Check if existing file is writable (if it exists)
918 if ($result['permissions']['file_exists'] && !$result['permissions']['file_writable']) {
919 $result['message'] = 'Existing robots.txt file is not writable. Please check file permissions.';
920 return $result;
921 }
922
923 try {
924 // Write new content using WP_Filesystem
925 if (!$this->init_filesystem()) {
926 $result['message'] = 'Could not initialize WordPress filesystem.';
927 return $result;
928 }
929
930 $write_success = $this->filesystem->put_contents($robots_file, $content, FS_CHMOD_FILE);
931
932 if ($write_success) {
933 $result['success'] = true;
934 $result['message'] = 'Robots.txt file written successfully.';
935 $result['bytes_written'] = strlen($content);
936 } else {
937 $result['message'] = 'Failed to write robots.txt file.';
938 }
939 } catch (\Exception $e) {
940 $result['message'] = 'Error writing robots.txt file: ' . $e->getMessage();
941 }
942
943 return $result;
944 }
945
946 /**
947 * Optimize site identity data with comprehensive analysis
948 *
949 * @since 1.0.0
950 *
951 * @param array $identity_data Site identity data to optimize
952 * @param array $options Optimization options including section focus
953 * @return array Optimized identity data with validation
954 */
955 public function optimize_site_identity(array $identity_data, array $options = []): array {
956 $optimization = [
957 'optimized_data' => [],
958 'validation' => [],
959 'suggestions' => [],
960 'warnings' => [],
961 'improvements' => [],
962 'score' => 0,
963 'section_scores' => []
964 ];
965
966 // Determine optimization focus
967 $focus = $options['focus'] ?? 'all';
968
969 // Section-specific optimization
970 if ($focus === 'title_formats' || $focus === 'all') {
971 $title_optimization = $this->optimize_title_formats($identity_data);
972 $optimization['section_scores']['title_formats'] = $title_optimization['score'];
973 $optimization['suggestions'] = array_merge($optimization['suggestions'], $title_optimization['suggestions']);
974 $optimization['warnings'] = array_merge($optimization['warnings'], $title_optimization['warnings']);
975 }
976
977 if ($focus === 'breadcrumbs' || $focus === 'all') {
978 $breadcrumb_optimization = $this->optimize_breadcrumbs($identity_data);
979 $optimization['section_scores']['breadcrumbs'] = $breadcrumb_optimization['score'];
980 $optimization['suggestions'] = array_merge($optimization['suggestions'], $breadcrumb_optimization['suggestions']);
981 $optimization['warnings'] = array_merge($optimization['warnings'], $breadcrumb_optimization['warnings']);
982 }
983
984 if ($focus === 'robots_txt' || $focus === 'all') {
985 $robots_optimization = $this->optimize_robots_txt($identity_data);
986 $optimization['section_scores']['robots_txt'] = $robots_optimization['score'];
987 $optimization['suggestions'] = array_merge($optimization['suggestions'], $robots_optimization['suggestions']);
988 $optimization['warnings'] = array_merge($optimization['warnings'], $robots_optimization['warnings']);
989 }
990
991 if ($focus === 'site_assets' || $focus === 'all') {
992 $assets_optimization = $this->optimize_site_assets($identity_data);
993 $optimization['section_scores']['site_assets'] = $assets_optimization['score'];
994 $optimization['suggestions'] = array_merge($optimization['suggestions'], $assets_optimization['suggestions']);
995 $optimization['warnings'] = array_merge($optimization['warnings'], $assets_optimization['warnings']);
996 }
997
998 // Legacy element-by-element optimization for basic site info
999 if ($focus === 'site_info' || $focus === 'all') {
1000 foreach ($this->identity_elements as $element => $config) {
1001 if (isset($identity_data[$element])) {
1002 $element_optimization = $this->optimize_identity_element(
1003 $element,
1004 $identity_data[$element],
1005 $config
1006 );
1007
1008 $optimization['optimized_data'][$element] = $element_optimization['optimized_value'];
1009 $optimization['validation'][$element] = $element_optimization['validation'];
1010 $optimization['suggestions'] = array_merge(
1011 $optimization['suggestions'],
1012 $element_optimization['suggestions']
1013 );
1014 }
1015 }
1016 }
1017
1018 // Calculate overall optimization score
1019 if (!empty($optimization['section_scores'])) {
1020 $optimization['score'] = (int) round(array_sum($optimization['section_scores']) / count($optimization['section_scores']));
1021 } else {
1022 $optimization['score'] = $this->calculate_identity_score($optimization['validation']);
1023 }
1024
1025 // Store optimization results in seo_analysis table
1026 $this->store_optimization_results($optimization, $focus);
1027
1028 return $optimization;
1029 }
1030
1031 /**
1032 * Optimize title formats with enhanced rules
1033 *
1034 * @since 1.0.0
1035 *
1036 * @param array $settings Title format settings
1037 * @return array Optimization results
1038 */
1039 public function optimize_title_formats(array $settings): array {
1040 $optimization = [
1041 'score' => 100,
1042 'suggestions' => [],
1043 'warnings' => [],
1044 'improvements' => []
1045 ];
1046
1047 // Check separator choice (applies to every context template).
1048 $separator = $settings['title_separator'] ?? 'pipe';
1049 $separator_data = self::$title_separators[$separator] ?? null;
1050 if ($separator_data) {
1051 $seo_score = $separator_data['seo_score'] ?? 5;
1052 if ($seo_score < 8) {
1053 $optimization['suggestions'][] = "Consider using '|' or '-' separators for better SEO performance";
1054 $optimization['score'] -= (10 - $seo_score);
1055 }
1056 }
1057
1058 // Analyze the per-context templates the Title Formats UI actually edits
1059 // and the front end actually renders — not the legacy `title_template`
1060 // enum, which this screen never sets.
1061 $context_labels = [
1062 'homepage_title' => 'Homepage',
1063 'post_title' => 'Post',
1064 'page_title' => 'Page',
1065 'category_title' => 'Category',
1066 'tag_title' => 'Tag',
1067 'author_title' => 'Author',
1068 'search_title' => 'Search',
1069 'archive_title' => 'Archive',
1070 ];
1071
1072 $configured = 0;
1073 foreach ($context_labels as $key => $label) {
1074 $template = isset($settings[$key]) ? trim((string) $settings[$key]) : '';
1075 if ($template === '') {
1076 continue; // Unconfigured — the front end falls back for this context.
1077 }
1078 $configured++;
1079
1080 // Brand recognition: the title should carry the site name.
1081 if (strpos($template, '%site_title%') === false && strpos($template, '%site_name%') === false) {
1082 $optimization['suggestions'][] = "{$label} title has no site name — add %site_title% for brand recognition";
1083 $optimization['score'] -= 5;
1084 }
1085
1086 // Length check against the ~60-char guideline, measured on the
1087 // resolved title for THIS context (with representative sample data).
1088 $sample_length = strlen($this->generate_sample_title($settings, $key));
1089 if ($sample_length > 60) {
1090 $optimization['warnings'][] = "{$label} title renders about {$sample_length} characters (over the 60-character limit)";
1091 $optimization['score'] -= 10;
1092 } elseif ($sample_length > 0 && $sample_length < 20) {
1093 $optimization['suggestions'][] = "{$label} title renders only about {$sample_length} characters — consider adding more context";
1094 $optimization['score'] -= 3;
1095 }
1096 }
1097
1098 // No context templates set at all — ThinkRank won't control any titles.
1099 if ($configured === 0) {
1100 $optimization['suggestions'][] = 'No title formats are configured — set templates so ThinkRank controls your page titles';
1101 $optimization['score'] -= 10;
1102 }
1103
1104 $optimization['score'] = max(0, min(100, $optimization['score']));
1105
1106 return $optimization;
1107 }
1108
1109 /**
1110 * Optimize breadcrumb settings with UX best practices
1111 *
1112 * @since 1.0.0
1113 *
1114 * @param array $settings Breadcrumb settings
1115 * @return array Optimization results
1116 */
1117 public function optimize_breadcrumbs(array $settings): array {
1118 $optimization = [
1119 'score' => 100,
1120 'suggestions' => [],
1121 'warnings' => [],
1122 'improvements' => []
1123 ];
1124
1125 // Check if breadcrumbs are enabled
1126 if (!($settings['breadcrumbs_enabled'] ?? true)) {
1127 $optimization['suggestions'][] = 'Enable breadcrumbs to improve user navigation and SEO (recommended by Google)';
1128 $optimization['score'] = 20; // Major penalty for disabled breadcrumbs
1129 return $optimization;
1130 }
1131
1132 // Validate breadcrumb type
1133 $type = $settings['breadcrumb_type'] ?? 'hierarchical';
1134 $type_scores = [
1135 'hierarchical' => 100,
1136 'category_based' => 90,
1137 'simple' => 70,
1138 'custom' => 80
1139 ];
1140
1141 $type_score = $type_scores[$type] ?? 60;
1142 $optimization['score'] = min($optimization['score'], $type_score);
1143
1144 if ($type === 'simple') {
1145 $optimization['suggestions'][] = 'Consider hierarchical breadcrumbs for better site structure representation';
1146 }
1147
1148 // Validate separator choice
1149 $separator = $settings['breadcrumb_separator'] ?? '';
1150 $separator_ux = [
1151 '' => ['score' => 100, 'note' => 'Clear directional indicator'],
1152 '>' => ['score' => 95, 'note' => 'Simple and effective'],
1153 '/' => ['score' => 85, 'note' => 'Familiar but can confuse with URLs'],
1154 '|' => ['score' => 75, 'note' => 'Less intuitive for navigation'],
1155 '»' => ['score' => 90, 'note' => 'Distinctive double arrow']
1156 ];
1157
1158 $sep_data = $separator_ux[$separator] ?? ['score' => 50, 'note' => 'Unusual choice'];
1159 $optimization['score'] = min($optimization['score'], $sep_data['score']);
1160
1161 if ($sep_data['score'] < 95) {
1162 $optimization['suggestions'][] = "Separator '{$separator}': {$sep_data['note']}";
1163 }
1164
1165 // Validate home text
1166 $home_text = $settings['breadcrumb_home_text'] ?? 'Home';
1167 if (empty($home_text)) {
1168 $optimization['warnings'][] = 'Empty home text reduces accessibility for screen readers';
1169 $optimization['score'] -= 15;
1170 } elseif (strlen($home_text) > 20) {
1171 $optimization['suggestions'][] = 'Keep home text concise (current: ' . strlen($home_text) . ' chars)';
1172 $optimization['score'] -= 5;
1173 }
1174
1175 // Check prefix usage
1176 $prefix = $settings['breadcrumb_prefix'] ?? '';
1177 if (!empty($prefix) && strlen($prefix) > 50) {
1178 $optimization['suggestions'][] = 'Breadcrumb prefix is quite long (' . strlen($prefix) . ' chars) - consider shortening';
1179 $optimization['score'] -= 5;
1180 }
1181
1182 // Current page display
1183 if (!($settings['show_current_page'] ?? true)) {
1184 $optimization['suggestions'][] = 'Show current page in breadcrumbs for better user orientation';
1185 $optimization['score'] -= 10;
1186 }
1187
1188 return $optimization;
1189 }
1190
1191 /**
1192 * Optimize robots.txt settings with technical SEO best practices
1193 *
1194 * @since 1.0.0
1195 *
1196 * @param array $settings Robots.txt settings
1197 * @return array Optimization results
1198 */
1199 public function optimize_robots_txt(array $settings): array {
1200 $optimization = [
1201 'score' => 100,
1202 'suggestions' => [],
1203 'warnings' => [],
1204 'improvements' => []
1205 ];
1206
1207 // Check if robots.txt management is enabled
1208 if (!($settings['robots_txt_enabled'] ?? true)) {
1209 $optimization['suggestions'][] = 'Enable robots.txt management for better SEO control and automated updates';
1210 $optimization['score'] = 30;
1211 return $optimization;
1212 }
1213
1214 // Critical: Search engine access
1215 if (!($settings['allow_search_engines'] ?? true)) {
1216 $optimization['warnings'][] = 'CRITICAL: Search engines are blocked - your site will not be indexed by Google, Bing, etc.';
1217 $optimization['score'] = 10; // Severe penalty
1218 }
1219
1220 // Sitemap URL validation (now from sitemap settings)
1221 $sitemap_urls = $this->get_sitemap_urls_for_robots();
1222 if (empty($sitemap_urls)) {
1223 $optimization['suggestions'][] = 'Enable sitemap generation to include sitemap URLs in robots.txt';
1224 $optimization['score'] -= 15;
1225 } else {
1226 // Validate first sitemap accessibility (representative check)
1227 $first_sitemap = $sitemap_urls[0];
1228 $sitemap_response = wp_remote_head($first_sitemap, ['timeout' => 10]);
1229 if (is_wp_error($sitemap_response) || wp_remote_retrieve_response_code($sitemap_response) !== 200) {
1230 $optimization['warnings'][] = 'Primary sitemap URL is not accessible - check sitemap generation';
1231 $optimization['score'] -= 10;
1232 }
1233 }
1234
1235 // File system permissions
1236 $robots_file = ABSPATH . 'robots.txt';
1237 $robots_dir = dirname($robots_file);
1238
1239 if (!$this->is_directory_writable($robots_dir)) {
1240 $optimization['warnings'][] = 'WordPress root directory is not writable - robots.txt cannot be managed automatically';
1241 $optimization['score'] -= 15;
1242 } elseif (file_exists($robots_file) && !$this->is_file_writable($robots_file)) {
1243 $optimization['warnings'][] = 'Existing robots.txt file is not writable - cannot update automatically';
1244 $optimization['score'] -= 10;
1245 }
1246
1247 // Content analysis
1248 $custom_content = $settings['robots_txt_content'] ?? '';
1249 if (!empty($custom_content)) {
1250 // Check for dangerous patterns
1251 if (preg_match('/User-agent:\s*\*\s*\n\s*Disallow:\s*\/\s*$/m', $custom_content)) {
1252 $optimization['warnings'][] = 'Blocking all content for all crawlers - this will prevent search engine indexing';
1253 $optimization['score'] -= 30;
1254 }
1255
1256 // Check for sitemap declaration in content
1257 if (!empty($sitemap_urls) && strpos($custom_content, 'Sitemap:') === false) {
1258 $optimization['suggestions'][] = 'Sitemap URLs are automatically included in generated robots.txt';
1259 $optimization['score'] -= 5;
1260 }
1261 }
1262
1263 return $optimization;
1264 }
1265
1266 /**
1267 * Optimize site assets (logo, favicon, apple touch icon)
1268 *
1269 * @since 1.0.0
1270 *
1271 * @param array $settings Site assets settings
1272 * @return array Optimization results
1273 */
1274 public function optimize_site_assets(array $settings): array {
1275 $optimization = [
1276 'score' => 100,
1277 'suggestions' => [],
1278 'warnings' => [],
1279 'improvements' => []
1280 ];
1281
1282 // Check site logo
1283 $logo_url = $settings['logo_url'] ?? '';
1284 if (empty($logo_url)) {
1285 $optimization['suggestions'][] = 'Add a site logo for better branding and professional appearance';
1286 $optimization['score'] -= 20;
1287 } else {
1288 // Validate logo URL and dimensions
1289 if (!filter_var($logo_url, FILTER_VALIDATE_URL)) {
1290 $optimization['warnings'][] = 'Logo URL format is invalid';
1291 $optimization['score'] -= 15;
1292 }
1293 }
1294
1295 // Check favicon
1296 $favicon_url = $settings['favicon_url'] ?? '';
1297 if (empty($favicon_url)) {
1298 $optimization['suggestions'][] = 'Add a favicon for better browser tab identification';
1299 $optimization['score'] -= 15;
1300 }
1301
1302 // Check Apple touch icon
1303 $apple_icon_url = $settings['apple_touch_icon_url'] ?? '';
1304 if (empty($apple_icon_url)) {
1305 $optimization['suggestions'][] = 'Add an Apple touch icon for better iOS device experience';
1306 $optimization['score'] -= 10;
1307 }
1308
1309 // Additional logo analysis for local images
1310 if (!empty($logo_url) && filter_var($logo_url, FILTER_VALIDATE_URL)) {
1311 $attachment_id = attachment_url_to_postid($logo_url);
1312 if ($attachment_id) {
1313 $image_meta = wp_get_attachment_metadata($attachment_id);
1314 $width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0;
1315 $height = isset($image_meta['height']) ? (int) $image_meta['height'] : 0;
1316
1317 // SVG logos store 0x0 metadata — no dimension/ratio analysis
1318 // is possible (and dividing by 0 is fatal).
1319 if ($image_meta && $width > 0 && $height > 0) {
1320 if ($width < 112 || $height < 112) {
1321 $optimization['warnings'][] = "Logo dimensions ({$width}x{$height}) are below recommended minimum (112x112)";
1322 $optimization['score'] -= 10;
1323 }
1324
1325 if ($width > 1920 || $height > 1920) {
1326 $optimization['suggestions'][] = "Logo dimensions ({$width}x{$height}) are very large - consider optimizing for faster loading";
1327 $optimization['score'] -= 5;
1328 }
1329
1330 // Aspect ratio check
1331 $ratio = $width / $height;
1332 if ($ratio < 0.5 || $ratio > 2.0) {
1333 $optimization['suggestions'][] = 'Logo aspect ratio should be between 1:2 and 2:1 for optimal display';
1334 $optimization['score'] -= 5;
1335 }
1336 }
1337 }
1338 }
1339
1340 return $optimization;
1341 }
1342
1343 /**
1344 * Optimize local SEO settings for better local search visibility
1345 *
1346 * @since 1.0.0
1347 *
1348 * @param array $settings Local SEO settings
1349 * @return array Optimization results
1350 */
1351 public function optimize_local_seo(array $settings): array {
1352 $optimization = [
1353 'score' => 100,
1354 'suggestions' => [],
1355 'warnings' => [],
1356 'improvements' => [],
1357 'optimized_data' => []
1358 ];
1359
1360 // Check if local SEO is enabled
1361 if (empty($settings['local_seo_enabled'])) {
1362 $optimization['warnings'][] = 'Local SEO is disabled - enable it to improve local search visibility';
1363 $optimization['score'] -= 20;
1364 return $optimization;
1365 }
1366
1367 // Validate business name (required for local SEO)
1368 if (empty($settings['business_name'])) {
1369 $optimization['warnings'][] = 'Business name is required for local SEO';
1370 $optimization['score'] -= 25;
1371 } else {
1372 // Optimize business name
1373 $optimized_name = $this->optimize_business_name($settings['business_name']);
1374 if ($optimized_name !== $settings['business_name']) {
1375 $optimization['optimized_data']['business_name'] = $optimized_name;
1376 $optimization['suggestions'][] = 'Business name optimized for better local search visibility';
1377 }
1378 }
1379
1380 // Validate complete address (NAP consistency)
1381 $address_score = $this->validate_business_address($settings, $optimization);
1382 $optimization['score'] -= (100 - $address_score);
1383
1384 // Validate phone number
1385 if (empty($settings['business_phone'])) {
1386 $optimization['warnings'][] = 'Business phone number is missing - important for local SEO and NAP consistency';
1387 $optimization['score'] -= 15;
1388 } else {
1389 $optimized_phone = $this->optimize_phone_number($settings['business_phone']);
1390 if ($optimized_phone !== $settings['business_phone']) {
1391 $optimization['optimized_data']['business_phone'] = $optimized_phone;
1392 $optimization['suggestions'][] = 'Phone number formatted for better consistency';
1393 }
1394 }
1395
1396 // Validate business hours
1397 if (empty($settings['business_hours']) || !is_array($settings['business_hours'])) {
1398 $optimization['suggestions'][] = 'Add business hours to improve local search visibility and customer experience';
1399 $optimization['score'] -= 10;
1400 } else {
1401 $hours_validation = $this->validate_business_hours($settings['business_hours']);
1402 if (!$hours_validation['valid']) {
1403 $optimization['warnings'] = array_merge($optimization['warnings'], $hours_validation['warnings']);
1404 $optimization['score'] -= $hours_validation['penalty'];
1405 }
1406 }
1407
1408 // Check for geo-coordinates
1409 if (empty($settings['business_latitude']) || empty($settings['business_longitude'])) {
1410 $optimization['suggestions'][] = 'Add latitude and longitude coordinates for precise location targeting';
1411 $optimization['score'] -= 10;
1412 } else {
1413 // Validate coordinates
1414 if (!$this->validate_coordinates($settings['business_latitude'], $settings['business_longitude'])) {
1415 $optimization['warnings'][] = 'Invalid latitude or longitude coordinates';
1416 $optimization['score'] -= 15;
1417 }
1418 }
1419
1420 // Business type validation
1421 if (empty($settings['business_type'])) {
1422 $optimization['suggestions'][] = 'Select a specific business type for better schema markup';
1423 $optimization['score'] -= 5;
1424 }
1425
1426 // Email validation
1427 if (!empty($settings['business_email']) && !is_email($settings['business_email'])) {
1428 $optimization['warnings'][] = 'Business email format is invalid';
1429 $optimization['score'] -= 10;
1430 }
1431
1432 // Local SEO best practices
1433 $this->add_local_seo_best_practices($optimization, $settings);
1434
1435 return $optimization;
1436 }
1437
1438 /**
1439 * Optimize business name for local SEO
1440 *
1441 * @param string $business_name Original business name
1442 * @return string Optimized business name
1443 */
1444 private function optimize_business_name(string $business_name): string {
1445 // Remove excessive punctuation and normalize spacing
1446 $optimized = preg_replace('/[^\w\s\-&.,]/', '', $business_name);
1447 $optimized = preg_replace('/\s+/', ' ', $optimized);
1448 $optimized = trim($optimized);
1449
1450 // Ensure proper capitalization
1451 $optimized = ucwords(strtolower($optimized));
1452
1453 return $optimized;
1454 }
1455
1456 /**
1457 * Validate business address components
1458 *
1459 * @param array $settings Business settings
1460 * @param array &$optimization Optimization results (passed by reference)
1461 * @return int Address completeness score (0-100)
1462 */
1463 private function validate_business_address(array $settings, array &$optimization): int {
1464 $score = 100;
1465 $required_fields = ['business_address', 'business_city', 'business_state', 'business_country'];
1466 $missing_fields = [];
1467
1468 foreach ($required_fields as $field) {
1469 if (empty($settings[$field])) {
1470 $missing_fields[] = str_replace('business_', '', $field);
1471 $score -= 20;
1472 }
1473 }
1474
1475 if (!empty($missing_fields)) {
1476 $optimization['warnings'][] = 'Missing address components: ' . implode(', ', $missing_fields) . ' - important for NAP consistency';
1477 }
1478
1479 // Postal code is recommended but not required
1480 if (empty($settings['business_postal_code'])) {
1481 $optimization['suggestions'][] = 'Add postal code for more precise location targeting';
1482 $score -= 5;
1483 }
1484
1485 return max(0, $score);
1486 }
1487
1488 /**
1489 * Optimize phone number format for consistency
1490 *
1491 * @param string $phone_number Original phone number
1492 * @return string Optimized phone number
1493 */
1494 private function optimize_phone_number(string $phone_number): string {
1495 // Remove all non-numeric characters except + for international numbers
1496 $cleaned = preg_replace('/[^\d+]/', '', $phone_number);
1497
1498 // If it's a US number (10 digits), format as (XXX) XXX-XXXX
1499 if (preg_match('/^(\d{10})$/', $cleaned, $matches)) {
1500 return '(' . substr($matches[1], 0, 3) . ') ' . substr($matches[1], 3, 3) . '-' . substr($matches[1], 6);
1501 }
1502
1503 // If it's a US number with country code, format as +1 (XXX) XXX-XXXX
1504 if (preg_match('/^1(\d{10})$/', $cleaned, $matches)) {
1505 return '+1 (' . substr($matches[1], 0, 3) . ') ' . substr($matches[1], 3, 3) . '-' . substr($matches[1], 6);
1506 }
1507
1508 // For international numbers, keep the + and return as-is
1509 return $cleaned;
1510 }
1511
1512 /**
1513 * Validate business hours format and completeness
1514 *
1515 * @param array $business_hours Business hours array
1516 * @return array Validation results
1517 */
1518 private function validate_business_hours(array $business_hours): array {
1519 $validation = [
1520 'valid' => true,
1521 'warnings' => [],
1522 'penalty' => 0
1523 ];
1524
1525 $days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
1526 $open_days = 0;
1527
1528 foreach ($days as $day) {
1529 if (!isset($business_hours[$day])) {
1530 continue;
1531 }
1532
1533 $day_data = $business_hours[$day];
1534
1535 if (empty($day_data['closed'])) {
1536 $open_days++;
1537
1538 // Validate time format
1539 if (empty($day_data['open']) || empty($day_data['close'])) {
1540 $validation['warnings'][] = "Missing opening or closing time for {$day}";
1541 $validation['penalty'] += 2;
1542 } else {
1543 // Validate time format (HH:MM)
1544 if (!preg_match('/^\d{2}:\d{2}$/', $day_data['open']) || !preg_match('/^\d{2}:\d{2}$/', $day_data['close'])) {
1545 $validation['warnings'][] = "Invalid time format for {$day} (use HH:MM format)";
1546 $validation['penalty'] += 2;
1547 }
1548 }
1549 }
1550 }
1551
1552 if ($open_days === 0) {
1553 $validation['warnings'][] = 'No business hours specified - all days marked as closed';
1554 $validation['penalty'] += 10;
1555 }
1556
1557 if ($validation['penalty'] > 0) {
1558 $validation['valid'] = false;
1559 }
1560
1561 return $validation;
1562 }
1563
1564 /**
1565 * Validate latitude and longitude coordinates
1566 *
1567 * @param string $latitude Latitude coordinate
1568 * @param string $longitude Longitude coordinate
1569 * @return bool True if coordinates are valid
1570 */
1571 private function validate_coordinates(string $latitude, string $longitude): bool {
1572 $lat = floatval($latitude);
1573 $lng = floatval($longitude);
1574
1575 // Validate latitude range (-90 to 90)
1576 if ($lat < -90 || $lat > 90) {
1577 return false;
1578 }
1579
1580 // Validate longitude range (-180 to 180)
1581 if ($lng < -180 || $lng > 180) {
1582 return false;
1583 }
1584
1585 return true;
1586 }
1587
1588 /**
1589 * Add local SEO best practices suggestions
1590 *
1591 * @param array &$optimization Optimization results (passed by reference)
1592 * @param array $settings Business settings
1593 * @return void
1594 */
1595 private function add_local_seo_best_practices(array &$optimization, array $settings): void {
1596 // Check for Google My Business integration
1597 if (empty($settings['google_my_business_url'])) {
1598 $optimization['suggestions'][] = 'Consider adding your Google My Business profile URL for better local visibility';
1599 }
1600
1601 // Check for social media profiles
1602 $social_platforms = ['facebook_url', 'twitter_url', 'instagram_url', 'linkedin_url'];
1603 $has_social = false;
1604 foreach ($social_platforms as $platform) {
1605 if (!empty($settings[$platform])) {
1606 $has_social = true;
1607 break;
1608 }
1609 }
1610
1611 if (!$has_social) {
1612 $optimization['suggestions'][] = 'Add social media profiles to improve local business credibility';
1613 }
1614
1615 // Check for business description
1616 if (empty($settings['business_description'])) {
1617 $optimization['suggestions'][] = 'Add a business description for better context in local search results';
1618 }
1619
1620 // Service area suggestions
1621 if (empty($settings['service_areas'])) {
1622 $optimization['suggestions'][] = 'Define service areas if your business serves multiple locations';
1623 }
1624 }
1625
1626 /**
1627 * Generate a sample rendered title for a given context template, so the
1628 * optimizer can measure the length users will actually see.
1629 *
1630 * Resolves the per-context template (e.g. `post_title`) with representative
1631 * sample values for the same variable tokens the front-end renderer fills
1632 * in (see SEO_Manager::get_title_placeholders()).
1633 *
1634 * @since 1.0.0
1635 *
1636 * @param array $settings Title format settings
1637 * @param string $context_key Per-context template key (e.g. 'post_title')
1638 * @return string Resolved sample title (empty string when the template is unset)
1639 */
1640 private function generate_sample_title(array $settings, string $context_key = 'post_title'): string {
1641 $template = isset($settings[$context_key]) ? trim((string) $settings[$context_key]) : '';
1642 if ($template === '') {
1643 return '';
1644 }
1645
1646 $separator = $settings['title_separator'] ?? 'pipe';
1647 $separator_symbol = self::$title_separators[$separator]['symbol'] ?? '|';
1648
1649 $site_name = $settings['site_name'] ?? '';
1650 if ($site_name === '') {
1651 $site_name = get_bloginfo('name') ?: 'Your Site Name';
1652 }
1653 $site_description = $settings['site_description'] ?? '';
1654 if ($site_description === '') {
1655 $site_description = get_bloginfo('description') ?: 'Your Site Description';
1656 }
1657 $tagline = $settings['tagline'] ?? '';
1658 if ($tagline === '') {
1659 $tagline = $site_description;
1660 }
1661
1662 // Representative sample values for the variable tokens the front end
1663 // substitutes per request. Keys mirror get_title_placeholders().
1664 $sample_data = [
1665 '%site_title%' => $site_name,
1666 '%site_name%' => $site_name,
1667 '%site_description%' => $site_description,
1668 '%tagline%' => $tagline,
1669 '%sep%' => ' ' . $separator_symbol . ' ',
1670 '%separator%' => ' ' . $separator_symbol . ' ',
1671 '%post_title%' => 'How to Optimize Your Website for Better SEO Results',
1672 '%page_title%' => 'About Our Company',
1673 '%category_title%' => 'SEO Tips',
1674 '%category%' => 'SEO Tips',
1675 '%tag_title%' => 'On-Page SEO',
1676 '%tag%' => 'On-Page SEO',
1677 '%author_name%' => 'Jane Doe',
1678 '%author%' => 'Jane Doe',
1679 '%search_term%' => 'keyword research',
1680 '%search_phrase%' => 'keyword research',
1681 '%archive_title%' => 'July 2026',
1682 '%date%' => gmdate('F Y'),
1683 ];
1684
1685 $title = str_replace(array_keys($sample_data), array_values($sample_data), $template);
1686
1687 // Collapse whitespace left by any empty/unresolved tokens, then trim.
1688 $title = preg_replace('/\s+/', ' ', $title);
1689
1690 return trim($title);
1691 }
1692
1693 /**
1694 * Store optimization results in seo_analysis table
1695 *
1696 * @since 1.0.0
1697 *
1698 * @param array $optimization Optimization results
1699 * @param string $focus Optimization focus section
1700 * @return void
1701 */
1702 private function store_optimization_results(array $optimization, string $focus): void {
1703 global $wpdb;
1704
1705 $table_name = $wpdb->prefix . 'thinkrank_seo_analysis';
1706
1707 // Only store if we have meaningful results
1708 if (empty($optimization['suggestions']) && empty($optimization['warnings'])) {
1709 return;
1710 }
1711
1712 $analysis_type = 'site_identity_rule_optimization';
1713 if ($focus !== 'all') {
1714 $analysis_type .= '_' . $focus;
1715 }
1716
1717 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Site identity analysis storage requires direct database access
1718 $wpdb->insert(
1719 $table_name,
1720 [
1721 'context_type' => 'site',
1722 'context_id' => null,
1723 'analysis_type' => $analysis_type,
1724 'analysis_data' => wp_json_encode($optimization),
1725 'score' => $optimization['score'],
1726 'status' => 'completed',
1727 'recommendations' => wp_json_encode($optimization['suggestions']),
1728 'validation_errors' => wp_json_encode($optimization['warnings']),
1729 'analyzed_by' => get_current_user_id()
1730 ],
1731 ['%s', '%d', '%s', '%s', '%d', '%s', '%s', '%s', '%d']
1732 );
1733 }
1734
1735 /**
1736 * Validate SEO settings (implements interface)
1737 *
1738 * @since 1.0.0
1739 *
1740 * @param array $settings Settings array to validate
1741 * @param string $tab_context Optional tab context for specific validation
1742 * @return array Validation results
1743 */
1744 public function validate_settings(array $settings, string $tab_context = ''): array {
1745 // If tab context is provided, use tab-specific validation
1746 if (!empty($tab_context)) {
1747 return $this->get_tab_specific_validation($settings, $tab_context);
1748 }
1749
1750 // Default comprehensive validation for backward compatibility
1751 $validation = [
1752 'valid' => true,
1753 'errors' => [],
1754 'warnings' => [],
1755 'suggestions' => [],
1756 'score' => 100
1757 ];
1758
1759 // Validate title template
1760 if (isset($settings['title_template'])) {
1761 if (!isset($this->title_templates[$settings['title_template']])) {
1762 $validation['errors'][] = 'Invalid title template specified';
1763 $validation['valid'] = false;
1764 }
1765 }
1766
1767 // Validate title separator
1768 if (isset($settings['title_separator'])) {
1769 if (!isset(self::$title_separators[$settings['title_separator']])) {
1770 $validation['errors'][] = __('Invalid title separator specified.', 'thinkrank');
1771 $validation['valid'] = false;
1772 }
1773 }
1774
1775 // Validate site name
1776 if (isset($settings['site_name'])) {
1777 if (empty($settings['site_name'])) {
1778 $validation['errors'][] = 'Site name is required';
1779 $validation['valid'] = false;
1780 } elseif (strlen($settings['site_name']) > 60) {
1781 $validation['warnings'][] = 'Site name is longer than 60 characters, may be truncated';
1782 }
1783 }
1784
1785 // Validate site description
1786 if (isset($settings['site_description']) && !empty($settings['site_description'])) {
1787 if (strlen($settings['site_description']) > 160) {
1788 $validation['warnings'][] = 'Site description is longer than 160 characters, may be truncated';
1789 } elseif (strlen($settings['site_description']) < 120) {
1790 $validation['suggestions'][] = 'Consider making site description longer (120-160 characters)';
1791 }
1792 }
1793
1794 // Validate logo URL
1795 if (isset($settings['logo_url']) && !empty($settings['logo_url'])) {
1796 if (!filter_var($settings['logo_url'], FILTER_VALIDATE_URL)) {
1797 $validation['errors'][] = 'Logo URL must be a valid URL';
1798 $validation['valid'] = false;
1799 }
1800 }
1801
1802 // Validate breadcrumb settings
1803 if (isset($settings['breadcrumb_type'])) {
1804 if (!isset($this->breadcrumb_types[$settings['breadcrumb_type']])) {
1805 $validation['errors'][] = 'Invalid breadcrumb type specified';
1806 $validation['valid'] = false;
1807 }
1808 }
1809
1810 // Validate robots.txt settings
1811 if (isset($settings['robots_txt_enabled']) && $settings['robots_txt_enabled']) {
1812 if (!$this->is_directory_writable(ABSPATH)) {
1813 $validation['warnings'][] = 'WordPress root directory is not writable, robots.txt cannot be automatically managed';
1814 }
1815 }
1816
1817 // Validate local SEO settings if enabled
1818 if (isset($settings['local_seo_enabled']) && $settings['local_seo_enabled']) {
1819 $local_seo_validation = $this->validate_local_seo_settings($settings);
1820 $validation['errors'] = array_merge($validation['errors'], $local_seo_validation['errors']);
1821 $validation['warnings'] = array_merge($validation['warnings'], $local_seo_validation['warnings']);
1822 $validation['suggestions'] = array_merge($validation['suggestions'], $local_seo_validation['suggestions']);
1823
1824 if (!$local_seo_validation['valid']) {
1825 $validation['valid'] = false;
1826 }
1827 }
1828
1829 // Calculate validation score
1830 $validation['score'] = $this->calculate_validation_score($validation);
1831
1832 // Add detailed field validation breakdown for generic validation
1833 $validation['field_details'] = $this->get_detailed_field_validation($settings, '');
1834
1835 return $validation;
1836 }
1837
1838 /**
1839 * Get tab-specific validation
1840 *
1841 * @since 1.0.0
1842 *
1843 * @param array $settings Settings array to validate
1844 * @param string $tab_context Tab context for specific validation
1845 * @return array Tab-specific validation results
1846 */
1847 private function get_tab_specific_validation(array $settings, string $tab_context): array {
1848 $validation = [
1849 'valid' => true,
1850 'errors' => [],
1851 'warnings' => [],
1852 'suggestions' => [],
1853 'score' => 100
1854 ];
1855
1856 // Get tab-specific field details
1857 $field_details = $this->get_detailed_field_validation($settings, $tab_context);
1858
1859 // Convert field details to validation format
1860 foreach ($field_details as $field) {
1861 switch ($field['status']) {
1862 case 'error':
1863 $validation['errors'][] = $field['label'];
1864 $validation['valid'] = false;
1865 $validation['score'] -= 20;
1866 break;
1867 case 'warning':
1868 $validation['warnings'][] = $field['label'];
1869 $validation['score'] -= 10;
1870 break;
1871 case 'suggestion':
1872 $validation['suggestions'][] = $field['label'];
1873 $validation['score'] -= 5;
1874 break;
1875 }
1876 }
1877
1878 // Ensure score doesn't go below 0
1879 $validation['score'] = max(0, $validation['score']);
1880
1881 // Add field details for frontend display
1882 $validation['field_details'] = $field_details;
1883
1884 return $validation;
1885 }
1886
1887 /**
1888 * Get detailed field validation breakdown
1889 *
1890 * @since 1.0.0
1891 *
1892 * @param array $settings Settings array to validate
1893 * @param string $tab_context Tab context for specific validation
1894 * @return array Detailed field validation results
1895 */
1896 private function get_detailed_field_validation(array $settings, string $tab_context = ''): array {
1897 $field_details = [];
1898
1899 // Return tab-specific validation based on context
1900 switch ($tab_context) {
1901 case 'local-seo':
1902 return $this->get_business_info_validation($settings);
1903 case 'hero-section':
1904 return $this->get_hero_section_validation($settings);
1905 case 'title-formats':
1906 return $this->get_title_formats_validation($settings);
1907 case 'breadcrumbs':
1908 return $this->get_breadcrumbs_validation($settings);
1909 default:
1910 // Default basic info validation
1911 return $this->get_basic_info_validation($settings);
1912 }
1913 }
1914
1915 /**
1916 * Get Business Info specific validation
1917 *
1918 * @since 1.0.0
1919 *
1920 * @param array $settings Settings array to validate
1921 * @return array Business Info validation results
1922 */
1923 private function get_business_info_validation(array $settings): array {
1924 $field_details = [];
1925
1926 // Check if Local SEO is enabled
1927 if (empty($settings['local_seo_enabled'])) {
1928 $field_details[] = [
1929 'field' => 'local_seo_enabled',
1930 'label' => 'Local SEO is disabled. Enable to configure business information.',
1931 'status' => 'warning',
1932 'icon' => ''
1933 ];
1934 return $field_details;
1935 }
1936
1937 // Business Name validation
1938 if (!empty($settings['business_name'])) {
1939 $field_details[] = [
1940 'field' => 'business_name',
1941 'label' => 'Business name is properly configured.',
1942 'status' => 'valid',
1943 'icon' => ''
1944 ];
1945 } else {
1946 $field_details[] = [
1947 'field' => 'business_name',
1948 'label' => 'Business name is required for local SEO.',
1949 'status' => 'error',
1950 'icon' => ''
1951 ];
1952 }
1953
1954 // Business Type validation
1955 if (!empty($settings['business_type']) && $settings['business_type'] !== 'LocalBusiness') {
1956 $field_details[] = [
1957 'field' => 'business_type',
1958 'label' => 'Business type is selected for proper schema markup.',
1959 'status' => 'valid',
1960 'icon' => ''
1961 ];
1962 } else {
1963 $field_details[] = [
1964 'field' => 'business_type',
1965 'label' => 'Specific business type selection recommended for better schema markup.',
1966 'status' => 'suggestion',
1967 'icon' => ''
1968 ];
1969 }
1970
1971 // Address validation (NAP consistency)
1972 $address_fields = ['business_address', 'business_city', 'business_state', 'business_country'];
1973 $address_complete = true;
1974 foreach ($address_fields as $field) {
1975 if (empty($settings[$field])) {
1976 $address_complete = false;
1977 break;
1978 }
1979 }
1980
1981 if ($address_complete) {
1982 $field_details[] = [
1983 'field' => 'business_address',
1984 'label' => 'Complete business address is configured for NAP consistency.',
1985 'status' => 'valid',
1986 'icon' => ''
1987 ];
1988 } else {
1989 $field_details[] = [
1990 'field' => 'business_address',
1991 'label' => 'Complete address (street, city, state, country) required for local SEO.',
1992 'status' => 'error',
1993 'icon' => ''
1994 ];
1995 }
1996
1997 // Phone validation
1998 if (!empty($settings['business_phone'])) {
1999 if ($this->validate_phone_format($settings['business_phone'])) {
2000 $field_details[] = [
2001 'field' => 'business_phone',
2002 'label' => 'Business phone number is properly formatted.',
2003 'status' => 'valid',
2004 'icon' => ''
2005 ];
2006 } else {
2007 $field_details[] = [
2008 'field' => 'business_phone',
2009 'label' => 'Business phone number format could be improved.',
2010 'status' => 'warning',
2011 'icon' => ''
2012 ];
2013 }
2014 } else {
2015 $field_details[] = [
2016 'field' => 'business_phone',
2017 'label' => 'Business phone number is important for local SEO and customer contact.',
2018 'status' => 'warning',
2019 'icon' => ''
2020 ];
2021 }
2022
2023 // Email validation
2024 if (!empty($settings['business_email'])) {
2025 if (is_email($settings['business_email'])) {
2026 $field_details[] = [
2027 'field' => 'business_email',
2028 'label' => 'Business email address is valid.',
2029 'status' => 'valid',
2030 'icon' => ''
2031 ];
2032 } else {
2033 $field_details[] = [
2034 'field' => 'business_email',
2035 'label' => 'Business email address format is invalid.',
2036 'status' => 'error',
2037 'icon' => ''
2038 ];
2039 }
2040 } else {
2041 $field_details[] = [
2042 'field' => 'business_email',
2043 'label' => 'Business email address recommended for contact information.',
2044 'status' => 'suggestion',
2045 'icon' => ''
2046 ];
2047 }
2048
2049 // Coordinates validation
2050 if (!empty($settings['business_latitude']) && !empty($settings['business_longitude'])) {
2051 if ($this->validate_coordinates($settings['business_latitude'], $settings['business_longitude'])) {
2052 $field_details[] = [
2053 'field' => 'business_coordinates',
2054 'label' => 'Business coordinates are properly configured for precise location.',
2055 'status' => 'valid',
2056 'icon' => ''
2057 ];
2058 } else {
2059 $field_details[] = [
2060 'field' => 'business_coordinates',
2061 'label' => 'Business coordinates appear to be invalid.',
2062 'status' => 'error',
2063 'icon' => ''
2064 ];
2065 }
2066 } else {
2067 $field_details[] = [
2068 'field' => 'business_coordinates',
2069 'label' => 'Business coordinates recommended for precise location targeting.',
2070 'status' => 'suggestion',
2071 'icon' => ''
2072 ];
2073 }
2074
2075 return $field_details;
2076 }
2077
2078 /**
2079 * Get Basic Info validation (default)
2080 *
2081 * @since 1.0.0
2082 *
2083 * @param array $settings Settings array to validate
2084 * @return array Basic Info validation results
2085 */
2086 private function get_basic_info_validation(array $settings): array {
2087 $field_details = [];
2088
2089 // Site Name validation
2090 if (!empty($settings['site_name'])) {
2091 $field_details[] = [
2092 'field' => 'site_name',
2093 'label' => 'Site name is properly configured.',
2094 'status' => 'valid',
2095 'icon' => ''
2096 ];
2097 } else {
2098 $field_details[] = [
2099 'field' => 'site_name',
2100 'label' => 'Site name is required.',
2101 'status' => 'error',
2102 'icon' => ''
2103 ];
2104 }
2105
2106 // Site Description validation
2107 if (!empty($settings['site_description'])) {
2108 $length = strlen($settings['site_description']);
2109 if ($length >= 120 && $length <= 160) {
2110 $field_details[] = [
2111 'field' => 'site_description',
2112 'label' => 'Site description is properly configured.',
2113 'status' => 'valid',
2114 'icon' => ''
2115 ];
2116 } else {
2117 $field_details[] = [
2118 'field' => 'site_description',
2119 'label' => 'Site description length could be optimized (120-160 characters recommended).',
2120 'status' => 'warning',
2121 'icon' => ''
2122 ];
2123 }
2124 } else {
2125 $field_details[] = [
2126 'field' => 'site_description',
2127 'label' => 'Site description is recommended for better SEO.',
2128 'status' => 'warning',
2129 'icon' => ''
2130 ];
2131 }
2132
2133 // Tagline validation
2134 if (!empty($settings['tagline'])) {
2135 $field_details[] = [
2136 'field' => 'tagline',
2137 'label' => 'Site tagline is configured.',
2138 'status' => 'valid',
2139 'icon' => ''
2140 ];
2141 } else {
2142 $field_details[] = [
2143 'field' => 'tagline',
2144 'label' => 'Site tagline recommended for better branding.',
2145 'status' => 'suggestion',
2146 'icon' => ''
2147 ];
2148 }
2149
2150 // Default Meta Description validation
2151 if (!empty($settings['default_meta_description'])) {
2152 $length = strlen($settings['default_meta_description']);
2153 if ($length >= 120 && $length <= 160) {
2154 $field_details[] = [
2155 'field' => 'default_meta_description',
2156 'label' => 'Default meta description is properly configured.',
2157 'status' => 'valid',
2158 'icon' => ''
2159 ];
2160 } else {
2161 $field_details[] = [
2162 'field' => 'default_meta_description',
2163 'label' => 'Default meta description length could be optimized (120-160 characters recommended).',
2164 'status' => 'warning',
2165 'icon' => ''
2166 ];
2167 }
2168 } else {
2169 $field_details[] = [
2170 'field' => 'default_meta_description',
2171 'label' => 'Default meta description recommended for pages without specific descriptions.',
2172 'status' => 'suggestion',
2173 'icon' => ''
2174 ];
2175 }
2176
2177 return $field_details;
2178 }
2179
2180 /**
2181 * Get Hero Section validation
2182 *
2183 * @since 1.0.0
2184 *
2185 * @param array $settings Settings array to validate
2186 * @return array Hero Section validation results
2187 */
2188 private function get_hero_section_validation(array $settings): array {
2189 $field_details = [];
2190
2191 // Hero Title validation
2192 if (!empty($settings['hero_title'])) {
2193 $field_details[] = [
2194 'field' => 'hero_title',
2195 'label' => 'Hero title is configured.',
2196 'status' => 'valid',
2197 'icon' => ''
2198 ];
2199 } else {
2200 $field_details[] = [
2201 'field' => 'hero_title',
2202 'label' => 'Hero title recommended for better homepage presentation.',
2203 'status' => 'suggestion',
2204 'icon' => ''
2205 ];
2206 }
2207
2208 // Hero Subtitle validation (correct field name)
2209 if (!empty($settings['hero_subtitle'])) {
2210 $field_details[] = [
2211 'field' => 'hero_subtitle',
2212 'label' => 'Hero subtitle is configured.',
2213 'status' => 'valid',
2214 'icon' => ''
2215 ];
2216 } else {
2217 $field_details[] = [
2218 'field' => 'hero_subtitle',
2219 'label' => 'Hero subtitle recommended for better user engagement.',
2220 'status' => 'suggestion',
2221 'icon' => ''
2222 ];
2223 }
2224
2225 // CTA Text validation
2226 if (!empty($settings['hero_cta_text'])) {
2227 $field_details[] = [
2228 'field' => 'hero_cta_text',
2229 'label' => 'Call-to-action text is configured.',
2230 'status' => 'valid',
2231 'icon' => ''
2232 ];
2233 } else {
2234 $field_details[] = [
2235 'field' => 'hero_cta_text',
2236 'label' => 'Call-to-action text recommended for better conversion.',
2237 'status' => 'suggestion',
2238 'icon' => ''
2239 ];
2240 }
2241
2242 // CTA URL validation
2243 if (!empty($settings['hero_cta_url'])) {
2244 if (filter_var($settings['hero_cta_url'], FILTER_VALIDATE_URL) || strpos($settings['hero_cta_url'], '/') === 0) {
2245 $field_details[] = [
2246 'field' => 'hero_cta_url',
2247 'label' => 'Call-to-action URL is properly configured.',
2248 'status' => 'valid',
2249 'icon' => ''
2250 ];
2251 } else {
2252 $field_details[] = [
2253 'field' => 'hero_cta_url',
2254 'label' => 'Call-to-action URL format appears invalid.',
2255 'status' => 'warning',
2256 'icon' => ''
2257 ];
2258 }
2259 } else {
2260 $field_details[] = [
2261 'field' => 'hero_cta_url',
2262 'label' => 'Call-to-action URL recommended for better conversion.',
2263 'status' => 'suggestion',
2264 'icon' => ''
2265 ];
2266 }
2267
2268 // Hero Background Image validation
2269 if (!empty($settings['hero_background_image'])) {
2270 $field_details[] = [
2271 'field' => 'hero_background_image',
2272 'label' => 'Hero background image is configured.',
2273 'status' => 'valid',
2274 'icon' => ''
2275 ];
2276 } else {
2277 $field_details[] = [
2278 'field' => 'hero_background_image',
2279 'label' => 'Hero background image recommended for visual appeal.',
2280 'status' => 'suggestion',
2281 'icon' => ''
2282 ];
2283 }
2284
2285 // Site Logo validation (from Site Assets section)
2286 if (!empty($settings['logo_url'])) {
2287 if (filter_var($settings['logo_url'], FILTER_VALIDATE_URL)) {
2288 $field_details[] = [
2289 'field' => 'logo_url',
2290 'label' => 'Site logo is properly configured.',
2291 'status' => 'valid',
2292 'icon' => ''
2293 ];
2294 } else {
2295 $field_details[] = [
2296 'field' => 'logo_url',
2297 'label' => 'Site logo URL format appears invalid.',
2298 'status' => 'warning',
2299 'icon' => ''
2300 ];
2301 }
2302 } else {
2303 $field_details[] = [
2304 'field' => 'logo_url',
2305 'label' => 'Site logo recommended for branding and schema markup.',
2306 'status' => 'suggestion',
2307 'icon' => ''
2308 ];
2309 }
2310
2311 // Favicon validation
2312 if (!empty($settings['favicon_url'])) {
2313 $field_details[] = [
2314 'field' => 'favicon_url',
2315 'label' => 'Favicon is configured.',
2316 'status' => 'valid',
2317 'icon' => ''
2318 ];
2319 } else {
2320 $field_details[] = [
2321 'field' => 'favicon_url',
2322 'label' => 'Favicon recommended for browser tab identification.',
2323 'status' => 'suggestion',
2324 'icon' => ''
2325 ];
2326 }
2327
2328 // Apple Touch Icon validation
2329 if (!empty($settings['apple_touch_icon_url'])) {
2330 $field_details[] = [
2331 'field' => 'apple_touch_icon_url',
2332 'label' => 'Apple touch icon is configured.',
2333 'status' => 'valid',
2334 'icon' => ''
2335 ];
2336 } else {
2337 $field_details[] = [
2338 'field' => 'apple_touch_icon_url',
2339 'label' => 'Apple touch icon recommended for iOS devices.',
2340 'status' => 'suggestion',
2341 'icon' => ''
2342 ];
2343 }
2344
2345 return $field_details;
2346 }
2347
2348 /**
2349 * Get Title Formats validation
2350 *
2351 * @since 1.0.0
2352 *
2353 * @param array $settings Settings array to validate
2354 * @return array Title Formats validation results
2355 */
2356 private function get_title_formats_validation(array $settings): array {
2357 $field_details = [];
2358
2359 // Title Separator validation
2360 if (!empty($settings['title_separator'])) {
2361 $field_details[] = [
2362 'field' => 'title_separator',
2363 'label' => 'Title separator is properly configured.',
2364 'status' => 'valid',
2365 'icon' => ''
2366 ];
2367 } else {
2368 $field_details[] = [
2369 'field' => 'title_separator',
2370 'label' => 'Title separator is required.',
2371 'status' => 'error',
2372 'icon' => ''
2373 ];
2374 }
2375
2376 // Homepage Title validation
2377 if (!empty($settings['homepage_title'])) {
2378 $field_details[] = [
2379 'field' => 'homepage_title',
2380 'label' => 'Homepage title format is configured.',
2381 'status' => 'valid',
2382 'icon' => ''
2383 ];
2384 } else {
2385 $field_details[] = [
2386 'field' => 'homepage_title',
2387 'label' => 'Homepage title format recommended.',
2388 'status' => 'suggestion',
2389 'icon' => ''
2390 ];
2391 }
2392
2393 // Post Title validation
2394 if (!empty($settings['post_title'])) {
2395 $field_details[] = [
2396 'field' => 'post_title',
2397 'label' => 'Post title format is configured.',
2398 'status' => 'valid',
2399 'icon' => ''
2400 ];
2401 } else {
2402 $field_details[] = [
2403 'field' => 'post_title',
2404 'label' => 'Post title format recommended.',
2405 'status' => 'suggestion',
2406 'icon' => ''
2407 ];
2408 }
2409
2410 // Page Title validation
2411 if (!empty($settings['page_title'])) {
2412 $field_details[] = [
2413 'field' => 'page_title',
2414 'label' => 'Page title format is configured.',
2415 'status' => 'valid',
2416 'icon' => ''
2417 ];
2418 } else {
2419 $field_details[] = [
2420 'field' => 'page_title',
2421 'label' => 'Page title format recommended.',
2422 'status' => 'suggestion',
2423 'icon' => ''
2424 ];
2425 }
2426
2427 // Category Title validation
2428 if (!empty($settings['category_title'])) {
2429 $field_details[] = [
2430 'field' => 'category_title',
2431 'label' => 'Category title format is configured.',
2432 'status' => 'valid',
2433 'icon' => ''
2434 ];
2435 } else {
2436 $field_details[] = [
2437 'field' => 'category_title',
2438 'label' => 'Category title format recommended.',
2439 'status' => 'suggestion',
2440 'icon' => ''
2441 ];
2442 }
2443
2444 // Search Title validation
2445 if (!empty($settings['search_title'])) {
2446 $field_details[] = [
2447 'field' => 'search_title',
2448 'label' => 'Search title format is configured.',
2449 'status' => 'valid',
2450 'icon' => ''
2451 ];
2452 } else {
2453 $field_details[] = [
2454 'field' => 'search_title',
2455 'label' => 'Search title format recommended.',
2456 'status' => 'suggestion',
2457 'icon' => ''
2458 ];
2459 }
2460
2461 return $field_details;
2462 }
2463
2464 /**
2465 * Get Breadcrumbs validation
2466 *
2467 * @since 1.0.0
2468 *
2469 * @param array $settings Settings array to validate
2470 * @return array Breadcrumbs validation results
2471 */
2472 private function get_breadcrumbs_validation(array $settings): array {
2473 $field_details = [];
2474
2475 // Breadcrumbs enabled validation
2476 if (!empty($settings['breadcrumbs_enabled'])) {
2477 $field_details[] = [
2478 'field' => 'breadcrumbs_enabled',
2479 'label' => 'Breadcrumbs are enabled for better navigation.',
2480 'status' => 'valid',
2481 'icon' => ''
2482 ];
2483
2484 // Only validate other fields if breadcrumbs are enabled
2485 // Breadcrumb Type validation
2486 if (!empty($settings['breadcrumb_type'])) {
2487 $field_details[] = [
2488 'field' => 'breadcrumb_type',
2489 'label' => 'Breadcrumb type is properly configured.',
2490 'status' => 'valid',
2491 'icon' => ''
2492 ];
2493 } else {
2494 $field_details[] = [
2495 'field' => 'breadcrumb_type',
2496 'label' => 'Breadcrumb type selection is required.',
2497 'status' => 'error',
2498 'icon' => ''
2499 ];
2500 }
2501
2502 // Home Text validation
2503 if (!empty($settings['breadcrumb_home_text'])) {
2504 $field_details[] = [
2505 'field' => 'breadcrumb_home_text',
2506 'label' => 'Home breadcrumb text is configured.',
2507 'status' => 'valid',
2508 'icon' => ''
2509 ];
2510 } else {
2511 $field_details[] = [
2512 'field' => 'breadcrumb_home_text',
2513 'label' => 'Home breadcrumb text recommended for clarity.',
2514 'status' => 'suggestion',
2515 'icon' => ''
2516 ];
2517 }
2518
2519 // Breadcrumb Separator validation
2520 if (!empty($settings['breadcrumb_separator'])) {
2521 $field_details[] = [
2522 'field' => 'breadcrumb_separator',
2523 'label' => 'Breadcrumb separator is configured.',
2524 'status' => 'valid',
2525 'icon' => ''
2526 ];
2527 } else {
2528 $field_details[] = [
2529 'field' => 'breadcrumb_separator',
2530 'label' => 'Breadcrumb separator recommended for better formatting.',
2531 'status' => 'suggestion',
2532 'icon' => ''
2533 ];
2534 }
2535
2536 // Breadcrumb Prefix validation (optional)
2537 if (!empty($settings['breadcrumb_prefix'])) {
2538 $field_details[] = [
2539 'field' => 'breadcrumb_prefix',
2540 'label' => 'Breadcrumb prefix is configured.',
2541 'status' => 'valid',
2542 'icon' => ''
2543 ];
2544 } else {
2545 $field_details[] = [
2546 'field' => 'breadcrumb_prefix',
2547 'label' => 'Breadcrumb prefix is optional but can improve user guidance.',
2548 'status' => 'suggestion',
2549 'icon' => ''
2550 ];
2551 }
2552
2553 // Show Current Page validation
2554 $field_details[] = [
2555 'field' => 'show_current_page',
2556 'label' => isset($settings['show_current_page']) ?
2557 'Current page display preference is configured.' :
2558 'Current page display preference is set to default.',
2559 'status' => 'valid',
2560 'icon' => ''
2561 ];
2562 } else {
2563 $field_details[] = [
2564 'field' => 'breadcrumbs_enabled',
2565 'label' => 'Breadcrumbs recommended for better user experience and SEO.',
2566 'status' => 'suggestion',
2567 'icon' => ''
2568 ];
2569 }
2570
2571 return $field_details;
2572 }
2573
2574 /**
2575 * Validate local SEO settings
2576 *
2577 * @since 1.0.0
2578 *
2579 * @param array $settings Settings array to validate
2580 * @return array Local SEO validation results
2581 */
2582 private function validate_local_seo_settings(array $settings): array {
2583 $validation = [
2584 'valid' => true,
2585 'errors' => [],
2586 'warnings' => [],
2587 'suggestions' => []
2588 ];
2589
2590 // Business name is what makes the LocalBusiness schema useful, but it
2591 // cannot be a blocking error: the toggle is what reveals the business
2592 // fields, so requiring the name up front makes enabling Local SEO
2593 // impossible. The frontend already skips the output while the name is
2594 // empty (see Seo_Manager::output_local_seo_meta_tags()).
2595 if (empty($settings['business_name'])) {
2596 $validation['warnings'][] = 'Business name is missing - required before local business schema is output';
2597 } elseif (strlen($settings['business_name']) > 100) {
2598 $validation['warnings'][] = 'Business name is very long, consider shortening for better display';
2599 }
2600
2601 // Validate business address components (NAP consistency)
2602 $required_address_fields = [
2603 'business_address' => 'Business address',
2604 'business_city' => 'Business city',
2605 'business_state' => 'Business state/province',
2606 'business_country' => 'Business country'
2607 ];
2608
2609 foreach ($required_address_fields as $field => $label) {
2610 if (empty($settings[$field])) {
2611 $validation['warnings'][] = "{$label} is missing - important for NAP consistency and local search";
2612 }
2613 }
2614
2615 // Validate postal code (recommended)
2616 if (empty($settings['business_postal_code'])) {
2617 $validation['suggestions'][] = 'Add postal code for more precise location targeting';
2618 }
2619
2620 // Validate phone number
2621 if (empty($settings['business_phone'])) {
2622 $validation['warnings'][] = 'Business phone number is missing - important for local SEO and customer contact';
2623 } elseif (!$this->validate_phone_format($settings['business_phone'])) {
2624 $validation['suggestions'][] = 'Phone number format could be improved for consistency';
2625 }
2626
2627 // Validate email address
2628 if (!empty($settings['business_email']) && !is_email($settings['business_email'])) {
2629 $validation['errors'][] = 'Business email address format is invalid';
2630 $validation['valid'] = false;
2631 }
2632
2633 // Validate coordinates if provided
2634 if (!empty($settings['business_latitude']) || !empty($settings['business_longitude'])) {
2635 if (empty($settings['business_latitude']) || empty($settings['business_longitude'])) {
2636 $validation['warnings'][] = 'Both latitude and longitude are required for geo-location';
2637 } elseif (!$this->validate_coordinates($settings['business_latitude'], $settings['business_longitude'])) {
2638 $validation['errors'][] = 'Invalid latitude or longitude coordinates';
2639 $validation['valid'] = false;
2640 }
2641 } else {
2642 $validation['suggestions'][] = 'Add latitude and longitude coordinates for precise location targeting';
2643 }
2644
2645 // Validate business hours
2646 if (!empty($settings['business_hours']) && is_array($settings['business_hours'])) {
2647 $hours_validation = $this->validate_business_hours($settings['business_hours']);
2648 if (!$hours_validation['valid']) {
2649 $validation['warnings'] = array_merge($validation['warnings'], $hours_validation['warnings']);
2650 }
2651 } else {
2652 $validation['suggestions'][] = 'Add business hours to improve local search visibility';
2653 }
2654
2655 // Validate business type
2656 if (empty($settings['business_type'])) {
2657 $validation['suggestions'][] = 'Select a specific business type for better schema markup';
2658 }
2659
2660 return $validation;
2661 }
2662
2663 /**
2664 * Validate phone number format
2665 *
2666 * @since 1.0.0
2667 *
2668 * @param string $phone_number Phone number to validate
2669 * @return bool True if format is acceptable
2670 */
2671 private function validate_phone_format(string $phone_number): bool {
2672 // Remove all non-numeric characters except + for international numbers
2673 $cleaned = preg_replace('/[^\d+]/', '', $phone_number);
2674
2675 // Check for common valid formats
2676 return (
2677 preg_match('/^\d{10}$/', $cleaned) || // 10 digits (US)
2678 preg_match('/^1\d{10}$/', $cleaned) || // 1 + 10 digits (US with country code)
2679 preg_match('/^\+\d{7,15}$/', $cleaned) // International format
2680 );
2681 }
2682
2683 /**
2684 * Get output data for frontend rendering (implements interface)
2685 *
2686 * @since 1.0.0
2687 *
2688 * @param string $context_type The context type
2689 * @param int|null $context_id Optional. Context ID
2690 * @return array Output data ready for frontend rendering
2691 */
2692 public function get_output_data(string $context_type, ?int $context_id): array {
2693 $settings = $this->get_settings($context_type, $context_id);
2694
2695 $output = [
2696 'title' => '',
2697 'breadcrumbs' => [],
2698 'identity' => [],
2699 'robots_txt' => [],
2700 'enabled' => $settings['enabled'] ?? true
2701 ];
2702
2703 if (!$output['enabled']) {
2704 return $output;
2705 }
2706
2707 // Generate title for current context
2708 $title_data = $this->extract_title_data($context_type, $context_id);
2709 $output['title'] = $this->generate_title(
2710 $settings['title_template'] ?? 'default',
2711 $title_data,
2712 $context_type
2713 );
2714
2715 // Generate breadcrumbs if enabled
2716 if (!empty($settings['breadcrumbs_enabled'])) {
2717 $breadcrumb_options = [
2718 'context_type' => $context_type,
2719 'context_id' => $context_id
2720 ];
2721 $output['breadcrumbs'] = $this->generate_breadcrumbs(
2722 $settings['breadcrumb_type'] ?? 'hierarchical',
2723 $breadcrumb_options
2724 );
2725 }
2726
2727 // Get site identity data
2728 $output['identity'] = $this->get_site_identity_data($settings);
2729
2730 // Get robots.txt data if enabled
2731 if (!empty($settings['robots_txt_enabled'])) {
2732 $output['robots_txt'] = $this->generate_robots_txt($settings['custom_robots_rules'] ?? []);
2733 }
2734
2735 return $output;
2736 }
2737
2738 /**
2739 * Keys the Site Identity screens store beyond the 16 defaults.
2740 *
2741 * Title formats, breadcrumb configuration, the hero fields, the business
2742 * block and the wizard's identity fields are all real settings written by
2743 * this manager, none of which get_default_settings() names — it seeds only
2744 * the values a fresh install needs. Gating on defaults alone would stop
2745 * every one of them saving (#452).
2746 *
2747 * @since 2.0.1
2748 *
2749 * @return string[]
2750 */
2751 protected function additional_setting_keys(): array {
2752 return [
2753 // Title formats, one per context.
2754 'homepage_title', 'post_title', 'page_title', 'category_title',
2755 'tag_title', 'author_title', 'search_title', 'archive_title',
2756 // Breadcrumbs.
2757 'breadcrumb_prefix', 'show_current_page', 'breadcrumb_use_seo_title',
2758 // Identity, as written by the setup wizard and the importers.
2759 'alternate_name', 'identity_type', 'represents',
2760 'default_meta_description', 'default_social_image',
2761 'social_media_accounts',
2762 // Schema toggles that live on this screen.
2763 'organization_schema', 'knowledge_graph',
2764 // Robots rules composed by the Robots.txt panel.
2765 'custom_robots_rules',
2766 // Hero section.
2767 'hero_title', 'hero_subtitle', 'hero_cta_text', 'hero_cta_url',
2768 'hero_background_image',
2769 // Local SEO / business details.
2770 'local_seo_enabled', 'business_type', 'business_name',
2771 'business_address', 'business_city', 'business_state',
2772 'business_postal_code', 'business_country', 'business_phone',
2773 'business_email', 'business_latitude', 'business_longitude',
2774 'business_price_range', 'business_hours',
2775 ];
2776 }
2777
2778 /**
2779 * Get default settings for a context type (implements interface)
2780 *
2781 * @since 1.0.0
2782 *
2783 * @param string $context_type The context type to get defaults for
2784 * @return array Default settings array
2785 */
2786 public function get_default_settings(string $context_type): array {
2787 $defaults = [
2788 'enabled' => true,
2789 'title_template' => 'default',
2790 'title_separator' => 'pipe',
2791 'site_name' => get_bloginfo('name'),
2792 'site_description' => get_bloginfo('description'),
2793 'tagline' => get_bloginfo('description'),
2794 'breadcrumbs_enabled' => true,
2795 'breadcrumb_type' => 'hierarchical',
2796 'breadcrumb_home_text' => 'Home',
2797 'breadcrumb_separator' => '>',
2798 'robots_txt_enabled' => true,
2799 'allow_search_engines' => true,
2800 'robots_txt_content' => '',
2801 'logo_url' => '',
2802 'favicon_url' => '',
2803 'apple_touch_icon_url' => ''
2804 ];
2805
2806 // Context-specific defaults
2807 switch ($context_type) {
2808 case 'site':
2809 // Site-wide defaults are already set above
2810 break;
2811 case 'post':
2812 $defaults['title_template'] = 'default';
2813 $defaults['breadcrumb_type'] = 'taxonomy';
2814 break;
2815 case 'page':
2816 $defaults['title_template'] = 'default';
2817 $defaults['breadcrumb_type'] = 'hierarchical';
2818 break;
2819 case 'product':
2820 $defaults['title_template'] = 'category';
2821 $defaults['breadcrumb_type'] = 'taxonomy';
2822 break;
2823 }
2824
2825 return $defaults;
2826 }
2827
2828 /**
2829 * Get settings schema definition (implements interface)
2830 *
2831 * @since 1.0.0
2832 *
2833 * @param string $context_type The context type to get schema for
2834 * @return array Settings schema definition
2835 */
2836 public function get_settings_schema(string $context_type): array {
2837 return [
2838 'enabled' => [
2839 'type' => 'boolean',
2840 'title' => 'Enable Site Identity',
2841 'description' => 'Enable site identity management features',
2842 'default' => true
2843 ],
2844 'title_template' => [
2845 'type' => 'string',
2846 'title' => 'Title Template',
2847 'description' => 'Template for generating page titles',
2848 'enum' => array_keys($this->title_templates),
2849 'default' => 'default'
2850 ],
2851 'title_separator' => [
2852 'type' => 'string',
2853 'title' => 'Title Separator',
2854 'description' => 'Character used to separate title elements',
2855 'enum' => array_keys(self::$title_separators),
2856 'default' => 'pipe'
2857 ],
2858 'site_name' => [
2859 'type' => 'string',
2860 'title' => 'Site Name',
2861 'description' => 'Official name of the website',
2862 'maxLength' => 60,
2863 'default' => get_bloginfo('name')
2864 ],
2865 'site_description' => [
2866 'type' => 'string',
2867 'title' => 'Site Description',
2868 'description' => 'Brief description of the website',
2869 'maxLength' => 160,
2870 'default' => get_bloginfo('description')
2871 ],
2872 'breadcrumbs_enabled' => [
2873 'type' => 'boolean',
2874 'title' => 'Enable Breadcrumbs',
2875 'description' => 'Enable breadcrumb navigation generation',
2876 'default' => true
2877 ],
2878 'breadcrumb_type' => [
2879 'type' => 'string',
2880 'title' => 'Breadcrumb Type',
2881 'description' => 'Type of breadcrumb navigation to generate',
2882 'enum' => array_keys($this->breadcrumb_types),
2883 'default' => 'hierarchical'
2884 ],
2885 'robots_txt_enabled' => [
2886 'type' => 'boolean',
2887 'title' => 'Enable Robots.txt Management',
2888 'description' => 'Enable automatic robots.txt generation and management',
2889 'default' => true
2890 ],
2891 'logo_url' => [
2892 'type' => 'string',
2893 'title' => 'Logo URL',
2894 'description' => 'URL of the site logo image',
2895 'format' => 'uri',
2896 'default' => ''
2897 ],
2898 'favicon_url' => [
2899 'type' => 'string',
2900 'title' => 'Favicon URL',
2901 'description' => 'URL of the site favicon',
2902 'format' => 'uri',
2903 'default' => ''
2904 ]
2905 ];
2906 }
2907
2908 /**
2909 * Prepare title placeholders for replacement
2910 *
2911 * @since 1.0.0
2912 *
2913 * @param array $data Content data
2914 * @param string $context Context type
2915 * @param array $settings Site settings
2916 * @return array Placeholder values
2917 */
2918 private function prepare_title_placeholders(array $data, string $context, array $settings): array {
2919 $placeholders = [
2920 '%title%' => $data['title'] ?? '',
2921 // `?:` rather than `??`: these are persisted as '' rather than left
2922 // unset, and '' is not null, so the null-coalesce never reached the
2923 // WordPress fallback (#398).
2924 '%sitename%' => ($settings['site_name'] ?? '') ?: get_bloginfo('name'),
2925 '%tagline%' => ($settings['tagline'] ?? '') ?: get_bloginfo('description'),
2926 '%separator%' => '', // Will be replaced with actual separator
2927 '%category%' => '',
2928 '%author%' => '',
2929 '%date%' => '',
2930 '%searchterm%' => ''
2931 ];
2932
2933 // Context-specific placeholders
2934 switch ($context) {
2935 case 'post':
2936 case 'page':
2937 case 'product':
2938 if (!empty($data['context_id'])) {
2939 $post = get_post($data['context_id']);
2940 if ($post) {
2941 $placeholders['%title%'] = get_the_title($post);
2942 $placeholders['%author%'] = get_the_author_meta('display_name', $post->post_author);
2943 $placeholders['%date%'] = get_the_date('F j, Y', $post);
2944
2945 // Get primary category
2946 $categories = get_the_category($post->ID);
2947 if (!empty($categories)) {
2948 $placeholders['%category%'] = $categories[0]->name;
2949 }
2950 }
2951 }
2952 break;
2953 case 'search':
2954 $placeholders['%searchterm%'] = get_search_query();
2955 break;
2956 }
2957
2958 return $placeholders;
2959 }
2960
2961 /**
2962 * Replace title placeholders with actual values
2963 *
2964 * @since 1.0.0
2965 *
2966 * @param string $template Title template
2967 * @param array $placeholders Placeholder values
2968 * @param string $separator Title separator
2969 * @return string Processed title
2970 */
2971 private function replace_title_placeholders(string $template, array $placeholders, string $separator): string {
2972 // Replace separator placeholder
2973 $placeholders['%separator%'] = $separator;
2974
2975 // Replace all placeholders
2976 $title = str_replace(array_keys($placeholders), array_values($placeholders), $template);
2977
2978 // Clean up empty placeholders and extra separators
2979 $title = preg_replace('/\s*' . preg_quote($separator, '/') . '\s*' . preg_quote($separator, '/') . '\s*/', ' ' . $separator . ' ', $title);
2980 $title = preg_replace('/^\s*' . preg_quote($separator, '/') . '\s*|\s*' . preg_quote($separator, '/') . '\s*$/', '', $title);
2981
2982 return trim($title);
2983 }
2984
2985 /**
2986 * Get title separator symbol
2987 *
2988 * @since 1.0.0
2989 *
2990 * @param string $separator_key Separator key
2991 * @return string Separator symbol
2992 */
2993 private function get_title_separator(string $separator_key): string {
2994 return self::$title_separators[$separator_key]['symbol'] ?? self::$title_separators['pipe']['symbol'];
2995 }
2996
2997 /**
2998 * Optimize title for SEO
2999 *
3000 * @since 1.0.0
3001 *
3002 * @param string $title Title to optimize
3003 * @param string $context Context type
3004 * @return string Optimized title
3005 */
3006 private function optimize_title(string $title, string $context): string {
3007 // Remove extra whitespace
3008 $title = preg_replace('/\s+/', ' ', $title);
3009 $title = trim($title);
3010
3011 // Ensure title is not too long (60 characters max for SEO)
3012 if (strlen($title) > 60) {
3013 // Try to truncate at word boundary
3014 $title = wp_trim_words($title, 8, '...');
3015 if (strlen($title) > 60) {
3016 $title = substr($title, 0, 57) . '...';
3017 }
3018 }
3019
3020 // Ensure title is not empty
3021 if (empty($title)) {
3022 $title = get_bloginfo('name');
3023 }
3024
3025 return $title;
3026 }
3027
3028 /**
3029 * Extract title data from context
3030 *
3031 * @since 1.0.0
3032 *
3033 * @param string $context_type Context type
3034 * @param int|null $context_id Context ID
3035 * @return array Title data
3036 */
3037 private function extract_title_data(string $context_type, ?int $context_id): array {
3038 $data = [
3039 'title' => '',
3040 'context_type' => $context_type,
3041 'context_id' => $context_id
3042 ];
3043
3044 switch ($context_type) {
3045 case 'site':
3046 $data['title'] = get_bloginfo('name');
3047 break;
3048 case 'post':
3049 case 'page':
3050 case 'product':
3051 if ($context_id) {
3052 $data['title'] = get_the_title($context_id);
3053 }
3054 break;
3055 case 'search':
3056 $data['title'] = 'Search Results';
3057 break;
3058 case '404':
3059 $data['title'] = 'Page Not Found';
3060 break;
3061 }
3062
3063 return $data;
3064 }
3065
3066 /**
3067 * Generate hierarchical breadcrumbs
3068 *
3069 * @since 1.0.0
3070 *
3071 * @param array $options Breadcrumb options
3072 * @return array Breadcrumb items
3073 */
3074 private function generate_hierarchical_breadcrumbs(array $options): array {
3075 $breadcrumbs = [];
3076
3077 // Add home breadcrumb
3078 $breadcrumbs[] = [
3079 'title' => 'Home',
3080 'url' => home_url(),
3081 'position' => 1
3082 ];
3083
3084 $context_type = $options['context_type'] ?? '';
3085 $context_id = $options['context_id'] ?? null;
3086
3087 if ($context_type === 'post' || $context_type === 'page' || $context_type === 'product') {
3088 if ($context_id) {
3089 $post = get_post($context_id);
3090 if ($post) {
3091 // Add parent pages for hierarchical content
3092 $ancestors = get_post_ancestors($post);
3093 $ancestors = array_reverse($ancestors);
3094
3095 $position = 2;
3096 foreach ($ancestors as $ancestor_id) {
3097 $breadcrumbs[] = [
3098 'title' => get_the_title($ancestor_id),
3099 'url' => get_permalink($ancestor_id),
3100 'position' => $position++
3101 ];
3102 }
3103
3104 // Add current page
3105 $breadcrumbs[] = [
3106 'title' => get_the_title($post),
3107 'url' => get_permalink($post),
3108 'position' => $position,
3109 'current' => true
3110 ];
3111 }
3112 }
3113 }
3114
3115 return $breadcrumbs;
3116 }
3117
3118 /**
3119 * Generate taxonomy-based breadcrumbs
3120 *
3121 * @since 1.0.0
3122 *
3123 * @param array $options Breadcrumb options
3124 * @return array Breadcrumb items
3125 */
3126 private function generate_taxonomy_breadcrumbs(array $options): array {
3127 $breadcrumbs = [];
3128
3129 // Add home breadcrumb
3130 $breadcrumbs[] = [
3131 'title' => 'Home',
3132 'url' => home_url(),
3133 'position' => 1
3134 ];
3135
3136 $context_type = $options['context_type'] ?? '';
3137 $context_id = $options['context_id'] ?? null;
3138
3139 if (($context_type === 'post' || $context_type === 'product') && $context_id) {
3140 $post = get_post($context_id);
3141 if ($post) {
3142 // Get primary category
3143 $categories = get_the_category($post->ID);
3144 if (!empty($categories)) {
3145 $primary_category = $categories[0];
3146
3147 // Add category hierarchy
3148 $category_ancestors = get_ancestors($primary_category->term_id, 'category');
3149 $category_ancestors = array_reverse($category_ancestors);
3150
3151 $position = 2;
3152 foreach ($category_ancestors as $ancestor_id) {
3153 $ancestor = get_category($ancestor_id);
3154 $breadcrumbs[] = [
3155 'title' => $ancestor->name,
3156 'url' => get_category_link($ancestor_id),
3157 'position' => $position++
3158 ];
3159 }
3160
3161 // Add primary category
3162 $breadcrumbs[] = [
3163 'title' => $primary_category->name,
3164 'url' => get_category_link($primary_category->term_id),
3165 'position' => $position++
3166 ];
3167 }
3168
3169 // Add current post
3170 $breadcrumbs[] = [
3171 'title' => get_the_title($post),
3172 'url' => get_permalink($post),
3173 'position' => $position,
3174 'current' => true
3175 ];
3176 }
3177 }
3178
3179 return $breadcrumbs;
3180 }
3181
3182 /**
3183 * Generate path-based breadcrumbs
3184 *
3185 * @since 1.0.0
3186 *
3187 * @param array $options Breadcrumb options
3188 * @return array Breadcrumb items
3189 */
3190 private function generate_path_breadcrumbs(array $options): array {
3191 $breadcrumbs = [];
3192
3193 // Add home breadcrumb
3194 $breadcrumbs[] = [
3195 'title' => 'Home',
3196 'url' => home_url(),
3197 'position' => 1
3198 ];
3199
3200 // Get current URL path
3201 $current_url = home_url(add_query_arg([]));
3202 $path = wp_parse_url($current_url, PHP_URL_PATH);
3203 $path_parts = array_filter(explode('/', trim($path, '/')));
3204
3205 $position = 2;
3206 $cumulative_path = '';
3207
3208 foreach ($path_parts as $part) {
3209 $cumulative_path .= '/' . $part;
3210 $url = home_url($cumulative_path);
3211
3212 // Try to get a meaningful title
3213 $title = ucwords(str_replace(['-', '_'], ' ', $part));
3214
3215 $breadcrumbs[] = [
3216 'title' => $title,
3217 'url' => $url,
3218 'position' => $position++,
3219 'current' => $cumulative_path === $path
3220 ];
3221 }
3222
3223 return $breadcrumbs;
3224 }
3225
3226 /**
3227 * Generate custom breadcrumbs
3228 *
3229 * @since 1.0.0
3230 *
3231 * @param array $options Breadcrumb options
3232 * @return array Breadcrumb items
3233 */
3234 private function generate_custom_breadcrumbs(array $options): array {
3235 // Return custom breadcrumbs if provided in options
3236 return $options['custom_breadcrumbs'] ?? [];
3237 }
3238
3239 /**
3240 * Generate breadcrumb schema markup
3241 *
3242 * @since 1.0.0
3243 *
3244 * @param array $breadcrumb_items Breadcrumb items
3245 * @return array Schema markup
3246 */
3247 private function generate_breadcrumb_schema(array $breadcrumb_items): array {
3248 $schema = [
3249 '@context' => 'https://schema.org',
3250 '@type' => 'BreadcrumbList',
3251 'itemListElement' => []
3252 ];
3253
3254 foreach ($breadcrumb_items as $item) {
3255 $schema['itemListElement'][] = [
3256 '@type' => 'ListItem',
3257 'position' => $item['position'],
3258 'name' => $item['title'],
3259 'item' => $item['url']
3260 ];
3261 }
3262
3263 return $schema;
3264 }
3265
3266 /**
3267 * Generate breadcrumb HTML
3268 *
3269 * @since 1.0.0
3270 *
3271 * @param array $breadcrumb_items Breadcrumb items
3272 * @param array $settings Breadcrumb settings
3273 * @return string HTML output
3274 */
3275 private function generate_breadcrumb_html(array $breadcrumb_items, array $settings): string {
3276 if (empty($breadcrumb_items)) {
3277 return '';
3278 }
3279
3280 $separator = $settings['separator'] ?? '>';
3281 $html = '<nav class="thinkrank-breadcrumbs" aria-label="Breadcrumb">';
3282 $html .= '<ol class="breadcrumb-list">';
3283
3284 foreach ($breadcrumb_items as $item) {
3285 $html .= '<li class="breadcrumb-item">';
3286
3287 if (!empty($item['current'])) {
3288 $html .= '<span class="breadcrumb-current" aria-current="page">' . esc_html($item['title']) . '</span>';
3289 } else {
3290 $html .= '<a href="' . esc_url($item['url']) . '">' . esc_html($item['title']) . '</a>';
3291 }
3292
3293 if ($item['position'] < count($breadcrumb_items)) {
3294 $html .= ' <span class="breadcrumb-separator">' . esc_html($separator) . '</span> ';
3295 }
3296
3297 $html .= '</li>';
3298 }
3299
3300 $html .= '</ol>';
3301 $html .= '</nav>';
3302
3303 return $html;
3304 }
3305
3306 /**
3307 * Generate default robots.txt rules
3308 *
3309 * @since 1.0.0
3310 *
3311 * @param array $settings Robots.txt settings
3312 * @return array Default rules
3313 */
3314 private function generate_default_robots_rules(array $settings): array {
3315 $rules = [];
3316
3317 // Full block: when the admin turns off "Allow Search Engines" or enables
3318 // WordPress's "Discourage search engines" (Settings → Reading, stored as
3319 // blog_public=0), serve a robots.txt that disallows everything rather
3320 // than the default per-path rules — otherwise the toggle has no effect.
3321 $allow_search = $settings['allow_search_engines'] ?? true;
3322 if (empty($allow_search) || !get_option('blog_public')) {
3323 $rules[] = ['directive' => 'user_agent', 'value' => '*'];
3324 $rules[] = ['directive' => 'disallow', 'value' => '/'];
3325 return $rules;
3326 }
3327
3328 // Default user agent rule
3329 $rules[] = [
3330 'directive' => 'user_agent',
3331 'value' => '*'
3332 ];
3333
3334 // WordPress core disallows.
3335 //
3336 // Deliberately minimal, matching Yoast/Rank Math defaults. We do NOT
3337 // block /wp-includes/, /wp-content/plugins/, or /wp-content/themes/:
3338 // those paths serve the CSS and JS Google must fetch to render pages,
3339 // and blocking them causes "blocked resource" warnings and can hurt
3340 // rankings. /wp-json/ is left crawlable for the same reason (embeds,
3341 // oEmbed, structured previews). Only wp-admin (bar admin-ajax) and the
3342 // handful of non-content endpoints below are disallowed.
3343 $default_disallows = [
3344 '/wp-admin/',
3345 '/xmlrpc.php',
3346 '/readme.html',
3347 '/license.txt',
3348 ];
3349
3350 // WooCommerce: keep cart/checkout/account and add-to-cart query URLs out
3351 // of the index to avoid crawl noise and duplicate/session URLs (parity
3352 // with Rank Math's WooCommerce robots defaults).
3353 if (class_exists('WooCommerce')) {
3354 $default_disallows[] = '/cart/';
3355 $default_disallows[] = '/checkout/';
3356 $default_disallows[] = '/my-account/';
3357 $default_disallows[] = '/*add-to-cart=*';
3358 }
3359
3360 foreach ($default_disallows as $disallow) {
3361 $rules[] = [
3362 'directive' => 'disallow',
3363 'value' => $disallow
3364 ];
3365 }
3366
3367 // Allow specific files
3368 $default_allows = [
3369 '/wp-admin/admin-ajax.php',
3370 '/wp-content/uploads/'
3371 ];
3372
3373 foreach ($default_allows as $allow) {
3374 $rules[] = [
3375 'directive' => 'allow',
3376 'value' => $allow
3377 ];
3378 }
3379
3380 // Add sitemap URLs from sitemap settings (auto-sync)
3381 $sitemap_urls = $this->get_sitemap_urls_for_robots();
3382
3383 foreach ($sitemap_urls as $sitemap_url) {
3384 if (!empty($sitemap_url)) {
3385 $rules[] = [
3386 'directive' => 'sitemap',
3387 'value' => $sitemap_url
3388 ];
3389 }
3390 }
3391
3392 // Add crawl delay if specified
3393 if (!empty($settings['crawl_delay'])) {
3394 $rules[] = [
3395 'directive' => 'crawl_delay',
3396 'value' => (int) $settings['crawl_delay']
3397 ];
3398 }
3399
3400 return $rules;
3401 }
3402
3403 /**
3404 * Get sitemap URLs from sitemap settings for robots.txt integration
3405 *
3406 * @since 1.0.0
3407 * @return array Array of sitemap URLs
3408 */
3409 private function get_sitemap_urls_for_robots(): array {
3410 try {
3411 // Get sitemap settings
3412 $sitemap_generator = new \ThinkRank\SEO\Sitemap_Generator();
3413 $sitemap_settings = $sitemap_generator->get_settings('site');
3414
3415 // If sitemap is disabled, return default
3416 if (empty($sitemap_settings['enabled'])) {
3417 return [home_url('/sitemap.xml')];
3418 }
3419
3420 $sitemap_urls = [];
3421 $site_url = home_url();
3422
3423 // Extract enabled sitemap URLs. When the index is enabled it is the
3424 // only entry worth advertising: every child sitemap is already
3425 // listed inside it, so naming them again in robots.txt is pure
3426 // redundancy and drifts out of date as soon as a post type is added.
3427 $index_url = '';
3428 if (!empty($sitemap_settings['sitemap_urls']) && is_array($sitemap_settings['sitemap_urls'])) {
3429 foreach ($sitemap_settings['sitemap_urls'] as $sitemap) {
3430 if (empty($sitemap['enabled']) || empty($sitemap['url'])) {
3431 continue;
3432 }
3433
3434 if (($sitemap['type'] ?? '') === 'index') {
3435 $index_url = $site_url . $sitemap['url'];
3436 continue;
3437 }
3438
3439 $sitemap_urls[] = $site_url . $sitemap['url'];
3440 }
3441 }
3442
3443 if ($index_url !== '') {
3444 // The index alone — it covers the children and, on a segmented
3445 // install, the local business sitemap too.
3446 return [$index_url];
3447 }
3448
3449 // Fallback to default if no URLs found
3450 if (empty($sitemap_urls)) {
3451 $sitemap_urls[] = home_url('/sitemap.xml');
3452 }
3453
3454 // No index on this install, so anything not already listed above has
3455 // no other discovery path — advertise it directly. The local
3456 // business sitemap and the sitemaps other plugins register both land
3457 // here for the same reason, so they go through one list (#104).
3458 $extra = [];
3459
3460 if (file_exists(ABSPATH . 'local-sitemap.xml')) {
3461 $extra[] = '/local-sitemap.xml';
3462 }
3463
3464 foreach (\ThinkRank\SEO\Sitemap_Generator::additional_sitemaps() as $path) {
3465 $extra[] = $path;
3466 }
3467
3468 foreach ($extra as $path) {
3469 $url = home_url($path);
3470 if (!in_array($url, $sitemap_urls, true)) {
3471 $sitemap_urls[] = $url;
3472 }
3473 }
3474
3475 return $sitemap_urls;
3476 } catch (\Exception $e) {
3477 // Fallback to default on error
3478 return [home_url('/sitemap.xml')];
3479 }
3480 }
3481
3482 /**
3483 * Validate robots.txt rules
3484 *
3485 * @since 1.0.0
3486 *
3487 * @param array $rules Rules to validate
3488 * @return array Validation results
3489 */
3490 private function validate_robots_rules(array $rules): array {
3491 $validation = [
3492 'valid' => true,
3493 'errors' => [],
3494 'warnings' => [],
3495 'suggestions' => []
3496 ];
3497
3498 $has_user_agent = false;
3499
3500 foreach ($rules as $rule) {
3501 $directive = $rule['directive'] ?? '';
3502 $value = $rule['value'] ?? '';
3503
3504 // Check if directive is valid
3505 if (!isset($this->robots_directives[$directive])) {
3506 $validation['errors'][] = "Unknown robots.txt directive: {$directive}";
3507 $validation['valid'] = false;
3508 continue;
3509 }
3510
3511 // Check for required user-agent
3512 if ($directive === 'user_agent') {
3513 $has_user_agent = true;
3514 }
3515
3516 // Validate directive-specific rules
3517 switch ($directive) {
3518 case 'disallow':
3519 case 'allow':
3520 if (!str_starts_with($value, '/')) {
3521 $validation['warnings'][] = "Path '{$value}' should start with '/'";
3522 }
3523 break;
3524 case 'sitemap':
3525 if (!filter_var($value, FILTER_VALIDATE_URL)) {
3526 $validation['errors'][] = "Invalid sitemap URL: {$value}";
3527 $validation['valid'] = false;
3528 }
3529 break;
3530 case 'crawl_delay':
3531 if (!is_numeric($value) || $value < 0) {
3532 $validation['errors'][] = "Crawl delay must be a positive number";
3533 $validation['valid'] = false;
3534 }
3535 break;
3536 }
3537 }
3538
3539 if (!$has_user_agent) {
3540 $validation['errors'][] = 'robots.txt must include at least one User-agent directive';
3541 $validation['valid'] = false;
3542 }
3543
3544 return $validation;
3545 }
3546
3547 /**
3548 * Build robots.txt content from rules
3549 *
3550 * @since 1.0.0
3551 *
3552 * @param array $rules Robots.txt rules
3553 * @return string Robots.txt content
3554 */
3555 private function build_robots_txt_content(array $rules): string {
3556 // Body only — no header. The "# generated by ThinkRank SEO" + timestamp
3557 // block is added at render time (see robots_txt_header), so it never
3558 // gets baked into the stored/editable content and can't show a stale
3559 // timestamp on every update.
3560 $content = '';
3561
3562 $current_user_agent = '';
3563 $sitemap_started = false;
3564
3565 foreach ($rules as $rule) {
3566 $directive = $rule['directive'] ?? '';
3567 $value = $rule['value'] ?? '';
3568
3569 switch ($directive) {
3570 case 'user_agent':
3571 if ($current_user_agent !== $value) {
3572 $content .= "\nUser-agent: {$value}\n";
3573 $current_user_agent = $value;
3574 }
3575 break;
3576 case 'disallow':
3577 $content .= "Disallow: {$value}\n";
3578 break;
3579 case 'allow':
3580 $content .= "Allow: {$value}\n";
3581 break;
3582 case 'crawl_delay':
3583 $content .= "Crawl-delay: {$value}\n";
3584 break;
3585 case 'sitemap':
3586 // One blank line separates the Sitemap block from the
3587 // preceding group, and none appear inside it. A blank line
3588 // terminates a record in the robots.txt grammar, so putting
3589 // one between every directive was invalid formatting.
3590 if (!$sitemap_started) {
3591 $content .= "\n";
3592 $sitemap_started = true;
3593 }
3594 $content .= "Sitemap: {$value}\n";
3595 break;
3596 }
3597 }
3598
3599 return ltrim($content, "\n");
3600 }
3601
3602 /**
3603 * Parse a robots.txt body back into the {directive, value} rule shape.
3604 *
3605 * generate_robots_txt() returns `rules` alongside `content`, but callers
3606 * replace `content` with the body actually being served (a stored override
3607 * or a physical file). The generated rules then described something the
3608 * response no longer contained. Re-deriving them from the served body keeps
3609 * the two halves of the payload describing the same document.
3610 *
3611 * @since 2.0.1
3612 *
3613 * @param string $content Robots.txt body (header optional).
3614 * @return array<int, array{directive: string, value: string}> Parsed rules.
3615 */
3616 public function parse_robots_txt_rules(string $content): array {
3617 $map = [
3618 'user-agent' => 'user_agent',
3619 'disallow' => 'disallow',
3620 'allow' => 'allow',
3621 'crawl-delay' => 'crawl_delay',
3622 'sitemap' => 'sitemap',
3623 ];
3624
3625 $rules = [];
3626
3627 foreach (preg_split('/\r\n|\r|\n/', $this->strip_robots_header($content)) as $line) {
3628 $line = trim($line);
3629
3630 // Blank lines separate groups and `#` starts a comment; neither is
3631 // a rule.
3632 if ($line === '' || str_starts_with($line, '#')) {
3633 continue;
3634 }
3635
3636 $parts = explode(':', $line, 2);
3637 if (count($parts) !== 2) {
3638 continue;
3639 }
3640
3641 $field = strtolower(trim($parts[0]));
3642 if (!isset($map[$field])) {
3643 continue;
3644 }
3645
3646 $rules[] = [
3647 'directive' => $map[$field],
3648 // Sitemap values are absolute URLs and contain the `:` the
3649 // limited explode above deliberately preserved.
3650 'value' => trim($parts[1]),
3651 ];
3652 }
3653
3654 return $rules;
3655 }
3656
3657 /**
3658 * The auto-generated header prepended to the served robots.txt.
3659 *
3660 * Kept separate from the body so it is only ever added at render time with
3661 * a fresh timestamp, never stored or shown in the editable textarea.
3662 *
3663 * @return string
3664 */
3665 private function robots_txt_header(): string {
3666 return "# Robots.txt generated by ThinkRank SEO\n"
3667 . "# " . gmdate('Y-m-d H:i:s') . " UTC\n\n";
3668 }
3669
3670 /**
3671 * Strip our auto-generated header from a robots.txt string.
3672 *
3673 * Used when surfacing existing content for editing so the header/timestamp
3674 * doesn't round-trip back into storage.
3675 *
3676 * @param string $content Raw robots.txt content.
3677 * @return string Body without the ThinkRank header.
3678 */
3679 private function strip_robots_header(string $content): string {
3680 $pattern = '/^# Robots\.txt generated by ThinkRank SEO\r?\n# [^\r\n]* UTC\r?\n\r?\n/';
3681 return trim((string) preg_replace($pattern, '', $content, 1));
3682 }
3683
3684 /**
3685 * The body that should populate the editor for the current site.
3686 *
3687 * Prefers what is actually being served: the physical file if one exists
3688 * (header stripped), otherwise the effective body. This is what the admin
3689 * screen shows so the textarea is never blank while /robots.txt has content.
3690 *
3691 * @return string
3692 */
3693 public function get_served_robots_body(): string {
3694 $settings = $this->get_settings('site');
3695
3696 $custom = trim((string) ($settings['robots_txt_content'] ?? ''));
3697 if ($custom !== '') {
3698 return $this->strip_robots_header($custom);
3699 }
3700
3701 $robots_file = ABSPATH . 'robots.txt';
3702 if (file_exists($robots_file)) {
3703 // 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.
3704 $raw = (string) @file_get_contents($robots_file);
3705 if ($raw !== '') {
3706 return $this->strip_robots_header($raw);
3707 }
3708 }
3709
3710 return trim($this->generate_robots_txt()['content']);
3711 }
3712 private function get_site_identity_data(array $settings): array {
3713 return [
3714 'site_name' => $settings['site_name'] ?? get_bloginfo('name'),
3715 'site_description' => $settings['site_description'] ?? get_bloginfo('description'),
3716 'tagline' => $settings['tagline'] ?? get_bloginfo('description'),
3717 'logo_url' => $settings['logo_url'] ?? '',
3718 'favicon_url' => $settings['favicon_url'] ?? '',
3719 'apple_touch_icon_url' => $settings['apple_touch_icon_url'] ?? ''
3720 ];
3721 }
3722
3723 /**
3724 * Optimize individual identity element
3725 *
3726 * @since 1.0.0
3727 *
3728 * @param string $element Element name
3729 * @param mixed $value Element value
3730 * @param array $config Element configuration
3731 * @return array Optimization results
3732 */
3733 private function optimize_identity_element(string $element, $value, array $config): array {
3734 $optimization = [
3735 'optimized_value' => $value,
3736 'validation' => [
3737 'valid' => true,
3738 'errors' => [],
3739 'warnings' => []
3740 ],
3741 'suggestions' => []
3742 ];
3743
3744 switch ($config['type']) {
3745 case 'text':
3746 $optimization = $this->optimize_text_element($element, $value, $config, $optimization);
3747 break;
3748 case 'image':
3749 $optimization = $this->optimize_image_element($element, $value, $config, $optimization);
3750 break;
3751 }
3752
3753 return $optimization;
3754 }
3755
3756 /**
3757 * Optimize text identity element
3758 *
3759 * @since 1.0.0
3760 *
3761 * @param string $element Element name
3762 * @param string $value Element value
3763 * @param array $config Element configuration
3764 * @param array $optimization Current optimization
3765 * @return array Updated optimization
3766 */
3767 private function optimize_text_element(string $element, string $value, array $config, array $optimization): array {
3768 if (empty($value) && !empty($config['required'])) {
3769 $optimization['validation']['errors'][] = "{$element} is required";
3770 $optimization['validation']['valid'] = false;
3771 }
3772
3773 if (!empty($value) && isset($config['max_length'])) {
3774 if (strlen($value) > $config['max_length']) {
3775 $optimization['validation']['warnings'][] = "{$element} exceeds maximum length of {$config['max_length']} characters";
3776 $optimization['optimized_value'] = substr($value, 0, $config['max_length']);
3777 }
3778 }
3779
3780 // SEO-specific optimizations
3781 if ($element === 'site_name' && !empty($value)) {
3782 // Remove excessive punctuation
3783 $optimization['optimized_value'] = preg_replace('/[!@#$%^&*()]+/', '', $value);
3784 }
3785
3786 return $optimization;
3787 }
3788
3789 /**
3790 * Optimize image identity element
3791 *
3792 * @since 1.0.0
3793 *
3794 * @param string $element Element name
3795 * @param string $value Element value
3796 * @param array $config Element configuration
3797 * @param array $optimization Current optimization
3798 * @return array Updated optimization
3799 */
3800 private function optimize_image_element(string $element, string $value, array $config, array $optimization): array {
3801 if (empty($value)) {
3802 if (!empty($config['required'])) {
3803 $optimization['validation']['errors'][] = "{$element} is required";
3804 $optimization['validation']['valid'] = false;
3805 }
3806 return $optimization;
3807 }
3808
3809 // Validate URL
3810 if (!filter_var($value, FILTER_VALIDATE_URL)) {
3811 $optimization['validation']['errors'][] = "{$element} must be a valid URL";
3812 $optimization['validation']['valid'] = false;
3813 return $optimization;
3814 }
3815
3816 // Check if it's a local image
3817 $attachment_id = attachment_url_to_postid($value);
3818 if ($attachment_id) {
3819 $image_meta = wp_get_attachment_metadata($attachment_id);
3820
3821 if ($image_meta && isset($image_meta['width'], $image_meta['height'])) {
3822 // Check recommended size
3823 if (isset($config['recommended_size'])) {
3824 [$rec_width, $rec_height] = explode('x', $config['recommended_size']);
3825
3826 if ((int) $image_meta['width'] !== (int) $rec_width || (int) $image_meta['height'] !== (int) $rec_height) {
3827 $optimization['suggestions'][] = "Consider using {$config['recommended_size']} size for optimal {$element}";
3828 }
3829 }
3830
3831 // Check file size
3832 if (isset($config['max_size'])) {
3833 $file_path = get_attached_file($attachment_id);
3834 if ($file_path && file_exists($file_path)) {
3835 $file_size = filesize($file_path);
3836 $max_size_bytes = $this->parse_size_string($config['max_size']);
3837
3838 if ($file_size > $max_size_bytes) {
3839 $optimization['validation']['warnings'][] = "{$element} file size exceeds {$config['max_size']}";
3840 }
3841 }
3842 }
3843 }
3844 }
3845
3846 return $optimization;
3847 }
3848
3849 /**
3850 * Parse size string to bytes
3851 *
3852 * @since 1.0.0
3853 *
3854 * @param string $size_string Size string (e.g., '2MB', '500KB')
3855 * @return int Size in bytes
3856 */
3857 private function parse_size_string(string $size_string): int {
3858 $size_string = strtoupper(trim($size_string));
3859 $size = (int) $size_string;
3860
3861 if (strpos($size_string, 'KB') !== false) {
3862 return $size * 1024;
3863 } elseif (strpos($size_string, 'MB') !== false) {
3864 return $size * 1024 * 1024;
3865 } elseif (strpos($size_string, 'GB') !== false) {
3866 return $size * 1024 * 1024 * 1024;
3867 }
3868
3869 return $size;
3870 }
3871
3872 /**
3873 * Calculate identity optimization score
3874 *
3875 * @since 1.0.0
3876 *
3877 * @param array $validations Element validations
3878 * @return int Score (0-100)
3879 */
3880 private function calculate_identity_score(array $validations): int {
3881 $total_score = 0;
3882 $element_count = 0;
3883
3884 foreach ($validations as $validation) {
3885 $element_score = 100;
3886 $element_score -= count($validation['errors']) * 30;
3887 $element_score -= count($validation['warnings']) * 15;
3888
3889 $total_score += max(0, $element_score);
3890 $element_count++;
3891 }
3892
3893 return $element_count > 0 ? (int) round($total_score / $element_count) : 0;
3894 }
3895
3896 /**
3897 * Calculate validation score
3898 *
3899 * @since 1.0.0
3900 *
3901 * @param array $validation Validation results
3902 * @return int Score (0-100)
3903 */
3904 private function calculate_validation_score(array $validation): int {
3905 $score = 100;
3906 $score -= count($validation['errors']) * 20;
3907 $score -= count($validation['warnings']) * 10;
3908 $score -= count($validation['suggestions']) * 5;
3909
3910 return max(0, $score);
3911 }
3912 }
3913