PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.0
2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 All 55 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.14.0, at includes/seo/class-site-identity-manager.php

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