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

4,355 lines 161.4 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 // Breadcrumbs.
2911 'breadcrumb_prefix', 'show_current_page', 'breadcrumb_use_seo_title',
2912 // Identity, as written by the setup wizard and the importers.
2913 'alternate_name', 'identity_type', 'represents',
2914 'default_meta_description', 'default_social_image',
2915 'social_media_accounts',
2916 // Schema toggles that live on this screen.
2917 'organization_schema', 'knowledge_graph',
2918 // Robots rules composed by the Robots.txt panel.
2919 'custom_robots_rules',
2920 // Per-agent AI crawler allow/block map (#657).
2921 'ai_crawler_rules',
2922 // Hero section.
2923 'hero_title', 'hero_subtitle', 'hero_cta_text', 'hero_cta_url',
2924 'hero_background_image',
2925 // Local SEO / business details.
2926 'local_seo_enabled', 'business_type', 'business_name',
2927 'business_address', 'business_city', 'business_state',
2928 'business_postal_code', 'business_country', 'business_phone',
2929 'business_email', 'business_latitude', 'business_longitude',
2930 'business_price_range', 'business_hours',
2931 ];
2932 }
2933
2934 /**
2935 * Sanitize settings, normalising the AI crawler rule map.
2936 *
2937 * The generic array sanitizer keeps the shape but says nothing about the
2938 * values: a payload could store `ai_crawler_rules[gptbot] = "maybe"`, or a
2939 * slug no crawler answers to, and both would round-trip through every
2940 * later response. Normalising here rather than in the REST handler puts it
2941 * on the one path every writer shares — the settings route, the robots
2942 * route and the MCP abilities all land in save_settings() (#657).
2943 *
2944 * @since 2.5.0
2945 *
2946 * @param array $settings Settings to sanitize.
2947 * @param string $context_type Context type.
2948 * @return array Sanitized settings.
2949 */
2950 protected function sanitize_settings(array $settings, string $context_type = 'site'): array {
2951 $sanitized = parent::sanitize_settings($settings, $context_type);
2952
2953 if (array_key_exists('ai_crawler_rules', $sanitized)) {
2954 $sanitized['ai_crawler_rules'] = AI_Crawlers::normalize_rules($sanitized['ai_crawler_rules']);
2955 }
2956
2957 // Same reasoning one key up, for the scheme override (#638). Anything
2958 // that is not one of the three modes means "follow WordPress", and is
2959 // stored as that rather than kept verbatim — otherwise get-site-identity
2960 // -settings would report a scheme the site does not actually publish.
2961 if (array_key_exists('canonical_scheme', $sanitized)) {
2962 $sanitized['canonical_scheme'] = in_array($sanitized['canonical_scheme'], Url_Scheme::MODES, true)
2963 ? $sanitized['canonical_scheme']
2964 : Url_Scheme::AUTOMATIC;
2965 }
2966
2967 // Same reasoning again for the business type. It goes straight into
2968 // LocalBusiness schema, so a type that is not in the schema.org
2969 // vocabulary is invalid structured data — and storing it verbatim would
2970 // have get-site-identity-settings report a type the site cannot
2971 // actually publish. An empty value keeps meaning "not set"; anything
2972 // else unrecognised falls back to the general-purpose root (#623).
2973 if (array_key_exists('business_type', $sanitized)) {
2974 $type = (string) $sanitized['business_type'];
2975
2976 if ('' !== $type && !\ThinkRank\Config\Local_Business_Types_Config::is_valid($type)) {
2977 $type = \ThinkRank\Config\Local_Business_Types_Config::ROOT;
2978 }
2979
2980 $sanitized['business_type'] = $type;
2981 }
2982
2983 return $sanitized;
2984 }
2985
2986 /**
2987 * schema.org's general-purpose LocalBusiness type.
2988 *
2989 * The default, the first option in the control, and a valid answer in its
2990 * own right — which is the whole point of #622.
2991 *
2992 * @since 2.10.0
2993 * @var string
2994 */
2995 private const GENERAL_BUSINESS_TYPE = 'LocalBusiness';
2996
2997 /**
2998 * The one rule for whether a business type needs the user's attention.
2999 *
3000 * There were three, with two wordings and two different conditions. Two
3001 * fired when the value was empty; the third fired when it WAS
3002 * `LocalBusiness` — which is the default, the first option in the control
3003 * and a perfectly valid schema.org type. So the warning appeared out of the
3004 * box for every site, could not be cleared without choosing a type that
3005 * might be inaccurate, and on an empty value it appeared three times in two
3006 * different phrasings, which is why it was reported as showing twice (#622).
3007 *
3008 * The rule now: a type is expected, and any type in the vocabulary is a
3009 * correct answer. Only an unset value is worth prompting about.
3010 * `LocalBusiness` is the general-purpose answer and is accepted as one —
3011 * with a note that a more specific type sharpens the schema, phrased as the
3012 * guidance it is rather than as a fault the user has to clear.
3013 *
3014 * @since 2.10.0
3015 *
3016 * @param array $settings Site identity settings.
3017 * @return array{status:string,message:string} `valid` or `suggestion`.
3018 */
3019 private function business_type_status(array $settings): array {
3020 $type = trim((string) ($settings['business_type'] ?? ''));
3021
3022 if ('' === $type) {
3023 return [
3024 'status' => 'suggestion',
3025 'message' => __('Select a business type so your local schema describes the right kind of business.', 'thinkrank'),
3026 ];
3027 }
3028
3029 // The literal rather than a constant from the expanded type list (#623):
3030 // that lands on its own branch, and this fix must not wait on it.
3031 if (self::GENERAL_BUSINESS_TYPE === $type) {
3032 return [
3033 'status' => 'valid',
3034 'message' => __('Business type is set to Local Business. A more specific type sharpens your schema, if one fits.', 'thinkrank'),
3035 ];
3036 }
3037
3038 return [
3039 'status' => 'valid',
3040 'message' => __('Business type is selected for proper schema markup.', 'thinkrank'),
3041 ];
3042 }
3043
3044 /**
3045 * Get default settings for a context type (implements interface)
3046 *
3047 * @since 1.0.0
3048 *
3049 * @param string $context_type The context type to get defaults for
3050 * @return array Default settings array
3051 */
3052 public function get_default_settings(string $context_type): array {
3053 $defaults = [
3054 'enabled' => true,
3055 'title_template' => 'default',
3056 'title_separator' => 'pipe',
3057 'site_name' => get_bloginfo('name'),
3058 'site_description' => get_bloginfo('description'),
3059 'tagline' => get_bloginfo('description'),
3060 'breadcrumbs_enabled' => true,
3061 'breadcrumb_type' => 'hierarchical',
3062 'breadcrumb_home_text' => 'Home',
3063 'breadcrumb_separator' => '>',
3064 'robots_txt_enabled' => true,
3065 'allow_search_engines' => true,
3066 // Answer 404 when a content selector in the URL resolved to
3067 // nothing (#634). On by default, unlike the other new settings
3068 // here: it changes no URL a visitor or a correct crawler uses, only
3069 // ones where WordPress resolved nothing and served the blog listing
3070 // at 200 anyway.
3071 'query_protection' => true,
3072
3073 // Feed controls (#635). All three off, so an upgrade changes
3074 // nothing about what an existing site already sends its
3075 // subscribers; a brand-new install is seeded with the signature and
3076 // the noindex on, in Activator::seed_feed_defaults().
3077 'feed_excerpt_only' => false,
3078 'feed_source_link' => false,
3079 'feed_noindex' => false,
3080
3081 // The scheme self-referential URLs go out with (#638). 'automatic'
3082 // means substitute nothing and follow WordPress, which is what
3083 // every site did before the setting existed.
3084 'canonical_scheme' => Url_Scheme::AUTOMATIC,
3085 'robots_txt_content' => '',
3086 // Empty map = every AI crawler allowed. Defaults must stay
3087 // permissive so an upgrade never starts blocking a crawler a site
3088 // was happily serving (#657).
3089 'ai_crawler_rules' => [],
3090 'logo_url' => '',
3091 'favicon_url' => '',
3092 'apple_touch_icon_url' => ''
3093 ];
3094
3095 // Context-specific defaults
3096 switch ($context_type) {
3097 case 'site':
3098 // Site-wide defaults are already set above
3099 break;
3100 case 'post':
3101 $defaults['title_template'] = 'default';
3102 $defaults['breadcrumb_type'] = 'taxonomy';
3103 break;
3104 case 'page':
3105 $defaults['title_template'] = 'default';
3106 $defaults['breadcrumb_type'] = 'hierarchical';
3107 break;
3108 case 'product':
3109 $defaults['title_template'] = 'category';
3110 $defaults['breadcrumb_type'] = 'taxonomy';
3111 break;
3112 }
3113
3114 return $defaults;
3115 }
3116
3117 /**
3118 * Get settings schema definition (implements interface)
3119 *
3120 * @since 1.0.0
3121 *
3122 * @param string $context_type The context type to get schema for
3123 * @return array Settings schema definition
3124 */
3125 public function get_settings_schema(string $context_type): array {
3126 return [
3127 'enabled' => [
3128 'type' => 'boolean',
3129 'title' => 'Enable Site Identity',
3130 'description' => 'Enable site identity management features',
3131 'default' => true
3132 ],
3133 'title_template' => [
3134 'type' => 'string',
3135 'title' => 'Title Template',
3136 'description' => 'Template for generating page titles',
3137 'enum' => array_keys($this->title_templates),
3138 'default' => 'default'
3139 ],
3140 'title_separator' => [
3141 'type' => 'string',
3142 'title' => 'Title Separator',
3143 'description' => 'Character used to separate title elements',
3144 'enum' => array_keys(self::$title_separators),
3145 'default' => 'pipe'
3146 ],
3147 'site_name' => [
3148 'type' => 'string',
3149 'title' => 'Site Name',
3150 'description' => 'Official name of the website',
3151 'maxLength' => 60,
3152 'default' => get_bloginfo('name')
3153 ],
3154 'site_description' => [
3155 'type' => 'string',
3156 'title' => 'Site Description',
3157 'description' => 'Brief description of the website',
3158 'maxLength' => 160,
3159 'default' => get_bloginfo('description')
3160 ],
3161 'breadcrumbs_enabled' => [
3162 'type' => 'boolean',
3163 'title' => 'Enable Breadcrumbs',
3164 'description' => 'Enable breadcrumb navigation generation',
3165 'default' => true
3166 ],
3167 'breadcrumb_type' => [
3168 'type' => 'string',
3169 'title' => 'Breadcrumb Type',
3170 'description' => 'Type of breadcrumb navigation to generate',
3171 'enum' => array_keys($this->breadcrumb_types),
3172 'default' => 'hierarchical'
3173 ],
3174 'robots_txt_enabled' => [
3175 'type' => 'boolean',
3176 'title' => 'Enable Robots.txt Management',
3177 'description' => 'Enable automatic robots.txt generation and management',
3178 'default' => true
3179 ],
3180 'logo_url' => [
3181 'type' => 'string',
3182 'title' => 'Logo URL',
3183 'description' => 'URL of the site logo image',
3184 'format' => 'uri',
3185 'default' => ''
3186 ],
3187 'favicon_url' => [
3188 'type' => 'string',
3189 'title' => 'Favicon URL',
3190 'description' => 'URL of the site favicon',
3191 'format' => 'uri',
3192 'default' => ''
3193 ]
3194 ];
3195 }
3196
3197 /**
3198 * Prepare title placeholders for replacement
3199 *
3200 * @since 1.0.0
3201 *
3202 * @param array $data Content data
3203 * @param string $context Context type
3204 * @param array $settings Site settings
3205 * @return array Placeholder values
3206 */
3207 private function prepare_title_placeholders(array $data, string $context, array $settings): array {
3208 $placeholders = [
3209 '%title%' => $data['title'] ?? '',
3210 // `?:` rather than `??`: these are persisted as '' rather than left
3211 // unset, and '' is not null, so the null-coalesce never reached the
3212 // WordPress fallback (#398).
3213 '%sitename%' => ($settings['site_name'] ?? '') ?: get_bloginfo('name'),
3214 '%tagline%' => ($settings['tagline'] ?? '') ?: get_bloginfo('description'),
3215 '%separator%' => '', // Will be replaced with actual separator
3216 '%category%' => '',
3217 '%author%' => '',
3218 '%date%' => '',
3219 '%searchterm%' => ''
3220 ];
3221
3222 // Context-specific placeholders
3223 switch ($context) {
3224 case 'post':
3225 case 'page':
3226 case 'product':
3227 if (!empty($data['context_id'])) {
3228 $post = get_post($data['context_id']);
3229 if ($post) {
3230 $placeholders['%title%'] = get_the_title($post);
3231 $placeholders['%author%'] = get_the_author_meta('display_name', $post->post_author);
3232 $placeholders['%date%'] = get_the_date('F j, Y', $post);
3233
3234 // Get primary category
3235 $categories = get_the_category($post->ID);
3236 if (!empty($categories)) {
3237 $placeholders['%category%'] = $categories[0]->name;
3238 }
3239 }
3240 }
3241 break;
3242 case 'search':
3243 $placeholders['%searchterm%'] = get_search_query();
3244 break;
3245 }
3246
3247 return $placeholders;
3248 }
3249
3250 /**
3251 * Replace title placeholders with actual values
3252 *
3253 * @since 1.0.0
3254 *
3255 * @param string $template Title template
3256 * @param array $placeholders Placeholder values
3257 * @param string $separator Title separator
3258 * @return string Processed title
3259 */
3260 private function replace_title_placeholders(string $template, array $placeholders, string $separator): string {
3261 // Replace separator placeholder
3262 $placeholders['%separator%'] = $separator;
3263
3264 // Replace all placeholders
3265 $title = str_replace(array_keys($placeholders), array_values($placeholders), $template);
3266
3267 // Clean up empty placeholders and extra separators
3268 $title = preg_replace('/\s*' . preg_quote($separator, '/') . '\s*' . preg_quote($separator, '/') . '\s*/', ' ' . $separator . ' ', $title);
3269 $title = preg_replace('/^\s*' . preg_quote($separator, '/') . '\s*|\s*' . preg_quote($separator, '/') . '\s*$/', '', $title);
3270
3271 return trim($title);
3272 }
3273
3274 /**
3275 * Get title separator symbol
3276 *
3277 * @since 1.0.0
3278 *
3279 * @param string $separator_key Separator key
3280 * @return string Separator symbol
3281 */
3282 private function get_title_separator(string $separator_key): string {
3283 return self::$title_separators[$separator_key]['symbol'] ?? self::$title_separators['pipe']['symbol'];
3284 }
3285
3286 /**
3287 * Optimize title for SEO
3288 *
3289 * @since 1.0.0
3290 *
3291 * @param string $title Title to optimize
3292 * @param string $context Context type
3293 * @return string Optimized title
3294 */
3295 private function optimize_title(string $title, string $context): string {
3296 // Remove extra whitespace
3297 $title = preg_replace('/\s+/', ' ', $title);
3298 $title = trim($title);
3299
3300 // Ensure title is not too long (60 characters max for SEO).
3301 // All three units here were wrong for non-Latin text: strlen() counts
3302 // BYTES so the gate fired at 20 Thai characters, wp_trim_words() counts
3303 // CHARACTERS on th/ja/zh_* so `8` cut the title to 8 of them, and
3304 // substr() cuts bytes so it split a character mid-sequence (#687).
3305 $title = \ThinkRank\Core\Seo_Text::trim_to_length(
3306 $title,
3307 \ThinkRank\Core\Seo_Text::TITLE_MAX_LENGTH
3308 );
3309
3310 // Ensure title is not empty
3311 if (empty($title)) {
3312 $title = get_bloginfo('name');
3313 }
3314
3315 return $title;
3316 }
3317
3318 /**
3319 * Extract title data from context
3320 *
3321 * @since 1.0.0
3322 *
3323 * @param string $context_type Context type
3324 * @param int|null $context_id Context ID
3325 * @return array Title data
3326 */
3327 private function extract_title_data(string $context_type, ?int $context_id): array {
3328 $data = [
3329 'title' => '',
3330 'context_type' => $context_type,
3331 'context_id' => $context_id
3332 ];
3333
3334 switch ($context_type) {
3335 case 'site':
3336 $data['title'] = get_bloginfo('name');
3337 break;
3338 case 'post':
3339 case 'page':
3340 case 'product':
3341 if ($context_id) {
3342 $data['title'] = get_the_title($context_id);
3343 }
3344 break;
3345 case 'search':
3346 $data['title'] = 'Search Results';
3347 break;
3348 case '404':
3349 $data['title'] = 'Page Not Found';
3350 break;
3351 }
3352
3353 return $data;
3354 }
3355
3356 /**
3357 * Generate hierarchical breadcrumbs
3358 *
3359 * @since 1.0.0
3360 *
3361 * @param array $options Breadcrumb options
3362 * @return array Breadcrumb items
3363 */
3364 private function generate_hierarchical_breadcrumbs(array $options): array {
3365 $breadcrumbs = [];
3366
3367 // Add home breadcrumb
3368 $breadcrumbs[] = [
3369 'title' => 'Home',
3370 'url' => home_url(),
3371 'position' => 1
3372 ];
3373
3374 $context_type = $options['context_type'] ?? '';
3375 $context_id = $options['context_id'] ?? null;
3376
3377 if ($context_type === 'post' || $context_type === 'page' || $context_type === 'product') {
3378 if ($context_id) {
3379 $post = get_post($context_id);
3380 if ($post) {
3381 // Add parent pages for hierarchical content
3382 $ancestors = get_post_ancestors($post);
3383 $ancestors = array_reverse($ancestors);
3384
3385 $position = 2;
3386 foreach ($ancestors as $ancestor_id) {
3387 $breadcrumbs[] = [
3388 'title' => get_the_title($ancestor_id),
3389 'url' => get_permalink($ancestor_id),
3390 'position' => $position++
3391 ];
3392 }
3393
3394 // Add current page
3395 $breadcrumbs[] = [
3396 'title' => get_the_title($post),
3397 'url' => get_permalink($post),
3398 'position' => $position,
3399 'current' => true
3400 ];
3401 }
3402 }
3403 }
3404
3405 return $breadcrumbs;
3406 }
3407
3408 /**
3409 * Generate taxonomy-based breadcrumbs
3410 *
3411 * @since 1.0.0
3412 *
3413 * @param array $options Breadcrumb options
3414 * @return array Breadcrumb items
3415 */
3416 private function generate_taxonomy_breadcrumbs(array $options): array {
3417 $breadcrumbs = [];
3418
3419 // Add home breadcrumb
3420 $breadcrumbs[] = [
3421 'title' => 'Home',
3422 'url' => home_url(),
3423 'position' => 1
3424 ];
3425
3426 $context_type = $options['context_type'] ?? '';
3427 $context_id = $options['context_id'] ?? null;
3428
3429 if (($context_type === 'post' || $context_type === 'product') && $context_id) {
3430 $post = get_post($context_id);
3431 if ($post) {
3432 // Get primary category
3433 $categories = get_the_category($post->ID);
3434 if (!empty($categories)) {
3435 $primary_category = $categories[0];
3436
3437 // Add category hierarchy
3438 $category_ancestors = get_ancestors($primary_category->term_id, 'category');
3439 $category_ancestors = array_reverse($category_ancestors);
3440
3441 $position = 2;
3442 foreach ($category_ancestors as $ancestor_id) {
3443 $ancestor = get_category($ancestor_id);
3444 $breadcrumbs[] = [
3445 'title' => $ancestor->name,
3446 'url' => get_category_link($ancestor_id),
3447 'position' => $position++
3448 ];
3449 }
3450
3451 // Add primary category
3452 $breadcrumbs[] = [
3453 'title' => $primary_category->name,
3454 'url' => get_category_link($primary_category->term_id),
3455 'position' => $position++
3456 ];
3457 }
3458
3459 // Add current post
3460 $breadcrumbs[] = [
3461 'title' => get_the_title($post),
3462 'url' => get_permalink($post),
3463 'position' => $position,
3464 'current' => true
3465 ];
3466 }
3467 }
3468
3469 return $breadcrumbs;
3470 }
3471
3472 /**
3473 * Generate path-based breadcrumbs
3474 *
3475 * @since 1.0.0
3476 *
3477 * @param array $options Breadcrumb options
3478 * @return array Breadcrumb items
3479 */
3480 private function generate_path_breadcrumbs(array $options): array {
3481 $breadcrumbs = [];
3482
3483 // Add home breadcrumb
3484 $breadcrumbs[] = [
3485 'title' => 'Home',
3486 'url' => home_url(),
3487 'position' => 1
3488 ];
3489
3490 // Get current URL path
3491 $current_url = home_url(add_query_arg([]));
3492 $path = wp_parse_url($current_url, PHP_URL_PATH);
3493 $path_parts = array_filter(explode('/', trim($path, '/')));
3494
3495 $position = 2;
3496 $cumulative_path = '';
3497
3498 foreach ($path_parts as $part) {
3499 $cumulative_path .= '/' . $part;
3500 $url = home_url($cumulative_path);
3501
3502 // Try to get a meaningful title
3503 $title = ucwords(str_replace(['-', '_'], ' ', $part));
3504
3505 $breadcrumbs[] = [
3506 'title' => $title,
3507 'url' => $url,
3508 'position' => $position++,
3509 'current' => $cumulative_path === $path
3510 ];
3511 }
3512
3513 return $breadcrumbs;
3514 }
3515
3516 /**
3517 * Generate custom breadcrumbs
3518 *
3519 * @since 1.0.0
3520 *
3521 * @param array $options Breadcrumb options
3522 * @return array Breadcrumb items
3523 */
3524 private function generate_custom_breadcrumbs(array $options): array {
3525 // Return custom breadcrumbs if provided in options
3526 return $options['custom_breadcrumbs'] ?? [];
3527 }
3528
3529 /**
3530 * Generate breadcrumb schema markup
3531 *
3532 * @since 1.0.0
3533 *
3534 * @param array $breadcrumb_items Breadcrumb items
3535 * @return array Schema markup
3536 */
3537 private function generate_breadcrumb_schema(array $breadcrumb_items): array {
3538 $schema = [
3539 '@context' => 'https://schema.org',
3540 '@type' => 'BreadcrumbList',
3541 'itemListElement' => []
3542 ];
3543
3544 foreach ($breadcrumb_items as $item) {
3545 $schema['itemListElement'][] = [
3546 '@type' => 'ListItem',
3547 'position' => $item['position'],
3548 'name' => $item['title'],
3549 'item' => $item['url']
3550 ];
3551 }
3552
3553 return $schema;
3554 }
3555
3556 /**
3557 * Generate breadcrumb HTML
3558 *
3559 * @since 1.0.0
3560 *
3561 * @param array $breadcrumb_items Breadcrumb items
3562 * @param array $settings Breadcrumb settings
3563 * @return string HTML output
3564 */
3565 private function generate_breadcrumb_html(array $breadcrumb_items, array $settings): string {
3566 if (empty($breadcrumb_items)) {
3567 return '';
3568 }
3569
3570 $separator = $settings['separator'] ?? '>';
3571 $html = '<nav class="thinkrank-breadcrumbs" aria-label="Breadcrumb">';
3572 $html .= '<ol class="breadcrumb-list">';
3573
3574 foreach ($breadcrumb_items as $item) {
3575 $html .= '<li class="breadcrumb-item">';
3576
3577 if (!empty($item['current'])) {
3578 $html .= '<span class="breadcrumb-current" aria-current="page">' . esc_html($item['title']) . '</span>';
3579 } else {
3580 $html .= '<a href="' . esc_url($item['url']) . '">' . esc_html($item['title']) . '</a>';
3581 }
3582
3583 if ($item['position'] < count($breadcrumb_items)) {
3584 $html .= ' <span class="breadcrumb-separator">' . esc_html($separator) . '</span> ';
3585 }
3586
3587 $html .= '</li>';
3588 }
3589
3590 $html .= '</ol>';
3591 $html .= '</nav>';
3592
3593 return $html;
3594 }
3595
3596 /**
3597 * Generate default robots.txt rules
3598 *
3599 * @since 1.0.0
3600 *
3601 * @param array $settings Robots.txt settings
3602 * @return array Default rules
3603 */
3604 private function generate_default_robots_rules(array $settings): array {
3605 $rules = [];
3606
3607 // Full block: when the admin turns off "Allow Search Engines" or enables
3608 // WordPress's "Discourage search engines" (Settings → Reading, stored as
3609 // blog_public=0), serve a robots.txt that disallows everything rather
3610 // than the default per-path rules — otherwise the toggle has no effect.
3611 $allow_search = $settings['allow_search_engines'] ?? true;
3612 if (empty($allow_search) || !get_option('blog_public')) {
3613 $rules[] = ['directive' => 'user_agent', 'value' => '*'];
3614 $rules[] = ['directive' => 'disallow', 'value' => '/'];
3615 return $rules;
3616 }
3617
3618 // Default user agent rule
3619 $rules[] = [
3620 'directive' => 'user_agent',
3621 'value' => '*'
3622 ];
3623
3624 // WordPress core disallows.
3625 //
3626 // Deliberately minimal, matching Yoast/Rank Math defaults. We do NOT
3627 // block /wp-includes/, /wp-content/plugins/, or /wp-content/themes/:
3628 // those paths serve the CSS and JS Google must fetch to render pages,
3629 // and blocking them causes "blocked resource" warnings and can hurt
3630 // rankings. /wp-json/ is left crawlable for the same reason (embeds,
3631 // oEmbed, structured previews). Only wp-admin (bar admin-ajax) and the
3632 // handful of non-content endpoints below are disallowed.
3633 $default_disallows = [
3634 '/wp-admin/',
3635 '/xmlrpc.php',
3636 '/readme.html',
3637 '/license.txt',
3638 ];
3639
3640 // WooCommerce: keep cart/checkout/account and add-to-cart query URLs out
3641 // of the index to avoid crawl noise and duplicate/session URLs (parity
3642 // with Rank Math's WooCommerce robots defaults).
3643 if (class_exists('WooCommerce')) {
3644 $default_disallows[] = '/cart/';
3645 $default_disallows[] = '/checkout/';
3646 $default_disallows[] = '/my-account/';
3647 $default_disallows[] = '/*add-to-cart=*';
3648 }
3649
3650 foreach ($default_disallows as $disallow) {
3651 $rules[] = [
3652 'directive' => 'disallow',
3653 'value' => $disallow
3654 ];
3655 }
3656
3657 // Allow specific files
3658 $default_allows = [
3659 '/wp-admin/admin-ajax.php',
3660 '/wp-content/uploads/'
3661 ];
3662
3663 foreach ($default_allows as $allow) {
3664 $rules[] = [
3665 'directive' => 'allow',
3666 'value' => $allow
3667 ];
3668 }
3669
3670 // Add sitemap URLs from sitemap settings (auto-sync)
3671 $sitemap_urls = $this->get_sitemap_urls_for_robots();
3672
3673 foreach ($sitemap_urls as $sitemap_url) {
3674 if (!empty($sitemap_url)) {
3675 $rules[] = [
3676 'directive' => 'sitemap',
3677 'value' => $sitemap_url
3678 ];
3679 }
3680 }
3681
3682 // Add crawl delay if specified
3683 if (!empty($settings['crawl_delay'])) {
3684 $rules[] = [
3685 'directive' => 'crawl_delay',
3686 'value' => (int) $settings['crawl_delay']
3687 ];
3688 }
3689
3690 return $rules;
3691 }
3692
3693 /**
3694 * Get sitemap URLs from sitemap settings for robots.txt integration
3695 *
3696 * @since 1.0.0
3697 * @return array Array of sitemap URLs
3698 */
3699 private function get_sitemap_urls_for_robots(): array {
3700 // One wrapper over every return path below, including the #104 extras.
3701 // The Sitemap: line is the only absolute URL of ours in robots.txt and
3702 // the one a crawler follows to find everything else, so it has to carry
3703 // the site's scheme preference (#638). Applied here rather than where
3704 // the body is assembled, because that path also renders a robots.txt a
3705 // site owner typed themselves, and their text is not ours to rewrite.
3706 return array_map(
3707 static function (string $url): string {
3708 return Url_Scheme::apply($url);
3709 },
3710 $this->collect_sitemap_urls_for_robots()
3711 );
3712 }
3713
3714 /**
3715 * The sitemap URLs robots.txt advertises, before the scheme preference.
3716 *
3717 * @since 1.0.0
3718 * @return array Array of sitemap URLs
3719 */
3720 private function collect_sitemap_urls_for_robots(): array {
3721 try {
3722 // Get sitemap settings
3723 $sitemap_generator = new \ThinkRank\SEO\Sitemap_Generator();
3724 $sitemap_settings = $sitemap_generator->get_settings('site');
3725
3726 // If sitemap is disabled, return default
3727 if (empty($sitemap_settings['enabled'])) {
3728 return [home_url('/sitemap.xml')];
3729 }
3730
3731 $sitemap_urls = [];
3732 $site_url = home_url();
3733
3734 // Extract enabled sitemap URLs. When the index is enabled it is the
3735 // only entry worth advertising: every child sitemap is already
3736 // listed inside it, so naming them again in robots.txt is pure
3737 // redundancy and drifts out of date as soon as a post type is added.
3738 $index_url = '';
3739 if (!empty($sitemap_settings['sitemap_urls']) && is_array($sitemap_settings['sitemap_urls'])) {
3740 foreach ($sitemap_settings['sitemap_urls'] as $sitemap) {
3741 if (empty($sitemap['enabled']) || empty($sitemap['url'])) {
3742 continue;
3743 }
3744
3745 if (($sitemap['type'] ?? '') === 'index') {
3746 $index_url = $site_url . $sitemap['url'];
3747 continue;
3748 }
3749
3750 $sitemap_urls[] = $site_url . $sitemap['url'];
3751 }
3752 }
3753
3754 if ($index_url !== '') {
3755 // The index alone — it covers the children and, on a segmented
3756 // install, the local business sitemap too.
3757 return [$index_url];
3758 }
3759
3760 // Fallback to default if no URLs found
3761 if (empty($sitemap_urls)) {
3762 $sitemap_urls[] = home_url('/sitemap.xml');
3763 }
3764
3765 // No index on this install, so anything not already listed above has
3766 // no other discovery path — advertise it directly. The local
3767 // business sitemap and the sitemaps other plugins register both land
3768 // here for the same reason, so they go through one list (#104).
3769 $extra = [];
3770
3771 // Not a file test. Under dynamic delivery the local sitemap is
3772 // served from PHP and no file is ever written, so file_exists()
3773 // silently dropped a sitemap the site really does publish (#752).
3774 // On static sites the file is still what proves it, so both count.
3775 $local_sitemap_published = file_exists(ABSPATH . 'local-sitemap.xml');
3776
3777 if (!$local_sitemap_published && class_exists('ThinkRank\\SEO\\Sitemap_Generator')) {
3778 $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3779
3780 $local_sitemap_published = 'dynamic' === $generator->resolve_delivery_mode()
3781 && $generator->publishes_local_sitemap();
3782 }
3783
3784 if ($local_sitemap_published) {
3785 $extra[] = '/local-sitemap.xml';
3786 }
3787
3788 foreach (\ThinkRank\SEO\Sitemap_Generator::additional_sitemaps() as $path) {
3789 $extra[] = $path;
3790 }
3791
3792 foreach ($extra as $path) {
3793 $url = home_url($path);
3794 if (!in_array($url, $sitemap_urls, true)) {
3795 $sitemap_urls[] = $url;
3796 }
3797 }
3798
3799 return $sitemap_urls;
3800 } catch (\Exception $e) {
3801 // Fallback to default on error
3802 return [home_url('/sitemap.xml')];
3803 }
3804 }
3805
3806 /**
3807 * Validate robots.txt rules
3808 *
3809 * @since 1.0.0
3810 *
3811 * @param array $rules Rules to validate
3812 * @return array Validation results
3813 */
3814 private function validate_robots_rules(array $rules): array {
3815 $validation = [
3816 'valid' => true,
3817 'errors' => [],
3818 'warnings' => [],
3819 'suggestions' => []
3820 ];
3821
3822 $has_user_agent = false;
3823
3824 foreach ($rules as $rule) {
3825 $directive = $rule['directive'] ?? '';
3826 $value = $rule['value'] ?? '';
3827
3828 // Check if directive is valid
3829 if (!isset($this->robots_directives[$directive])) {
3830 $validation['errors'][] = "Unknown robots.txt directive: {$directive}";
3831 $validation['valid'] = false;
3832 continue;
3833 }
3834
3835 // Check for required user-agent
3836 if ($directive === 'user_agent') {
3837 $has_user_agent = true;
3838 }
3839
3840 // Validate directive-specific rules
3841 switch ($directive) {
3842 case 'disallow':
3843 case 'allow':
3844 if (!str_starts_with($value, '/')) {
3845 $validation['warnings'][] = "Path '{$value}' should start with '/'";
3846 }
3847 break;
3848 case 'sitemap':
3849 if (!filter_var($value, FILTER_VALIDATE_URL)) {
3850 $validation['errors'][] = "Invalid sitemap URL: {$value}";
3851 $validation['valid'] = false;
3852 }
3853 break;
3854 case 'crawl_delay':
3855 if (!is_numeric($value) || $value < 0) {
3856 $validation['errors'][] = "Crawl delay must be a positive number";
3857 $validation['valid'] = false;
3858 }
3859 break;
3860 }
3861 }
3862
3863 if (!$has_user_agent) {
3864 $validation['errors'][] = 'robots.txt must include at least one User-agent directive';
3865 $validation['valid'] = false;
3866 }
3867
3868 return $validation;
3869 }
3870
3871 /**
3872 * Build robots.txt content from rules
3873 *
3874 * @since 1.0.0
3875 *
3876 * @param array $rules Robots.txt rules
3877 * @return string Robots.txt content
3878 */
3879 private function build_robots_txt_content(array $rules): string {
3880 // Body only — no header. The "# generated by ThinkRank SEO" + timestamp
3881 // block is added at render time (see robots_txt_header), so it never
3882 // gets baked into the stored/editable content and can't show a stale
3883 // timestamp on every update.
3884 $content = '';
3885
3886 $current_user_agent = '';
3887 $sitemap_started = false;
3888
3889 foreach ($rules as $rule) {
3890 $directive = $rule['directive'] ?? '';
3891 $value = $rule['value'] ?? '';
3892
3893 switch ($directive) {
3894 case 'user_agent':
3895 if ($current_user_agent !== $value) {
3896 $content .= "\nUser-agent: {$value}\n";
3897 $current_user_agent = $value;
3898 }
3899 break;
3900 case 'disallow':
3901 $content .= "Disallow: {$value}\n";
3902 break;
3903 case 'allow':
3904 $content .= "Allow: {$value}\n";
3905 break;
3906 case 'crawl_delay':
3907 $content .= "Crawl-delay: {$value}\n";
3908 break;
3909 case 'sitemap':
3910 // One blank line separates the Sitemap block from the
3911 // preceding group, and none appear inside it. A blank line
3912 // terminates a record in the robots.txt grammar, so putting
3913 // one between every directive was invalid formatting.
3914 if (!$sitemap_started) {
3915 $content .= "\n";
3916 $sitemap_started = true;
3917 }
3918 $content .= "Sitemap: {$value}\n";
3919 break;
3920 }
3921 }
3922
3923 return ltrim($content, "\n");
3924 }
3925
3926 /**
3927 * Parse a robots.txt body back into the {directive, value} rule shape.
3928 *
3929 * generate_robots_txt() returns `rules` alongside `content`, but callers
3930 * replace `content` with the body actually being served (a stored override
3931 * or a physical file). The generated rules then described something the
3932 * response no longer contained. Re-deriving them from the served body keeps
3933 * the two halves of the payload describing the same document.
3934 *
3935 * @since 2.0.1
3936 *
3937 * @param string $content Robots.txt body (header optional).
3938 * @return array<int, array{directive: string, value: string}> Parsed rules.
3939 */
3940 public function parse_robots_txt_rules(string $content): array {
3941 $map = [
3942 'user-agent' => 'user_agent',
3943 'disallow' => 'disallow',
3944 'allow' => 'allow',
3945 'crawl-delay' => 'crawl_delay',
3946 'sitemap' => 'sitemap',
3947 ];
3948
3949 $rules = [];
3950
3951 foreach (preg_split('/\r\n|\r|\n/', $this->strip_robots_header($content)) as $line) {
3952 $line = trim($line);
3953
3954 // Blank lines separate groups and `#` starts a comment; neither is
3955 // a rule.
3956 if ($line === '' || str_starts_with($line, '#')) {
3957 continue;
3958 }
3959
3960 $parts = explode(':', $line, 2);
3961 if (count($parts) !== 2) {
3962 continue;
3963 }
3964
3965 $field = strtolower(trim($parts[0]));
3966 if (!isset($map[$field])) {
3967 continue;
3968 }
3969
3970 $rules[] = [
3971 'directive' => $map[$field],
3972 // Sitemap values are absolute URLs and contain the `:` the
3973 // limited explode above deliberately preserved.
3974 'value' => trim($parts[1]),
3975 ];
3976 }
3977
3978 return $rules;
3979 }
3980
3981 /**
3982 * Opening fence of the machine-owned AI crawler region.
3983 *
3984 * @since 2.5.0
3985 * @var string
3986 */
3987 public const AI_BLOCK_BEGIN = '# BEGIN ThinkRank AI crawlers';
3988
3989 /**
3990 * Closing fence of the machine-owned AI crawler region.
3991 *
3992 * @since 2.5.0
3993 * @var string
3994 */
3995 public const AI_BLOCK_END = '# END ThinkRank AI crawlers';
3996
3997 /**
3998 * Render the fenced AI crawler region for the current settings.
3999 *
4000 * One `User-agent:` / `Disallow: /` record per blocked crawler. Allowed
4001 * crawlers emit nothing at all: `Disallow:` with an empty value is the
4002 * robots.txt way of saying "allow everything", but writing eighteen such
4003 * records to say what silence already says would triple the file and
4004 * invite the reading that an unlisted crawler is therefore refused.
4005 *
4006 * @since 2.5.0
4007 *
4008 * @param array $settings Site settings.
4009 * @return string Fenced block, newline-terminated, or '' when nothing is blocked.
4010 */
4011 private function build_ai_crawler_block(array $settings): string {
4012 $blocked = AI_Crawlers::blocked_slugs($settings['ai_crawler_rules'] ?? []);
4013
4014 if (empty($blocked)) {
4015 return '';
4016 }
4017
4018 $agents = AI_Crawlers::all();
4019
4020 $lines = [
4021 self::AI_BLOCK_BEGIN,
4022 '# Managed by ThinkRank — edits between these lines are overwritten.',
4023 ];
4024
4025 foreach ($blocked as $slug) {
4026 $lines[] = '';
4027 $lines[] = 'User-agent: ' . $agents[$slug]['token'];
4028 $lines[] = 'Disallow: /';
4029 }
4030
4031 $lines[] = self::AI_BLOCK_END;
4032
4033 return implode("\n", $lines) . "\n";
4034 }
4035
4036 /**
4037 * Remove the fenced AI crawler region from a robots.txt body.
4038 *
4039 * Tolerates a missing closing fence rather than leaving the rest of the
4040 * file swallowed: a truncated write, or someone deleting the END line by
4041 * hand, would otherwise make every subsequent read drop everything below
4042 * the opening fence.
4043 *
4044 * @since 2.5.0
4045 *
4046 * @param string $body Robots.txt body.
4047 * @return string Body with the region removed.
4048 */
4049 public function strip_ai_crawler_block(string $body): string {
4050 if (false === strpos($body, self::AI_BLOCK_BEGIN)) {
4051 return $body;
4052 }
4053
4054 $pattern = '/\R*' . preg_quote(self::AI_BLOCK_BEGIN, '/')
4055 . '.*?(?:' . preg_quote(self::AI_BLOCK_END, '/') . '|\z)\R*/s';
4056
4057 return trim((string) preg_replace($pattern, "\n\n", $body, 1));
4058 }
4059
4060 /**
4061 * Put the current AI crawler region into a robots.txt body.
4062 *
4063 * Replaces an existing region in place so the block keeps its position in
4064 * a hand-ordered file, and appends when there is none. Everything outside
4065 * the fences is returned untouched — that is the whole point of fencing
4066 * it, since the body is also a free-text field the user edits.
4067 *
4068 * @since 2.5.0
4069 *
4070 * @param string $body Robots.txt body (fences optional).
4071 * @param array $settings Site settings.
4072 * @return string Body carrying the current region.
4073 */
4074 private function apply_ai_crawler_block(string $body, array $settings): string {
4075 $stripped = $this->strip_ai_crawler_block($body);
4076 $block = $this->build_ai_crawler_block($settings);
4077
4078 if ('' === $block) {
4079 return $stripped;
4080 }
4081
4082 if ('' === trim($stripped)) {
4083 return trim($block);
4084 }
4085
4086 return rtrim($stripped) . "\n\n" . trim($block);
4087 }
4088
4089 /**
4090 * The auto-generated header prepended to the served robots.txt.
4091 *
4092 * Kept separate from the body so it is only ever added at render time with
4093 * a fresh timestamp, never stored or shown in the editable textarea.
4094 *
4095 * @return string
4096 */
4097 private function robots_txt_header(): string {
4098 return "# Robots.txt generated by ThinkRank SEO\n"
4099 . "# " . gmdate('Y-m-d H:i:s') . " UTC\n\n";
4100 }
4101
4102 /**
4103 * Strip our auto-generated header from a robots.txt string.
4104 *
4105 * Used when surfacing existing content for editing so the header/timestamp
4106 * doesn't round-trip back into storage.
4107 *
4108 * @param string $content Raw robots.txt content.
4109 * @return string Body without the ThinkRank header.
4110 */
4111 private function strip_robots_header(string $content): string {
4112 $pattern = '/^# Robots\.txt generated by ThinkRank SEO\r?\n# [^\r\n]* UTC\r?\n\r?\n/';
4113 return trim((string) preg_replace($pattern, '', $content, 1));
4114 }
4115
4116 /**
4117 * The body that should populate the editor for the current site.
4118 *
4119 * Prefers what is actually being served: the physical file if one exists
4120 * (header stripped), otherwise the effective body. This is what the admin
4121 * screen shows so the textarea is never blank while /robots.txt has content.
4122 *
4123 * @return string
4124 */
4125 public function get_served_robots_body(): string {
4126 $settings = $this->get_settings('site');
4127
4128 // The AI block is stripped from every one of these paths. A physical
4129 // robots.txt we wrote carries it, and the stored override is whatever
4130 // the textarea last held — so without this the block round-trips into
4131 // the editor, gets saved as ordinary body text, and is then appended
4132 // to a second time on the next render.
4133 $custom = trim((string) ($settings['robots_txt_content'] ?? ''));
4134 if ($custom !== '') {
4135 return $this->strip_ai_crawler_block($this->strip_robots_header($custom));
4136 }
4137
4138 $robots_file = ABSPATH . 'robots.txt';
4139 if (file_exists($robots_file)) {
4140 // 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.
4141 $raw = (string) @file_get_contents($robots_file);
4142 if ($raw !== '') {
4143 return $this->strip_ai_crawler_block($this->strip_robots_header($raw));
4144 }
4145 }
4146
4147 return $this->strip_ai_crawler_block(trim($this->generate_robots_txt()['content']));
4148 }
4149 private function get_site_identity_data(array $settings): array {
4150 return [
4151 'site_name' => $settings['site_name'] ?? get_bloginfo('name'),
4152 'site_description' => $settings['site_description'] ?? get_bloginfo('description'),
4153 'tagline' => $settings['tagline'] ?? get_bloginfo('description'),
4154 'logo_url' => $settings['logo_url'] ?? '',
4155 'favicon_url' => $settings['favicon_url'] ?? '',
4156 'apple_touch_icon_url' => $settings['apple_touch_icon_url'] ?? ''
4157 ];
4158 }
4159
4160 /**
4161 * Optimize individual identity element
4162 *
4163 * @since 1.0.0
4164 *
4165 * @param string $element Element name
4166 * @param mixed $value Element value
4167 * @param array $config Element configuration
4168 * @return array Optimization results
4169 */
4170 private function optimize_identity_element(string $element, $value, array $config): array {
4171 $optimization = [
4172 'optimized_value' => $value,
4173 'validation' => [
4174 'valid' => true,
4175 'errors' => [],
4176 'warnings' => []
4177 ],
4178 'suggestions' => []
4179 ];
4180
4181 switch ($config['type']) {
4182 case 'text':
4183 $optimization = $this->optimize_text_element($element, $value, $config, $optimization);
4184 break;
4185 case 'image':
4186 $optimization = $this->optimize_image_element($element, $value, $config, $optimization);
4187 break;
4188 }
4189
4190 return $optimization;
4191 }
4192
4193 /**
4194 * Optimize text identity element
4195 *
4196 * @since 1.0.0
4197 *
4198 * @param string $element Element name
4199 * @param string $value Element value
4200 * @param array $config Element configuration
4201 * @param array $optimization Current optimization
4202 * @return array Updated optimization
4203 */
4204 private function optimize_text_element(string $element, string $value, array $config, array $optimization): array {
4205 if (empty($value) && !empty($config['required'])) {
4206 $optimization['validation']['errors'][] = "{$element} is required";
4207 $optimization['validation']['valid'] = false;
4208 }
4209
4210 if (!empty($value) && isset($config['max_length'])) {
4211 // The warning says "characters", so measure and cut in characters:
4212 // strlen()/substr() fired early on non-Latin values and the
4213 // suggested replacement was cut mid-character (#687).
4214 if (mb_strlen($value) > $config['max_length']) {
4215 $optimization['validation']['warnings'][] = "{$element} exceeds maximum length of {$config['max_length']} characters";
4216 $optimization['optimized_value'] = \ThinkRank\Core\Seo_Text::trim_to_length($value, (int) $config['max_length']);
4217 }
4218 }
4219
4220 // SEO-specific optimizations
4221 if ($element === 'site_name' && !empty($value)) {
4222 // Remove excessive punctuation
4223 $optimization['optimized_value'] = preg_replace('/[!@#$%^&*()]+/', '', $value);
4224 }
4225
4226 return $optimization;
4227 }
4228
4229 /**
4230 * Optimize image identity element
4231 *
4232 * @since 1.0.0
4233 *
4234 * @param string $element Element name
4235 * @param string $value Element value
4236 * @param array $config Element configuration
4237 * @param array $optimization Current optimization
4238 * @return array Updated optimization
4239 */
4240 private function optimize_image_element(string $element, string $value, array $config, array $optimization): array {
4241 if (empty($value)) {
4242 if (!empty($config['required'])) {
4243 $optimization['validation']['errors'][] = "{$element} is required";
4244 $optimization['validation']['valid'] = false;
4245 }
4246 return $optimization;
4247 }
4248
4249 // Validate URL
4250 if (!filter_var($value, FILTER_VALIDATE_URL)) {
4251 $optimization['validation']['errors'][] = "{$element} must be a valid URL";
4252 $optimization['validation']['valid'] = false;
4253 return $optimization;
4254 }
4255
4256 // Check if it's a local image
4257 $attachment_id = Attachment_Lookup::id_from_url($value);
4258 if ($attachment_id) {
4259 $image_meta = wp_get_attachment_metadata($attachment_id);
4260
4261 if ($image_meta && isset($image_meta['width'], $image_meta['height'])) {
4262 // Check recommended size, against the configured file itself
4263 // rather than the upload it may have been generated from.
4264 if (isset($config['recommended_size'])) {
4265 [$rec_width, $rec_height] = explode('x', $config['recommended_size']);
4266 $image_file = Attachment_Lookup::describe($attachment_id, $value);
4267
4268 if ($image_file['width'] !== (int) $rec_width || $image_file['height'] !== (int) $rec_height) {
4269 $optimization['suggestions'][] = "Consider using {$config['recommended_size']} size for optimal {$element}";
4270 }
4271 }
4272
4273 // Check file size
4274 if (isset($config['max_size'])) {
4275 $file_path = get_attached_file($attachment_id);
4276 if ($file_path && file_exists($file_path)) {
4277 $file_size = filesize($file_path);
4278 $max_size_bytes = $this->parse_size_string($config['max_size']);
4279
4280 if ($file_size > $max_size_bytes) {
4281 $optimization['validation']['warnings'][] = "{$element} file size exceeds {$config['max_size']}";
4282 }
4283 }
4284 }
4285 }
4286 }
4287
4288 return $optimization;
4289 }
4290
4291 /**
4292 * Parse size string to bytes
4293 *
4294 * @since 1.0.0
4295 *
4296 * @param string $size_string Size string (e.g., '2MB', '500KB')
4297 * @return int Size in bytes
4298 */
4299 private function parse_size_string(string $size_string): int {
4300 $size_string = strtoupper(trim($size_string));
4301 $size = (int) $size_string;
4302
4303 if (strpos($size_string, 'KB') !== false) {
4304 return $size * 1024;
4305 } elseif (strpos($size_string, 'MB') !== false) {
4306 return $size * 1024 * 1024;
4307 } elseif (strpos($size_string, 'GB') !== false) {
4308 return $size * 1024 * 1024 * 1024;
4309 }
4310
4311 return $size;
4312 }
4313
4314 /**
4315 * Calculate identity optimization score
4316 *
4317 * @since 1.0.0
4318 *
4319 * @param array $validations Element validations
4320 * @return int Score (0-100)
4321 */
4322 private function calculate_identity_score(array $validations): int {
4323 $total_score = 0;
4324 $element_count = 0;
4325
4326 foreach ($validations as $validation) {
4327 $element_score = 100;
4328 $element_score -= count($validation['errors']) * 30;
4329 $element_score -= count($validation['warnings']) * 15;
4330
4331 $total_score += max(0, $element_score);
4332 $element_count++;
4333 }
4334
4335 return $element_count > 0 ? (int) round($total_score / $element_count) : 0;
4336 }
4337
4338 /**
4339 * Calculate validation score
4340 *
4341 * @since 1.0.0
4342 *
4343 * @param array $validation Validation results
4344 * @return int Score (0-100)
4345 */
4346 private function calculate_validation_score(array $validation): int {
4347 $score = 100;
4348 $score -= count($validation['errors']) * 20;
4349 $score -= count($validation['warnings']) * 10;
4350 $score -= count($validation['suggestions']) * 5;
4351
4352 return max(0, $score);
4353 }
4354 }
4355