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

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