PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
2.14.2 2.14.1 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 All 57 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.2, at includes/seo/class-site-identity-manager.php

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