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

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