PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / 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.7.0, at includes/seo/class-site-identity-manager.php

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