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

4,612 lines 171.0 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 (!filter_var($logo_url, FILTER_VALIDATE_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) && filter_var($logo_url, FILTER_VALIDATE_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 // Validate logo URL
2150 if (isset($settings['logo_url']) && !empty($settings['logo_url'])) {
2151 if (!filter_var($settings['logo_url'], FILTER_VALIDATE_URL)) {
2152 $validation['errors'][] = 'Logo URL must be a valid URL';
2153 $validation['valid'] = false;
2154 }
2155 }
2156
2157 // Validate breadcrumb settings
2158 if (isset($settings['breadcrumb_type'])) {
2159 if (!isset($this->breadcrumb_types[$settings['breadcrumb_type']])) {
2160 $validation['errors'][] = 'Invalid breadcrumb type specified';
2161 $validation['valid'] = false;
2162 }
2163 }
2164
2165 // Validate robots.txt settings
2166 if (isset($settings['robots_txt_enabled']) && $settings['robots_txt_enabled']) {
2167 if (!$this->is_directory_writable(ABSPATH)) {
2168 $validation['warnings'][] = 'WordPress root directory is not writable, robots.txt cannot be automatically managed';
2169 }
2170 }
2171
2172 // Validate local SEO settings if enabled
2173 if (isset($settings['local_seo_enabled']) && $settings['local_seo_enabled']) {
2174 $local_seo_validation = $this->validate_local_seo_settings($settings);
2175 $validation['errors'] = array_merge($validation['errors'], $local_seo_validation['errors']);
2176 $validation['warnings'] = array_merge($validation['warnings'], $local_seo_validation['warnings']);
2177 $validation['suggestions'] = array_merge($validation['suggestions'], $local_seo_validation['suggestions']);
2178
2179 if (!$local_seo_validation['valid']) {
2180 $validation['valid'] = false;
2181 }
2182 }
2183
2184 // Calculate validation score
2185 $validation['score'] = $this->calculate_validation_score($validation);
2186
2187 // Add detailed field validation breakdown for generic validation
2188 $validation['field_details'] = $this->get_detailed_field_validation($settings, '');
2189
2190 return $validation;
2191 }
2192
2193 /**
2194 * Get tab-specific validation
2195 *
2196 * @since 1.0.0
2197 *
2198 * @param array $settings Settings array to validate
2199 * @param string $tab_context Tab context for specific validation
2200 * @return array Tab-specific validation results
2201 */
2202 private function get_tab_specific_validation(array $settings, string $tab_context): array {
2203 $validation = [
2204 'valid' => true,
2205 'errors' => [],
2206 'warnings' => [],
2207 'suggestions' => [],
2208 'score' => 100
2209 ];
2210
2211 // Get tab-specific field details
2212 $field_details = $this->get_detailed_field_validation($settings, $tab_context);
2213
2214 // Convert field details to validation format
2215 foreach ($field_details as $field) {
2216 switch ($field['status']) {
2217 case 'error':
2218 $validation['errors'][] = $field['label'];
2219 $validation['valid'] = false;
2220 $validation['score'] -= 20;
2221 break;
2222 case 'warning':
2223 $validation['warnings'][] = $field['label'];
2224 $validation['score'] -= 10;
2225 break;
2226 case 'suggestion':
2227 $validation['suggestions'][] = $field['label'];
2228 $validation['score'] -= 5;
2229 break;
2230 }
2231 }
2232
2233 // Ensure score doesn't go below 0
2234 $validation['score'] = max(0, $validation['score']);
2235
2236 // Add field details for frontend display
2237 $validation['field_details'] = $field_details;
2238
2239 return $validation;
2240 }
2241
2242 /**
2243 * Get detailed field validation breakdown
2244 *
2245 * @since 1.0.0
2246 *
2247 * @param array $settings Settings array to validate
2248 * @param string $tab_context Tab context for specific validation
2249 * @return array Detailed field validation results
2250 */
2251 private function get_detailed_field_validation(array $settings, string $tab_context = ''): array {
2252 $field_details = [];
2253
2254 // Return tab-specific validation based on context
2255 switch ($tab_context) {
2256 case 'local-seo':
2257 return $this->get_business_info_validation($settings);
2258 case 'hero-section':
2259 return $this->get_hero_section_validation($settings);
2260 case 'title-formats':
2261 return $this->get_title_formats_validation($settings);
2262 case 'breadcrumbs':
2263 return $this->get_breadcrumbs_validation($settings);
2264 default:
2265 // Default basic info validation
2266 return $this->get_basic_info_validation($settings);
2267 }
2268 }
2269
2270 /**
2271 * Get Business Info specific validation
2272 *
2273 * @since 1.0.0
2274 *
2275 * @param array $settings Settings array to validate
2276 * @return array Business Info validation results
2277 */
2278 private function get_business_info_validation(array $settings): array {
2279 $field_details = [];
2280
2281 // Check if Local SEO is enabled
2282 if (empty($settings['local_seo_enabled'])) {
2283 $field_details[] = [
2284 'field' => 'local_seo_enabled',
2285 'label' => 'Local SEO is disabled. Enable to configure business information.',
2286 'status' => 'warning',
2287 'icon' => '⚠'
2288 ];
2289 return $field_details;
2290 }
2291
2292 // Business Name validation
2293 if (!empty($settings['business_name'])) {
2294 $field_details[] = [
2295 'field' => 'business_name',
2296 'label' => 'Business name is properly configured.',
2297 'status' => 'valid',
2298 'icon' => '✓'
2299 ];
2300 } else {
2301 $field_details[] = [
2302 'field' => 'business_name',
2303 'label' => 'Business name is required for local SEO.',
2304 'status' => 'error',
2305 'icon' => '✗'
2306 ];
2307 }
2308
2309 // Business Type validation — see business_type_status() for why there
2310 // is exactly one rule here now (#622).
2311 $business_type = $this->business_type_status($settings);
2312 $field_details[] = [
2313 'field' => 'business_type',
2314 'label' => $business_type['message'],
2315 'status' => $business_type['status'],
2316 'icon' => 'valid' === $business_type['status'] ? '✓' : '⚠',
2317 ];
2318
2319 // Address validation (NAP consistency)
2320 $address_fields = ['business_address', 'business_city', 'business_state', 'business_country'];
2321 $address_complete = true;
2322 foreach ($address_fields as $field) {
2323 if (empty($settings[$field])) {
2324 $address_complete = false;
2325 break;
2326 }
2327 }
2328
2329 if ($address_complete) {
2330 $field_details[] = [
2331 'field' => 'business_address',
2332 'label' => 'Complete business address is configured for NAP consistency.',
2333 'status' => 'valid',
2334 'icon' => '✓'
2335 ];
2336 } else {
2337 $field_details[] = [
2338 'field' => 'business_address',
2339 'label' => 'Complete address (street, city, state, country) required for local SEO.',
2340 'status' => 'error',
2341 'icon' => '✗'
2342 ];
2343 }
2344
2345 // Phone validation
2346 if (!empty($settings['business_phone'])) {
2347 if ($this->validate_phone_format($settings['business_phone'])) {
2348 $field_details[] = [
2349 'field' => 'business_phone',
2350 'label' => 'Business phone number is properly formatted.',
2351 'status' => 'valid',
2352 'icon' => '✓'
2353 ];
2354 } else {
2355 $field_details[] = [
2356 'field' => 'business_phone',
2357 'label' => 'Business phone number format could be improved.',
2358 'status' => 'warning',
2359 'icon' => '⚠'
2360 ];
2361 }
2362 } else {
2363 $field_details[] = [
2364 'field' => 'business_phone',
2365 'label' => 'Business phone number is important for local SEO and customer contact.',
2366 'status' => 'warning',
2367 'icon' => '⚠'
2368 ];
2369 }
2370
2371 // Email validation
2372 if (!empty($settings['business_email'])) {
2373 if (is_email($settings['business_email'])) {
2374 $field_details[] = [
2375 'field' => 'business_email',
2376 'label' => 'Business email address is valid.',
2377 'status' => 'valid',
2378 'icon' => '✓'
2379 ];
2380 } else {
2381 $field_details[] = [
2382 'field' => 'business_email',
2383 'label' => 'Business email address format is invalid.',
2384 'status' => 'error',
2385 'icon' => '✗'
2386 ];
2387 }
2388 } else {
2389 $field_details[] = [
2390 'field' => 'business_email',
2391 'label' => 'Business email address recommended for contact information.',
2392 'status' => 'suggestion',
2393 'icon' => '⚠'
2394 ];
2395 }
2396
2397 // Coordinates validation
2398 if (!empty($settings['business_latitude']) && !empty($settings['business_longitude'])) {
2399 if ($this->validate_coordinates($settings['business_latitude'], $settings['business_longitude'])) {
2400 $field_details[] = [
2401 'field' => 'business_coordinates',
2402 'label' => 'Business coordinates are properly configured for precise location.',
2403 'status' => 'valid',
2404 'icon' => '✓'
2405 ];
2406 } else {
2407 $field_details[] = [
2408 'field' => 'business_coordinates',
2409 'label' => 'Business coordinates appear to be invalid.',
2410 'status' => 'error',
2411 'icon' => '✗'
2412 ];
2413 }
2414 } else {
2415 $field_details[] = [
2416 'field' => 'business_coordinates',
2417 'label' => 'Business coordinates recommended for precise location targeting.',
2418 'status' => 'suggestion',
2419 'icon' => '⚠'
2420 ];
2421 }
2422
2423 return $field_details;
2424 }
2425
2426 /**
2427 * Get Basic Info validation (default)
2428 *
2429 * @since 1.0.0
2430 *
2431 * @param array $settings Settings array to validate
2432 * @return array Basic Info validation results
2433 */
2434 private function get_basic_info_validation(array $settings): array {
2435 $field_details = [];
2436
2437 // Site Name validation
2438 if (!empty($settings['site_name'])) {
2439 $field_details[] = [
2440 'field' => 'site_name',
2441 'label' => 'Site name is properly configured.',
2442 'status' => 'valid',
2443 'icon' => '✓'
2444 ];
2445 } else {
2446 $field_details[] = [
2447 'field' => 'site_name',
2448 'label' => 'Site name is required.',
2449 'status' => 'error',
2450 'icon' => '✗'
2451 ];
2452 }
2453
2454 // Site Description validation
2455 if (!empty($settings['site_description'])) {
2456 $length = strlen($settings['site_description']);
2457 if ($length >= 120 && $length <= 160) {
2458 $field_details[] = [
2459 'field' => 'site_description',
2460 'label' => 'Site description is properly configured.',
2461 'status' => 'valid',
2462 'icon' => '✓'
2463 ];
2464 } else {
2465 $field_details[] = [
2466 'field' => 'site_description',
2467 'label' => 'Site description length could be optimized (120-160 characters recommended).',
2468 'status' => 'warning',
2469 'icon' => '⚠'
2470 ];
2471 }
2472 } else {
2473 $field_details[] = [
2474 'field' => 'site_description',
2475 'label' => 'Site description is recommended for better SEO.',
2476 'status' => 'warning',
2477 'icon' => '⚠'
2478 ];
2479 }
2480
2481 // Tagline validation
2482 if (!empty($settings['tagline'])) {
2483 $field_details[] = [
2484 'field' => 'tagline',
2485 'label' => 'Site tagline is configured.',
2486 'status' => 'valid',
2487 'icon' => '✓'
2488 ];
2489 } else {
2490 $field_details[] = [
2491 'field' => 'tagline',
2492 'label' => 'Site tagline recommended for better branding.',
2493 'status' => 'suggestion',
2494 'icon' => '⚠'
2495 ];
2496 }
2497
2498 // Default Meta Description validation
2499 if (!empty($settings['default_meta_description'])) {
2500 $length = strlen($settings['default_meta_description']);
2501 if ($length >= 120 && $length <= 160) {
2502 $field_details[] = [
2503 'field' => 'default_meta_description',
2504 'label' => 'Default meta description is properly configured.',
2505 'status' => 'valid',
2506 'icon' => '✓'
2507 ];
2508 } else {
2509 $field_details[] = [
2510 'field' => 'default_meta_description',
2511 'label' => 'Default meta description length could be optimized (120-160 characters recommended).',
2512 'status' => 'warning',
2513 'icon' => '⚠'
2514 ];
2515 }
2516 } else {
2517 $field_details[] = [
2518 'field' => 'default_meta_description',
2519 'label' => 'Default meta description recommended for pages without specific descriptions.',
2520 'status' => 'suggestion',
2521 'icon' => '⚠'
2522 ];
2523 }
2524
2525 return $field_details;
2526 }
2527
2528 /**
2529 * Get Hero Section validation
2530 *
2531 * @since 1.0.0
2532 *
2533 * @param array $settings Settings array to validate
2534 * @return array Hero Section validation results
2535 */
2536 private function get_hero_section_validation(array $settings): array {
2537 $field_details = [];
2538
2539 // Hero Title validation
2540 if (!empty($settings['hero_title'])) {
2541 $field_details[] = [
2542 'field' => 'hero_title',
2543 'label' => 'Hero title is configured.',
2544 'status' => 'valid',
2545 'icon' => '✓'
2546 ];
2547 } else {
2548 $field_details[] = [
2549 'field' => 'hero_title',
2550 'label' => 'Hero title recommended for better homepage presentation.',
2551 'status' => 'suggestion',
2552 'icon' => '⚠'
2553 ];
2554 }
2555
2556 // Hero Subtitle validation (correct field name)
2557 if (!empty($settings['hero_subtitle'])) {
2558 $field_details[] = [
2559 'field' => 'hero_subtitle',
2560 'label' => 'Hero subtitle is configured.',
2561 'status' => 'valid',
2562 'icon' => '✓'
2563 ];
2564 } else {
2565 $field_details[] = [
2566 'field' => 'hero_subtitle',
2567 'label' => 'Hero subtitle recommended for better user engagement.',
2568 'status' => 'suggestion',
2569 'icon' => '⚠'
2570 ];
2571 }
2572
2573 // CTA Text validation
2574 if (!empty($settings['hero_cta_text'])) {
2575 $field_details[] = [
2576 'field' => 'hero_cta_text',
2577 'label' => 'Call-to-action text is configured.',
2578 'status' => 'valid',
2579 'icon' => '✓'
2580 ];
2581 } else {
2582 $field_details[] = [
2583 'field' => 'hero_cta_text',
2584 'label' => 'Call-to-action text recommended for better conversion.',
2585 'status' => 'suggestion',
2586 'icon' => '⚠'
2587 ];
2588 }
2589
2590 // CTA URL validation
2591 if (!empty($settings['hero_cta_url'])) {
2592 if (filter_var($settings['hero_cta_url'], FILTER_VALIDATE_URL) || strpos($settings['hero_cta_url'], '/') === 0) {
2593 $field_details[] = [
2594 'field' => 'hero_cta_url',
2595 'label' => 'Call-to-action URL is properly configured.',
2596 'status' => 'valid',
2597 'icon' => '✓'
2598 ];
2599 } else {
2600 $field_details[] = [
2601 'field' => 'hero_cta_url',
2602 'label' => 'Call-to-action URL format appears invalid.',
2603 'status' => 'warning',
2604 'icon' => '⚠'
2605 ];
2606 }
2607 } else {
2608 $field_details[] = [
2609 'field' => 'hero_cta_url',
2610 'label' => 'Call-to-action URL recommended for better conversion.',
2611 'status' => 'suggestion',
2612 'icon' => '⚠'
2613 ];
2614 }
2615
2616 // Hero Background Image validation
2617 if (!empty($settings['hero_background_image'])) {
2618 $field_details[] = [
2619 'field' => 'hero_background_image',
2620 'label' => 'Hero background image is configured.',
2621 'status' => 'valid',
2622 'icon' => '✓'
2623 ];
2624 } else {
2625 $field_details[] = [
2626 'field' => 'hero_background_image',
2627 'label' => 'Hero background image recommended for visual appeal.',
2628 'status' => 'suggestion',
2629 'icon' => '⚠'
2630 ];
2631 }
2632
2633 // Site Logo validation (from Site Assets section)
2634 if (!empty($settings['logo_url'])) {
2635 if (filter_var($settings['logo_url'], FILTER_VALIDATE_URL)) {
2636 $field_details[] = [
2637 'field' => 'logo_url',
2638 'label' => 'Site logo is properly configured.',
2639 'status' => 'valid',
2640 'icon' => '✓'
2641 ];
2642 } else {
2643 $field_details[] = [
2644 'field' => 'logo_url',
2645 'label' => 'Site logo URL format appears invalid.',
2646 'status' => 'warning',
2647 'icon' => '⚠'
2648 ];
2649 }
2650 } else {
2651 $field_details[] = [
2652 'field' => 'logo_url',
2653 'label' => 'Site logo recommended for branding and schema markup.',
2654 'status' => 'suggestion',
2655 'icon' => '⚠'
2656 ];
2657 }
2658
2659 // Favicon validation
2660 if (!empty($settings['favicon_url'])) {
2661 $field_details[] = [
2662 'field' => 'favicon_url',
2663 'label' => 'Favicon is configured.',
2664 'status' => 'valid',
2665 'icon' => '✓'
2666 ];
2667 } else {
2668 $field_details[] = [
2669 'field' => 'favicon_url',
2670 'label' => 'Favicon recommended for browser tab identification.',
2671 'status' => 'suggestion',
2672 'icon' => '⚠'
2673 ];
2674 }
2675
2676 // Apple Touch Icon validation
2677 if (!empty($settings['apple_touch_icon_url'])) {
2678 $field_details[] = [
2679 'field' => 'apple_touch_icon_url',
2680 'label' => 'Apple touch icon is configured.',
2681 'status' => 'valid',
2682 'icon' => '✓'
2683 ];
2684 } else {
2685 $field_details[] = [
2686 'field' => 'apple_touch_icon_url',
2687 'label' => 'Apple touch icon recommended for iOS devices.',
2688 'status' => 'suggestion',
2689 'icon' => '⚠'
2690 ];
2691 }
2692
2693 return $field_details;
2694 }
2695
2696 /**
2697 * Get Title Formats validation
2698 *
2699 * @since 1.0.0
2700 *
2701 * @param array $settings Settings array to validate
2702 * @return array Title Formats validation results
2703 */
2704 private function get_title_formats_validation(array $settings): array {
2705 $field_details = [];
2706
2707 // Title Separator validation
2708 if (!empty($settings['title_separator'])) {
2709 $field_details[] = [
2710 'field' => 'title_separator',
2711 'label' => 'Title separator is properly configured.',
2712 'status' => 'valid',
2713 'icon' => '✓'
2714 ];
2715 } else {
2716 $field_details[] = [
2717 'field' => 'title_separator',
2718 'label' => 'Title separator is required.',
2719 'status' => 'error',
2720 'icon' => '✗'
2721 ];
2722 }
2723
2724 // Homepage Title validation
2725 if (!empty($settings['homepage_title'])) {
2726 $field_details[] = [
2727 'field' => 'homepage_title',
2728 'label' => 'Homepage title format is configured.',
2729 'status' => 'valid',
2730 'icon' => '✓'
2731 ];
2732 } else {
2733 $field_details[] = [
2734 'field' => 'homepage_title',
2735 'label' => 'Homepage title format recommended.',
2736 'status' => 'suggestion',
2737 'icon' => '⚠'
2738 ];
2739 }
2740
2741 // Post Title validation
2742 if (!empty($settings['post_title'])) {
2743 $field_details[] = [
2744 'field' => 'post_title',
2745 'label' => 'Post title format is configured.',
2746 'status' => 'valid',
2747 'icon' => '✓'
2748 ];
2749 } else {
2750 $field_details[] = [
2751 'field' => 'post_title',
2752 'label' => 'Post title format recommended.',
2753 'status' => 'suggestion',
2754 'icon' => '⚠'
2755 ];
2756 }
2757
2758 // Page Title validation
2759 if (!empty($settings['page_title'])) {
2760 $field_details[] = [
2761 'field' => 'page_title',
2762 'label' => 'Page title format is configured.',
2763 'status' => 'valid',
2764 'icon' => '✓'
2765 ];
2766 } else {
2767 $field_details[] = [
2768 'field' => 'page_title',
2769 'label' => 'Page title format recommended.',
2770 'status' => 'suggestion',
2771 'icon' => '⚠'
2772 ];
2773 }
2774
2775 // Category Title validation
2776 if (!empty($settings['category_title'])) {
2777 $field_details[] = [
2778 'field' => 'category_title',
2779 'label' => 'Category title format is configured.',
2780 'status' => 'valid',
2781 'icon' => '✓'
2782 ];
2783 } else {
2784 $field_details[] = [
2785 'field' => 'category_title',
2786 'label' => 'Category title format recommended.',
2787 'status' => 'suggestion',
2788 'icon' => '⚠'
2789 ];
2790 }
2791
2792 // Search Title validation
2793 if (!empty($settings['search_title'])) {
2794 $field_details[] = [
2795 'field' => 'search_title',
2796 'label' => 'Search title format is configured.',
2797 'status' => 'valid',
2798 'icon' => '✓'
2799 ];
2800 } else {
2801 $field_details[] = [
2802 'field' => 'search_title',
2803 'label' => 'Search title format recommended.',
2804 'status' => 'suggestion',
2805 'icon' => '⚠'
2806 ];
2807 }
2808
2809 return $field_details;
2810 }
2811
2812 /**
2813 * Get Breadcrumbs validation
2814 *
2815 * @since 1.0.0
2816 *
2817 * @param array $settings Settings array to validate
2818 * @return array Breadcrumbs validation results
2819 */
2820 private function get_breadcrumbs_validation(array $settings): array {
2821 $field_details = [];
2822
2823 // Breadcrumbs enabled validation
2824 if (!empty($settings['breadcrumbs_enabled'])) {
2825 $field_details[] = [
2826 'field' => 'breadcrumbs_enabled',
2827 'label' => 'Breadcrumbs are enabled for better navigation.',
2828 'status' => 'valid',
2829 'icon' => '✓'
2830 ];
2831
2832 // Only validate other fields if breadcrumbs are enabled
2833 // Breadcrumb Type validation
2834 if (!empty($settings['breadcrumb_type'])) {
2835 $field_details[] = [
2836 'field' => 'breadcrumb_type',
2837 'label' => 'Breadcrumb type is properly configured.',
2838 'status' => 'valid',
2839 'icon' => '✓'
2840 ];
2841 } else {
2842 $field_details[] = [
2843 'field' => 'breadcrumb_type',
2844 'label' => 'Breadcrumb type selection is required.',
2845 'status' => 'error',
2846 'icon' => '✗'
2847 ];
2848 }
2849
2850 // Home Text validation
2851 if (!empty($settings['breadcrumb_home_text'])) {
2852 $field_details[] = [
2853 'field' => 'breadcrumb_home_text',
2854 'label' => 'Home breadcrumb text is configured.',
2855 'status' => 'valid',
2856 'icon' => '✓'
2857 ];
2858 } else {
2859 $field_details[] = [
2860 'field' => 'breadcrumb_home_text',
2861 'label' => 'Home breadcrumb text recommended for clarity.',
2862 'status' => 'suggestion',
2863 'icon' => '⚠'
2864 ];
2865 }
2866
2867 // Breadcrumb Separator validation
2868 if (!empty($settings['breadcrumb_separator'])) {
2869 $field_details[] = [
2870 'field' => 'breadcrumb_separator',
2871 'label' => 'Breadcrumb separator is configured.',
2872 'status' => 'valid',
2873 'icon' => '✓'
2874 ];
2875 } else {
2876 $field_details[] = [
2877 'field' => 'breadcrumb_separator',
2878 'label' => 'Breadcrumb separator recommended for better formatting.',
2879 'status' => 'suggestion',
2880 'icon' => '⚠'
2881 ];
2882 }
2883
2884 // Breadcrumb Prefix validation (optional)
2885 if (!empty($settings['breadcrumb_prefix'])) {
2886 $field_details[] = [
2887 'field' => 'breadcrumb_prefix',
2888 'label' => 'Breadcrumb prefix is configured.',
2889 'status' => 'valid',
2890 'icon' => '✓'
2891 ];
2892 } else {
2893 $field_details[] = [
2894 'field' => 'breadcrumb_prefix',
2895 'label' => 'Breadcrumb prefix is optional but can improve user guidance.',
2896 'status' => 'suggestion',
2897 'icon' => '⚠'
2898 ];
2899 }
2900
2901 // Show Current Page validation
2902 $field_details[] = [
2903 'field' => 'show_current_page',
2904 'label' => isset($settings['show_current_page']) ?
2905 'Current page display preference is configured.' :
2906 'Current page display preference is set to default.',
2907 'status' => 'valid',
2908 'icon' => '✓'
2909 ];
2910 } else {
2911 $field_details[] = [
2912 'field' => 'breadcrumbs_enabled',
2913 'label' => 'Breadcrumbs recommended for better user experience and SEO.',
2914 'status' => 'suggestion',
2915 'icon' => '⚠'
2916 ];
2917 }
2918
2919 return $field_details;
2920 }
2921
2922 /**
2923 * Validate local SEO settings
2924 *
2925 * @since 1.0.0
2926 *
2927 * @param array $settings Settings array to validate
2928 * @return array Local SEO validation results
2929 */
2930 private function validate_local_seo_settings(array $settings): array {
2931 $validation = [
2932 'valid' => true,
2933 'errors' => [],
2934 'warnings' => [],
2935 'suggestions' => []
2936 ];
2937
2938 // Business name is what makes the LocalBusiness schema useful, but it
2939 // cannot be a blocking error: the toggle is what reveals the business
2940 // fields, so requiring the name up front makes enabling Local SEO
2941 // impossible. The frontend already skips the output while the name is
2942 // empty (see Seo_Manager::output_local_seo_meta_tags()).
2943 if (empty($settings['business_name'])) {
2944 $validation['warnings'][] = 'Business name is missing - required before local business schema is output';
2945 } elseif (strlen($settings['business_name']) > 100) {
2946 $validation['warnings'][] = 'Business name is very long, consider shortening for better display';
2947 }
2948
2949 // Validate business address components (NAP consistency)
2950 $required_address_fields = [
2951 'business_address' => 'Business address',
2952 'business_city' => 'Business city',
2953 'business_state' => 'Business state/province',
2954 'business_country' => 'Business country'
2955 ];
2956
2957 foreach ($required_address_fields as $field => $label) {
2958 if (empty($settings[$field])) {
2959 $validation['warnings'][] = "{$label} is missing - important for NAP consistency and local search";
2960 }
2961 }
2962
2963 // Validate postal code (recommended)
2964 if (empty($settings['business_postal_code'])) {
2965 $validation['suggestions'][] = 'Add postal code for more precise location targeting';
2966 }
2967
2968 // Validate phone number
2969 if (empty($settings['business_phone'])) {
2970 $validation['warnings'][] = 'Business phone number is missing - important for local SEO and customer contact';
2971 } elseif (!$this->validate_phone_format($settings['business_phone'])) {
2972 $validation['suggestions'][] = 'Phone number format could be improved for consistency';
2973 }
2974
2975 // Validate email address
2976 if (!empty($settings['business_email']) && !is_email($settings['business_email'])) {
2977 $validation['errors'][] = 'Business email address format is invalid';
2978 $validation['valid'] = false;
2979 }
2980
2981 // Validate coordinates if provided
2982 if (!empty($settings['business_latitude']) || !empty($settings['business_longitude'])) {
2983 if (empty($settings['business_latitude']) || empty($settings['business_longitude'])) {
2984 $validation['warnings'][] = 'Both latitude and longitude are required for geo-location';
2985 } elseif (!$this->validate_coordinates($settings['business_latitude'], $settings['business_longitude'])) {
2986 $validation['errors'][] = 'Invalid latitude or longitude coordinates';
2987 $validation['valid'] = false;
2988 }
2989 } else {
2990 $validation['suggestions'][] = 'Add latitude and longitude coordinates for precise location targeting';
2991 }
2992
2993 // Validate business hours
2994 if (!empty($settings['business_hours']) && is_array($settings['business_hours'])) {
2995 $hours_validation = $this->validate_business_hours($settings['business_hours']);
2996 if (!$hours_validation['valid']) {
2997 $validation['warnings'] = array_merge($validation['warnings'], $hours_validation['warnings']);
2998 }
2999 } else {
3000 $validation['suggestions'][] = 'Add business hours to improve local search visibility';
3001 }
3002
3003 // Business type, through the shared rule (#622). This is the only place
3004 // it is reported on the generic path: validate_settings() with no tab
3005 // context attaches basic-info field details, not business-info ones, so
3006 // without this the setting would go unreported there entirely.
3007 $business_type = $this->business_type_status($settings);
3008 if ('suggestion' === $business_type['status']) {
3009 $validation['suggestions'][] = $business_type['message'];
3010 }
3011
3012 return $validation;
3013 }
3014
3015 /**
3016 * Validate phone number format
3017 *
3018 * @since 1.0.0
3019 *
3020 * @param string $phone_number Phone number to validate
3021 * @return bool True if format is acceptable
3022 */
3023 private function validate_phone_format(string $phone_number): bool {
3024 // Remove all non-numeric characters except + for international numbers
3025 $cleaned = preg_replace('/[^\d+]/', '', $phone_number);
3026
3027 // Check for common valid formats
3028 return (
3029 preg_match('/^\d{10}$/', $cleaned) || // 10 digits (US)
3030 preg_match('/^1\d{10}$/', $cleaned) || // 1 + 10 digits (US with country code)
3031 preg_match('/^\+\d{7,15}$/', $cleaned) // International format
3032 );
3033 }
3034
3035 /**
3036 * Get output data for frontend rendering (implements interface)
3037 *
3038 * @since 1.0.0
3039 *
3040 * @param string $context_type The context type
3041 * @param int|null $context_id Optional. Context ID
3042 * @return array Output data ready for frontend rendering
3043 */
3044 public function get_output_data(string $context_type, ?int $context_id): array {
3045 $settings = $this->get_settings($context_type, $context_id);
3046
3047 $output = [
3048 'title' => '',
3049 'breadcrumbs' => [],
3050 'identity' => [],
3051 'robots_txt' => [],
3052 'enabled' => $settings['enabled'] ?? true
3053 ];
3054
3055 if (!$output['enabled']) {
3056 return $output;
3057 }
3058
3059 // Generate title for current context
3060 $title_data = $this->extract_title_data($context_type, $context_id);
3061 $output['title'] = $this->generate_title(
3062 $settings['title_template'] ?? 'default',
3063 $title_data,
3064 $context_type
3065 );
3066
3067 // Generate breadcrumbs if enabled
3068 if (!empty($settings['breadcrumbs_enabled'])) {
3069 $breadcrumb_options = [
3070 'context_type' => $context_type,
3071 'context_id' => $context_id
3072 ];
3073 $output['breadcrumbs'] = $this->generate_breadcrumbs(
3074 $settings['breadcrumb_type'] ?? 'hierarchical',
3075 $breadcrumb_options
3076 );
3077 }
3078
3079 // Get site identity data
3080 $output['identity'] = $this->get_site_identity_data($settings);
3081
3082 // Get robots.txt data if enabled
3083 if (!empty($settings['robots_txt_enabled'])) {
3084 $output['robots_txt'] = $this->generate_robots_txt($settings['custom_robots_rules'] ?? []);
3085 }
3086
3087 return $output;
3088 }
3089
3090 /**
3091 * Keys the Site Identity screens store beyond the 16 defaults.
3092 *
3093 * Title formats, breadcrumb configuration, the hero fields, the business
3094 * block and the wizard's identity fields are all real settings written by
3095 * this manager, none of which get_default_settings() names — it seeds only
3096 * the values a fresh install needs. Gating on defaults alone would stop
3097 * every one of them saving (#452).
3098 *
3099 * @since 2.0.1
3100 *
3101 * @return string[]
3102 */
3103 /**
3104 * The stored alternate name(s), shaped for schema output.
3105 *
3106 * schema.org and Google both allow `alternateName` to carry one value or
3107 * several, and the store already round-trips either shape, so this accepts
3108 * both and normalises: null when there is nothing to publish, a bare string
3109 * for one name, a list for more. Emitting a one-element array would be
3110 * valid but noisier than it needs to be.
3111 *
3112 * Shared because both WebSite producers need it and must agree — a property
3113 * added to one and not the other is how #688 happened.
3114 *
3115 * @since 2.7.0
3116 *
3117 * @param mixed $value Stored alternate_name value.
3118 * @return string|string[]|null
3119 */
3120 public static function alternate_name_for_schema($value) {
3121 $names = [];
3122
3123 foreach ((array) $value as $name) {
3124 if (!is_scalar($name)) {
3125 continue;
3126 }
3127
3128 $name = trim((string) $name);
3129
3130 if ('' !== $name && !in_array($name, $names, true)) {
3131 $names[] = $name;
3132 }
3133 }
3134
3135 if (empty($names)) {
3136 return null;
3137 }
3138
3139 return 1 === count($names) ? $names[0] : $names;
3140 }
3141
3142 protected function additional_setting_keys(): array {
3143 return [
3144 // Title formats, one per context.
3145 'homepage_title', 'post_title', 'page_title', 'category_title',
3146 'tag_title', 'author_title', 'search_title', 'archive_title',
3147 // The blog-index homepage's meta description (#897).
3148 'homepage_description',
3149 // Breadcrumbs.
3150 'breadcrumb_prefix', 'show_current_page', 'breadcrumb_use_seo_title',
3151 // Identity, as written by the setup wizard and the importers.
3152 'alternate_name', 'identity_type', 'represents',
3153 'default_meta_description', 'default_social_image',
3154 'social_media_accounts',
3155 // Schema toggles that live on this screen.
3156 'organization_schema', 'knowledge_graph',
3157 // Robots rules composed by the Robots.txt panel.
3158 'custom_robots_rules',
3159 // Per-agent AI crawler allow/block map (#657).
3160 'ai_crawler_rules',
3161 // Hero section.
3162 'hero_title', 'hero_subtitle', 'hero_cta_text', 'hero_cta_url',
3163 'hero_background_image',
3164 // Local SEO / business details.
3165 'local_seo_enabled', 'business_type', 'business_name',
3166 'business_address', 'business_city', 'business_state',
3167 'business_postal_code', 'business_country', 'business_phone',
3168 'business_email', 'business_latitude', 'business_longitude',
3169 'business_price_range', 'business_hours',
3170 ];
3171 }
3172
3173 /**
3174 * Sanitize settings, normalising the AI crawler rule map.
3175 *
3176 * The generic array sanitizer keeps the shape but says nothing about the
3177 * values: a payload could store `ai_crawler_rules[gptbot] = "maybe"`, or a
3178 * slug no crawler answers to, and both would round-trip through every
3179 * later response. Normalising here rather than in the REST handler puts it
3180 * on the one path every writer shares — the settings route, the robots
3181 * route and the MCP abilities all land in save_settings() (#657).
3182 *
3183 * @since 2.5.0
3184 *
3185 * @param array $settings Settings to sanitize.
3186 * @param string $context_type Context type.
3187 * @return array Sanitized settings.
3188 */
3189 protected function sanitize_settings(array $settings, string $context_type = 'site'): array {
3190 $sanitized = parent::sanitize_settings($settings, $context_type);
3191
3192 if (array_key_exists('ai_crawler_rules', $sanitized)) {
3193 $sanitized['ai_crawler_rules'] = AI_Crawlers::normalize_rules($sanitized['ai_crawler_rules']);
3194 }
3195
3196 // Same reasoning one key up, for the scheme override (#638). Anything
3197 // that is not one of the three modes means "follow WordPress", and is
3198 // stored as that rather than kept verbatim — otherwise get-site-identity
3199 // -settings would report a scheme the site does not actually publish.
3200 if (array_key_exists('canonical_scheme', $sanitized)) {
3201 $sanitized['canonical_scheme'] = in_array($sanitized['canonical_scheme'], Url_Scheme::MODES, true)
3202 ? $sanitized['canonical_scheme']
3203 : Url_Scheme::AUTOMATIC;
3204 }
3205
3206 // Same reasoning again for the business type. It goes straight into
3207 // LocalBusiness schema, so a type that is not in the schema.org
3208 // vocabulary is invalid structured data — and storing it verbatim would
3209 // have get-site-identity-settings report a type the site cannot
3210 // actually publish. An empty value keeps meaning "not set"; anything
3211 // else unrecognised falls back to the general-purpose root (#623).
3212 if (array_key_exists('business_type', $sanitized)) {
3213 $type = (string) $sanitized['business_type'];
3214
3215 if ('' !== $type && !\ThinkRank\Config\Local_Business_Types_Config::is_valid($type)) {
3216 $type = \ThinkRank\Config\Local_Business_Types_Config::ROOT;
3217 }
3218
3219 $sanitized['business_type'] = $type;
3220 }
3221
3222 return $sanitized;
3223 }
3224
3225 /**
3226 * schema.org's general-purpose LocalBusiness type.
3227 *
3228 * The default, the first option in the control, and a valid answer in its
3229 * own right — which is the whole point of #622.
3230 *
3231 * @since 2.10.0
3232 * @var string
3233 */
3234 private const GENERAL_BUSINESS_TYPE = 'LocalBusiness';
3235
3236 /**
3237 * The one rule for whether a business type needs the user's attention.
3238 *
3239 * There were three, with two wordings and two different conditions. Two
3240 * fired when the value was empty; the third fired when it WAS
3241 * `LocalBusiness` — which is the default, the first option in the control
3242 * and a perfectly valid schema.org type. So the warning appeared out of the
3243 * box for every site, could not be cleared without choosing a type that
3244 * might be inaccurate, and on an empty value it appeared three times in two
3245 * different phrasings, which is why it was reported as showing twice (#622).
3246 *
3247 * The rule now: a type is expected, and any type in the vocabulary is a
3248 * correct answer. Only an unset value is worth prompting about.
3249 * `LocalBusiness` is the general-purpose answer and is accepted as one —
3250 * with a note that a more specific type sharpens the schema, phrased as the
3251 * guidance it is rather than as a fault the user has to clear.
3252 *
3253 * @since 2.10.0
3254 *
3255 * @param array $settings Site identity settings.
3256 * @return array{status:string,message:string} `valid` or `suggestion`.
3257 */
3258 private function business_type_status(array $settings): array {
3259 $type = trim((string) ($settings['business_type'] ?? ''));
3260
3261 if ('' === $type) {
3262 return [
3263 'status' => 'suggestion',
3264 'message' => __('Select a business type so your local schema describes the right kind of business.', 'thinkrank'),
3265 ];
3266 }
3267
3268 // The literal rather than a constant from the expanded type list (#623):
3269 // that lands on its own branch, and this fix must not wait on it.
3270 if (self::GENERAL_BUSINESS_TYPE === $type) {
3271 return [
3272 'status' => 'valid',
3273 'message' => __('Business type is set to Local Business. A more specific type sharpens your schema, if one fits.', 'thinkrank'),
3274 ];
3275 }
3276
3277 return [
3278 'status' => 'valid',
3279 'message' => __('Business type is selected for proper schema markup.', 'thinkrank'),
3280 ];
3281 }
3282
3283 /**
3284 * Get default settings for a context type (implements interface)
3285 *
3286 * @since 1.0.0
3287 *
3288 * @param string $context_type The context type to get defaults for
3289 * @return array Default settings array
3290 */
3291 public function get_default_settings(string $context_type): array {
3292 $defaults = [
3293 'enabled' => true,
3294 'title_template' => 'default',
3295 'title_separator' => 'pipe',
3296 'site_name' => get_bloginfo('name'),
3297 'site_description' => get_bloginfo('description'),
3298 'tagline' => get_bloginfo('description'),
3299 'breadcrumbs_enabled' => true,
3300 'breadcrumb_type' => 'hierarchical',
3301 'breadcrumb_home_text' => 'Home',
3302 'breadcrumb_separator' => '>',
3303 'robots_txt_enabled' => true,
3304 'allow_search_engines' => true,
3305 // Answer 404 when a content selector in the URL resolved to
3306 // nothing (#634). On by default, unlike the other new settings
3307 // here: it changes no URL a visitor or a correct crawler uses, only
3308 // ones where WordPress resolved nothing and served the blog listing
3309 // at 200 anyway.
3310 'query_protection' => true,
3311
3312 // Feed controls (#635). All three off, so an upgrade changes
3313 // nothing about what an existing site already sends its
3314 // subscribers; a brand-new install is seeded with the signature and
3315 // the noindex on, in Activator::seed_feed_defaults().
3316 'feed_excerpt_only' => false,
3317 'feed_source_link' => false,
3318 'feed_noindex' => false,
3319
3320 // The scheme self-referential URLs go out with (#638). 'automatic'
3321 // means substitute nothing and follow WordPress, which is what
3322 // every site did before the setting existed.
3323 'canonical_scheme' => Url_Scheme::AUTOMATIC,
3324 'robots_txt_content' => '',
3325 // Empty map = every AI crawler allowed. Defaults must stay
3326 // permissive so an upgrade never starts blocking a crawler a site
3327 // was happily serving (#657).
3328 'ai_crawler_rules' => [],
3329 'logo_url' => '',
3330 'favicon_url' => '',
3331 'apple_touch_icon_url' => ''
3332 ];
3333
3334 // Context-specific defaults
3335 switch ($context_type) {
3336 case 'site':
3337 // Site-wide defaults are already set above
3338 break;
3339 case 'post':
3340 $defaults['title_template'] = 'default';
3341 $defaults['breadcrumb_type'] = 'taxonomy';
3342 break;
3343 case 'page':
3344 $defaults['title_template'] = 'default';
3345 $defaults['breadcrumb_type'] = 'hierarchical';
3346 break;
3347 case 'product':
3348 $defaults['title_template'] = 'category';
3349 $defaults['breadcrumb_type'] = 'taxonomy';
3350 break;
3351 }
3352
3353 return $defaults;
3354 }
3355
3356 /**
3357 * Get settings schema definition (implements interface)
3358 *
3359 * @since 1.0.0
3360 *
3361 * @param string $context_type The context type to get schema for
3362 * @return array Settings schema definition
3363 */
3364 public function get_settings_schema(string $context_type): array {
3365 return [
3366 'enabled' => [
3367 'type' => 'boolean',
3368 'title' => 'Enable Site Identity',
3369 'description' => 'Enable site identity management features',
3370 'default' => true
3371 ],
3372 'title_template' => [
3373 'type' => 'string',
3374 'title' => 'Title Template',
3375 'description' => 'Template for generating page titles',
3376 'enum' => array_keys($this->title_templates),
3377 'default' => 'default'
3378 ],
3379 'title_separator' => [
3380 'type' => 'string',
3381 'title' => 'Title Separator',
3382 'description' => 'Character used to separate title elements',
3383 'enum' => array_keys(self::$title_separators),
3384 'default' => 'pipe'
3385 ],
3386 'site_name' => [
3387 'type' => 'string',
3388 'title' => 'Site Name',
3389 'description' => 'Official name of the website',
3390 'maxLength' => 60,
3391 'default' => get_bloginfo('name')
3392 ],
3393 'site_description' => [
3394 'type' => 'string',
3395 'title' => 'Site Description',
3396 'description' => 'Brief description of the website',
3397 'maxLength' => 160,
3398 'default' => get_bloginfo('description')
3399 ],
3400 'breadcrumbs_enabled' => [
3401 'type' => 'boolean',
3402 'title' => 'Enable Breadcrumbs',
3403 'description' => 'Enable breadcrumb navigation generation',
3404 'default' => true
3405 ],
3406 'breadcrumb_type' => [
3407 'type' => 'string',
3408 'title' => 'Breadcrumb Type',
3409 'description' => 'Type of breadcrumb navigation to generate',
3410 'enum' => array_keys($this->breadcrumb_types),
3411 'default' => 'hierarchical'
3412 ],
3413 'robots_txt_enabled' => [
3414 'type' => 'boolean',
3415 'title' => 'Enable Robots.txt Management',
3416 'description' => 'Enable automatic robots.txt generation and management',
3417 'default' => true
3418 ],
3419 'logo_url' => [
3420 'type' => 'string',
3421 'title' => 'Logo URL',
3422 'description' => 'URL of the site logo image',
3423 'format' => 'uri',
3424 'default' => ''
3425 ],
3426 'favicon_url' => [
3427 'type' => 'string',
3428 'title' => 'Favicon URL',
3429 'description' => 'URL of the site favicon',
3430 'format' => 'uri',
3431 'default' => ''
3432 ]
3433 ];
3434 }
3435
3436 /**
3437 * Prepare title placeholders for replacement
3438 *
3439 * @since 1.0.0
3440 *
3441 * @param array $data Content data
3442 * @param string $context Context type
3443 * @param array $settings Site settings
3444 * @return array Placeholder values
3445 */
3446 private function prepare_title_placeholders(array $data, string $context, array $settings): array {
3447 $placeholders = [
3448 '%title%' => $data['title'] ?? '',
3449 // `?:` rather than `??`: these are persisted as '' rather than left
3450 // unset, and '' is not null, so the null-coalesce never reached the
3451 // WordPress fallback (#398).
3452 '%sitename%' => ($settings['site_name'] ?? '') ?: get_bloginfo('name'),
3453 '%tagline%' => ($settings['tagline'] ?? '') ?: get_bloginfo('description'),
3454 '%separator%' => '', // Will be replaced with actual separator
3455 '%category%' => '',
3456 '%author%' => '',
3457 '%date%' => '',
3458 '%searchterm%' => ''
3459 ];
3460
3461 // Context-specific placeholders
3462 switch ($context) {
3463 case 'post':
3464 case 'page':
3465 case 'product':
3466 if (!empty($data['context_id'])) {
3467 $post = get_post($data['context_id']);
3468 if ($post) {
3469 $placeholders['%title%'] = get_the_title($post);
3470 $placeholders['%author%'] = get_the_author_meta('display_name', $post->post_author);
3471 $placeholders['%date%'] = get_the_date('F j, Y', $post);
3472
3473 // Get primary category
3474 $categories = get_the_category($post->ID);
3475 if (!empty($categories)) {
3476 $placeholders['%category%'] = $categories[0]->name;
3477 }
3478 }
3479 }
3480 break;
3481 case 'search':
3482 $placeholders['%searchterm%'] = get_search_query();
3483 break;
3484 }
3485
3486 return $placeholders;
3487 }
3488
3489 /**
3490 * Replace title placeholders with actual values
3491 *
3492 * @since 1.0.0
3493 *
3494 * @param string $template Title template
3495 * @param array $placeholders Placeholder values
3496 * @param string $separator Title separator
3497 * @return string Processed title
3498 */
3499 private function replace_title_placeholders(string $template, array $placeholders, string $separator): string {
3500 // Replace separator placeholder
3501 $placeholders['%separator%'] = $separator;
3502
3503 // Replace all placeholders
3504 $title = str_replace(array_keys($placeholders), array_values($placeholders), $template);
3505
3506 // Clean up empty placeholders and extra separators
3507 $title = preg_replace('/\s*' . preg_quote($separator, '/') . '\s*' . preg_quote($separator, '/') . '\s*/', ' ' . $separator . ' ', $title);
3508 $title = preg_replace('/^\s*' . preg_quote($separator, '/') . '\s*|\s*' . preg_quote($separator, '/') . '\s*$/', '', $title);
3509
3510 return trim($title);
3511 }
3512
3513 /**
3514 * Get title separator symbol
3515 *
3516 * @since 1.0.0
3517 *
3518 * @param string $separator_key Separator key
3519 * @return string Separator symbol
3520 */
3521 private function get_title_separator(string $separator_key): string {
3522 return self::$title_separators[$separator_key]['symbol'] ?? self::$title_separators['pipe']['symbol'];
3523 }
3524
3525 /**
3526 * Optimize title for SEO
3527 *
3528 * @since 1.0.0
3529 *
3530 * @param string $title Title to optimize
3531 * @param string $context Context type
3532 * @return string Optimized title
3533 */
3534 private function optimize_title(string $title, string $context): string {
3535 // Remove extra whitespace
3536 $title = preg_replace('/\s+/', ' ', $title);
3537 $title = trim($title);
3538
3539 // Ensure title is not too long (60 characters max for SEO).
3540 // All three units here were wrong for non-Latin text: strlen() counts
3541 // BYTES so the gate fired at 20 Thai characters, wp_trim_words() counts
3542 // CHARACTERS on th/ja/zh_* so `8` cut the title to 8 of them, and
3543 // substr() cuts bytes so it split a character mid-sequence (#687).
3544 $title = \ThinkRank\Core\Seo_Text::trim_to_length(
3545 $title,
3546 \ThinkRank\Core\Seo_Text::TITLE_MAX_LENGTH
3547 );
3548
3549 // Ensure title is not empty
3550 if (empty($title)) {
3551 $title = get_bloginfo('name');
3552 }
3553
3554 return $title;
3555 }
3556
3557 /**
3558 * Extract title data from context
3559 *
3560 * @since 1.0.0
3561 *
3562 * @param string $context_type Context type
3563 * @param int|null $context_id Context ID
3564 * @return array Title data
3565 */
3566 private function extract_title_data(string $context_type, ?int $context_id): array {
3567 $data = [
3568 'title' => '',
3569 'context_type' => $context_type,
3570 'context_id' => $context_id
3571 ];
3572
3573 switch ($context_type) {
3574 case 'site':
3575 $data['title'] = get_bloginfo('name');
3576 break;
3577 case 'post':
3578 case 'page':
3579 case 'product':
3580 if ($context_id) {
3581 $data['title'] = get_the_title($context_id);
3582 }
3583 break;
3584 case 'search':
3585 $data['title'] = 'Search Results';
3586 break;
3587 case '404':
3588 $data['title'] = 'Page Not Found';
3589 break;
3590 }
3591
3592 return $data;
3593 }
3594
3595 /**
3596 * Generate hierarchical breadcrumbs
3597 *
3598 * @since 1.0.0
3599 *
3600 * @param array $options Breadcrumb options
3601 * @return array Breadcrumb items
3602 */
3603 private function generate_hierarchical_breadcrumbs(array $options): array {
3604 $breadcrumbs = [];
3605
3606 // Add home breadcrumb
3607 $breadcrumbs[] = [
3608 'title' => 'Home',
3609 'url' => home_url(),
3610 'position' => 1
3611 ];
3612
3613 $context_type = $options['context_type'] ?? '';
3614 $context_id = $options['context_id'] ?? null;
3615
3616 if ($context_type === 'post' || $context_type === 'page' || $context_type === 'product') {
3617 if ($context_id) {
3618 $post = get_post($context_id);
3619 if ($post) {
3620 // Add parent pages for hierarchical content
3621 $ancestors = get_post_ancestors($post);
3622 $ancestors = array_reverse($ancestors);
3623
3624 $position = 2;
3625 foreach ($ancestors as $ancestor_id) {
3626 $breadcrumbs[] = [
3627 'title' => get_the_title($ancestor_id),
3628 'url' => get_permalink($ancestor_id),
3629 'position' => $position++
3630 ];
3631 }
3632
3633 // Add current page
3634 $breadcrumbs[] = [
3635 'title' => get_the_title($post),
3636 'url' => get_permalink($post),
3637 'position' => $position,
3638 'current' => true
3639 ];
3640 }
3641 }
3642 }
3643
3644 return $breadcrumbs;
3645 }
3646
3647 /**
3648 * Generate taxonomy-based breadcrumbs
3649 *
3650 * @since 1.0.0
3651 *
3652 * @param array $options Breadcrumb options
3653 * @return array Breadcrumb items
3654 */
3655 private function generate_taxonomy_breadcrumbs(array $options): array {
3656 $breadcrumbs = [];
3657
3658 // Add home breadcrumb
3659 $breadcrumbs[] = [
3660 'title' => 'Home',
3661 'url' => home_url(),
3662 'position' => 1
3663 ];
3664
3665 $context_type = $options['context_type'] ?? '';
3666 $context_id = $options['context_id'] ?? null;
3667
3668 if (($context_type === 'post' || $context_type === 'product') && $context_id) {
3669 $post = get_post($context_id);
3670 if ($post) {
3671 // Get primary category
3672 $categories = get_the_category($post->ID);
3673 if (!empty($categories)) {
3674 $primary_category = $categories[0];
3675
3676 // Add category hierarchy
3677 $category_ancestors = get_ancestors($primary_category->term_id, 'category');
3678 $category_ancestors = array_reverse($category_ancestors);
3679
3680 $position = 2;
3681 foreach ($category_ancestors as $ancestor_id) {
3682 $ancestor = get_category($ancestor_id);
3683 $breadcrumbs[] = [
3684 'title' => $ancestor->name,
3685 'url' => get_category_link($ancestor_id),
3686 'position' => $position++
3687 ];
3688 }
3689
3690 // Add primary category
3691 $breadcrumbs[] = [
3692 'title' => $primary_category->name,
3693 'url' => get_category_link($primary_category->term_id),
3694 'position' => $position++
3695 ];
3696 }
3697
3698 // Add current post
3699 $breadcrumbs[] = [
3700 'title' => get_the_title($post),
3701 'url' => get_permalink($post),
3702 'position' => $position,
3703 'current' => true
3704 ];
3705 }
3706 }
3707
3708 return $breadcrumbs;
3709 }
3710
3711 /**
3712 * Generate path-based breadcrumbs
3713 *
3714 * @since 1.0.0
3715 *
3716 * @param array $options Breadcrumb options
3717 * @return array Breadcrumb items
3718 */
3719 private function generate_path_breadcrumbs(array $options): array {
3720 $breadcrumbs = [];
3721
3722 // Add home breadcrumb
3723 $breadcrumbs[] = [
3724 'title' => 'Home',
3725 'url' => home_url(),
3726 'position' => 1
3727 ];
3728
3729 // Get current URL path
3730 $current_url = home_url(add_query_arg([]));
3731 $path = wp_parse_url($current_url, PHP_URL_PATH);
3732 $path_parts = array_filter(explode('/', trim($path, '/')));
3733
3734 $position = 2;
3735 $cumulative_path = '';
3736
3737 foreach ($path_parts as $part) {
3738 $cumulative_path .= '/' . $part;
3739 $url = home_url($cumulative_path);
3740
3741 // Try to get a meaningful title
3742 $title = ucwords(str_replace(['-', '_'], ' ', $part));
3743
3744 $breadcrumbs[] = [
3745 'title' => $title,
3746 'url' => $url,
3747 'position' => $position++,
3748 'current' => $cumulative_path === $path
3749 ];
3750 }
3751
3752 return $breadcrumbs;
3753 }
3754
3755 /**
3756 * Generate custom breadcrumbs
3757 *
3758 * @since 1.0.0
3759 *
3760 * @param array $options Breadcrumb options
3761 * @return array Breadcrumb items
3762 */
3763 private function generate_custom_breadcrumbs(array $options): array {
3764 // Return custom breadcrumbs if provided in options
3765 return $options['custom_breadcrumbs'] ?? [];
3766 }
3767
3768 /**
3769 * Generate breadcrumb schema markup
3770 *
3771 * @since 1.0.0
3772 *
3773 * @param array $breadcrumb_items Breadcrumb items
3774 * @return array Schema markup
3775 */
3776 private function generate_breadcrumb_schema(array $breadcrumb_items): array {
3777 $schema = [
3778 '@context' => 'https://schema.org',
3779 '@type' => 'BreadcrumbList',
3780 'itemListElement' => []
3781 ];
3782
3783 foreach ($breadcrumb_items as $item) {
3784 $schema['itemListElement'][] = [
3785 '@type' => 'ListItem',
3786 'position' => $item['position'],
3787 'name' => $item['title'],
3788 'item' => $item['url']
3789 ];
3790 }
3791
3792 return $schema;
3793 }
3794
3795 /**
3796 * Generate breadcrumb HTML
3797 *
3798 * @since 1.0.0
3799 *
3800 * @param array $breadcrumb_items Breadcrumb items
3801 * @param array $settings Breadcrumb settings
3802 * @return string HTML output
3803 */
3804 private function generate_breadcrumb_html(array $breadcrumb_items, array $settings): string {
3805 if (empty($breadcrumb_items)) {
3806 return '';
3807 }
3808
3809 $separator = $settings['separator'] ?? '>';
3810 $html = '<nav class="thinkrank-breadcrumbs" aria-label="Breadcrumb">';
3811 $html .= '<ol class="breadcrumb-list">';
3812
3813 foreach ($breadcrumb_items as $item) {
3814 $html .= '<li class="breadcrumb-item">';
3815
3816 if (!empty($item['current'])) {
3817 $html .= '<span class="breadcrumb-current" aria-current="page">' . esc_html($item['title']) . '</span>';
3818 } else {
3819 $html .= '<a href="' . esc_url($item['url']) . '">' . esc_html($item['title']) . '</a>';
3820 }
3821
3822 if ($item['position'] < count($breadcrumb_items)) {
3823 $html .= ' <span class="breadcrumb-separator">' . esc_html($separator) . '</span> ';
3824 }
3825
3826 $html .= '</li>';
3827 }
3828
3829 $html .= '</ol>';
3830 $html .= '</nav>';
3831
3832 return $html;
3833 }
3834
3835 /**
3836 * Generate default robots.txt rules
3837 *
3838 * @since 1.0.0
3839 *
3840 * @param array $settings Robots.txt settings
3841 * @return array Default rules
3842 */
3843 private function generate_default_robots_rules(array $settings): array {
3844 $rules = [];
3845
3846 // Full block: when the admin turns off "Allow Search Engines" or enables
3847 // WordPress's "Discourage search engines" (Settings → Reading, stored as
3848 // blog_public=0), serve a robots.txt that disallows everything rather
3849 // than the default per-path rules — otherwise the toggle has no effect.
3850 $allow_search = $settings['allow_search_engines'] ?? true;
3851 if (empty($allow_search) || !get_option('blog_public')) {
3852 $rules[] = ['directive' => 'user_agent', 'value' => '*'];
3853 $rules[] = ['directive' => 'disallow', 'value' => '/'];
3854 return $rules;
3855 }
3856
3857 // Default user agent rule
3858 $rules[] = [
3859 'directive' => 'user_agent',
3860 'value' => '*'
3861 ];
3862
3863 // WordPress core disallows.
3864 //
3865 // Deliberately minimal, matching Yoast/Rank Math defaults. We do NOT
3866 // block /wp-includes/, /wp-content/plugins/, or /wp-content/themes/:
3867 // those paths serve the CSS and JS Google must fetch to render pages,
3868 // and blocking them causes "blocked resource" warnings and can hurt
3869 // rankings. /wp-json/ is left crawlable for the same reason (embeds,
3870 // oEmbed, structured previews). Only wp-admin (bar admin-ajax) and the
3871 // handful of non-content endpoints below are disallowed.
3872 $default_disallows = [
3873 '/wp-admin/',
3874 '/xmlrpc.php',
3875 '/readme.html',
3876 '/license.txt',
3877 ];
3878
3879 // WooCommerce: keep cart/checkout/account and add-to-cart query URLs out
3880 // of the index to avoid crawl noise and duplicate/session URLs (parity
3881 // with Rank Math's WooCommerce robots defaults).
3882 if (class_exists('WooCommerce')) {
3883 $default_disallows[] = '/cart/';
3884 $default_disallows[] = '/checkout/';
3885 $default_disallows[] = '/my-account/';
3886 $default_disallows[] = '/*add-to-cart=*';
3887 }
3888
3889 foreach ($default_disallows as $disallow) {
3890 $rules[] = [
3891 'directive' => 'disallow',
3892 'value' => $disallow
3893 ];
3894 }
3895
3896 // Allow specific files
3897 $default_allows = [
3898 '/wp-admin/admin-ajax.php',
3899 '/wp-content/uploads/'
3900 ];
3901
3902 foreach ($default_allows as $allow) {
3903 $rules[] = [
3904 'directive' => 'allow',
3905 'value' => $allow
3906 ];
3907 }
3908
3909 // Add sitemap URLs from sitemap settings (auto-sync)
3910 $sitemap_urls = $this->get_sitemap_urls_for_robots();
3911
3912 foreach ($sitemap_urls as $sitemap_url) {
3913 if (!empty($sitemap_url)) {
3914 $rules[] = [
3915 'directive' => 'sitemap',
3916 'value' => $sitemap_url
3917 ];
3918 }
3919 }
3920
3921 // Add crawl delay if specified
3922 if (!empty($settings['crawl_delay'])) {
3923 $rules[] = [
3924 'directive' => 'crawl_delay',
3925 'value' => (int) $settings['crawl_delay']
3926 ];
3927 }
3928
3929 return $rules;
3930 }
3931
3932 /**
3933 * Get sitemap URLs from sitemap settings for robots.txt integration
3934 *
3935 * @since 1.0.0
3936 * @return array Array of sitemap URLs
3937 */
3938 private function get_sitemap_urls_for_robots(): array {
3939 // One wrapper over every return path below, including the #104 extras.
3940 // The Sitemap: line is the only absolute URL of ours in robots.txt and
3941 // the one a crawler follows to find everything else, so it has to carry
3942 // the site's scheme preference (#638). Applied here rather than where
3943 // the body is assembled, because that path also renders a robots.txt a
3944 // site owner typed themselves, and their text is not ours to rewrite.
3945 return array_map(
3946 static function (string $url): string {
3947 return Url_Scheme::apply($url);
3948 },
3949 $this->collect_sitemap_urls_for_robots()
3950 );
3951 }
3952
3953 /**
3954 * The sitemap URLs robots.txt advertises, before the scheme preference.
3955 *
3956 * @since 1.0.0
3957 * @return array Array of sitemap URLs
3958 */
3959 private function collect_sitemap_urls_for_robots(): array {
3960 try {
3961 // Get sitemap settings
3962 $sitemap_generator = new \ThinkRank\SEO\Sitemap_Generator();
3963 $sitemap_settings = $sitemap_generator->get_settings('site');
3964
3965 // If sitemap is disabled, return default
3966 if (empty($sitemap_settings['enabled'])) {
3967 return [home_url('/sitemap.xml')];
3968 }
3969
3970 $sitemap_urls = [];
3971 $site_url = home_url();
3972
3973 // Extract enabled sitemap URLs. When the index is enabled it is the
3974 // only entry worth advertising: every child sitemap is already
3975 // listed inside it, so naming them again in robots.txt is pure
3976 // redundancy and drifts out of date as soon as a post type is added.
3977 $index_url = '';
3978 if (!empty($sitemap_settings['sitemap_urls']) && is_array($sitemap_settings['sitemap_urls'])) {
3979 foreach ($sitemap_settings['sitemap_urls'] as $sitemap) {
3980 if (empty($sitemap['enabled']) || empty($sitemap['url'])) {
3981 continue;
3982 }
3983
3984 if (($sitemap['type'] ?? '') === 'index') {
3985 $index_url = $site_url . $sitemap['url'];
3986 continue;
3987 }
3988
3989 $sitemap_urls[] = $site_url . $sitemap['url'];
3990 }
3991 }
3992
3993 if ($index_url !== '') {
3994 // The index covers the children and, on a segmented install,
3995 // the local business sitemap too.
3996 //
3997 // It does not cover a sitemap contributed through
3998 // `thinkrank_additional_sitemaps`: the index is built by this
3999 // plugin's own generator and never lists them. Returning the
4000 // index alone therefore left a contributed sitemap with no
4001 // discovery path at all — absent from robots.txt and absent
4002 // from the index — so Pro's News sitemap was unreachable on any
4003 // install with the index enabled, which is the default (#835).
4004 $contributed = [];
4005
4006 foreach (\ThinkRank\SEO\Sitemap_Generator::additional_sitemaps() as $path) {
4007 $url = home_url($path);
4008
4009 if ($url !== $index_url && !in_array($url, $contributed, true)) {
4010 $contributed[] = $url;
4011 }
4012 }
4013
4014 return array_merge([$index_url], $contributed);
4015 }
4016
4017 // Fallback to default if no URLs found
4018 if (empty($sitemap_urls)) {
4019 $sitemap_urls[] = home_url('/sitemap.xml');
4020 }
4021
4022 // No index on this install, so anything not already listed above has
4023 // no other discovery path — advertise it directly. The local
4024 // business sitemap and the sitemaps other plugins register both land
4025 // here for the same reason, so they go through one list (#104).
4026 $extra = [];
4027
4028 // Not a file test. Under dynamic delivery the local sitemap is
4029 // served from PHP and no file is ever written, so file_exists()
4030 // silently dropped a sitemap the site really does publish (#752).
4031 // On static sites the file is still what proves it, so both count.
4032 $local_sitemap_published = file_exists(ABSPATH . 'local-sitemap.xml');
4033
4034 if (!$local_sitemap_published && class_exists('ThinkRank\\SEO\\Sitemap_Generator')) {
4035 $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
4036
4037 $local_sitemap_published = 'dynamic' === $generator->resolve_delivery_mode()
4038 && $generator->publishes_local_sitemap();
4039 }
4040
4041 if ($local_sitemap_published) {
4042 $extra[] = '/local-sitemap.xml';
4043 }
4044
4045 foreach (\ThinkRank\SEO\Sitemap_Generator::additional_sitemaps() as $path) {
4046 $extra[] = $path;
4047 }
4048
4049 foreach ($extra as $path) {
4050 $url = home_url($path);
4051 if (!in_array($url, $sitemap_urls, true)) {
4052 $sitemap_urls[] = $url;
4053 }
4054 }
4055
4056 return $sitemap_urls;
4057 } catch (\Exception $e) {
4058 // Fallback to default on error
4059 return [home_url('/sitemap.xml')];
4060 }
4061 }
4062
4063 /**
4064 * Validate robots.txt rules
4065 *
4066 * @since 1.0.0
4067 *
4068 * @param array $rules Rules to validate
4069 * @return array Validation results
4070 */
4071 private function validate_robots_rules(array $rules): array {
4072 $validation = [
4073 'valid' => true,
4074 'errors' => [],
4075 'warnings' => [],
4076 'suggestions' => []
4077 ];
4078
4079 $has_user_agent = false;
4080
4081 foreach ($rules as $rule) {
4082 $directive = $rule['directive'] ?? '';
4083 $value = $rule['value'] ?? '';
4084
4085 // Check if directive is valid
4086 if (!isset($this->robots_directives[$directive])) {
4087 $validation['errors'][] = "Unknown robots.txt directive: {$directive}";
4088 $validation['valid'] = false;
4089 continue;
4090 }
4091
4092 // Check for required user-agent
4093 if ($directive === 'user_agent') {
4094 $has_user_agent = true;
4095 }
4096
4097 // Validate directive-specific rules
4098 switch ($directive) {
4099 case 'disallow':
4100 case 'allow':
4101 if (!str_starts_with($value, '/')) {
4102 $validation['warnings'][] = "Path '{$value}' should start with '/'";
4103 }
4104 break;
4105 case 'sitemap':
4106 if (!filter_var($value, FILTER_VALIDATE_URL)) {
4107 $validation['errors'][] = "Invalid sitemap URL: {$value}";
4108 $validation['valid'] = false;
4109 }
4110 break;
4111 case 'crawl_delay':
4112 if (!is_numeric($value) || $value < 0) {
4113 $validation['errors'][] = "Crawl delay must be a positive number";
4114 $validation['valid'] = false;
4115 }
4116 break;
4117 }
4118 }
4119
4120 if (!$has_user_agent) {
4121 $validation['errors'][] = 'robots.txt must include at least one User-agent directive';
4122 $validation['valid'] = false;
4123 }
4124
4125 return $validation;
4126 }
4127
4128 /**
4129 * Build robots.txt content from rules
4130 *
4131 * @since 1.0.0
4132 *
4133 * @param array $rules Robots.txt rules
4134 * @return string Robots.txt content
4135 */
4136 private function build_robots_txt_content(array $rules): string {
4137 // Body only — no header. The "# generated by ThinkRank SEO" + timestamp
4138 // block is added at render time (see robots_txt_header), so it never
4139 // gets baked into the stored/editable content and can't show a stale
4140 // timestamp on every update.
4141 $content = '';
4142
4143 $current_user_agent = '';
4144 $sitemap_started = false;
4145
4146 foreach ($rules as $rule) {
4147 $directive = $rule['directive'] ?? '';
4148 $value = $rule['value'] ?? '';
4149
4150 switch ($directive) {
4151 case 'user_agent':
4152 if ($current_user_agent !== $value) {
4153 $content .= "\nUser-agent: {$value}\n";
4154 $current_user_agent = $value;
4155 }
4156 break;
4157 case 'disallow':
4158 $content .= "Disallow: {$value}\n";
4159 break;
4160 case 'allow':
4161 $content .= "Allow: {$value}\n";
4162 break;
4163 case 'crawl_delay':
4164 $content .= "Crawl-delay: {$value}\n";
4165 break;
4166 case 'sitemap':
4167 // One blank line separates the Sitemap block from the
4168 // preceding group, and none appear inside it. A blank line
4169 // terminates a record in the robots.txt grammar, so putting
4170 // one between every directive was invalid formatting.
4171 if (!$sitemap_started) {
4172 $content .= "\n";
4173 $sitemap_started = true;
4174 }
4175 $content .= "Sitemap: {$value}\n";
4176 break;
4177 }
4178 }
4179
4180 return ltrim($content, "\n");
4181 }
4182
4183 /**
4184 * Parse a robots.txt body back into the {directive, value} rule shape.
4185 *
4186 * generate_robots_txt() returns `rules` alongside `content`, but callers
4187 * replace `content` with the body actually being served (a stored override
4188 * or a physical file). The generated rules then described something the
4189 * response no longer contained. Re-deriving them from the served body keeps
4190 * the two halves of the payload describing the same document.
4191 *
4192 * @since 2.0.1
4193 *
4194 * @param string $content Robots.txt body (header optional).
4195 * @return array<int, array{directive: string, value: string}> Parsed rules.
4196 */
4197 public function parse_robots_txt_rules(string $content): array {
4198 $map = [
4199 'user-agent' => 'user_agent',
4200 'disallow' => 'disallow',
4201 'allow' => 'allow',
4202 'crawl-delay' => 'crawl_delay',
4203 'sitemap' => 'sitemap',
4204 ];
4205
4206 $rules = [];
4207
4208 foreach (preg_split('/\r\n|\r|\n/', $this->strip_robots_header($content)) as $line) {
4209 $line = trim($line);
4210
4211 // Blank lines separate groups and `#` starts a comment; neither is
4212 // a rule.
4213 if ($line === '' || str_starts_with($line, '#')) {
4214 continue;
4215 }
4216
4217 $parts = explode(':', $line, 2);
4218 if (count($parts) !== 2) {
4219 continue;
4220 }
4221
4222 $field = strtolower(trim($parts[0]));
4223 if (!isset($map[$field])) {
4224 continue;
4225 }
4226
4227 $rules[] = [
4228 'directive' => $map[$field],
4229 // Sitemap values are absolute URLs and contain the `:` the
4230 // limited explode above deliberately preserved.
4231 'value' => trim($parts[1]),
4232 ];
4233 }
4234
4235 return $rules;
4236 }
4237
4238 /**
4239 * Opening fence of the machine-owned AI crawler region.
4240 *
4241 * @since 2.5.0
4242 * @var string
4243 */
4244 public const AI_BLOCK_BEGIN = '# BEGIN ThinkRank AI crawlers';
4245
4246 /**
4247 * Closing fence of the machine-owned AI crawler region.
4248 *
4249 * @since 2.5.0
4250 * @var string
4251 */
4252 public const AI_BLOCK_END = '# END ThinkRank AI crawlers';
4253
4254 /**
4255 * Render the fenced AI crawler region for the current settings.
4256 *
4257 * One `User-agent:` / `Disallow: /` record per blocked crawler. Allowed
4258 * crawlers emit nothing at all: `Disallow:` with an empty value is the
4259 * robots.txt way of saying "allow everything", but writing eighteen such
4260 * records to say what silence already says would triple the file and
4261 * invite the reading that an unlisted crawler is therefore refused.
4262 *
4263 * @since 2.5.0
4264 *
4265 * @param array $settings Site settings.
4266 * @return string Fenced block, newline-terminated, or '' when nothing is blocked.
4267 */
4268 private function build_ai_crawler_block(array $settings): string {
4269 $blocked = AI_Crawlers::blocked_slugs($settings['ai_crawler_rules'] ?? []);
4270
4271 if (empty($blocked)) {
4272 return '';
4273 }
4274
4275 $agents = AI_Crawlers::all();
4276
4277 $lines = [
4278 self::AI_BLOCK_BEGIN,
4279 '# Managed by ThinkRank — edits between these lines are overwritten.',
4280 ];
4281
4282 foreach ($blocked as $slug) {
4283 $lines[] = '';
4284 $lines[] = 'User-agent: ' . $agents[$slug]['token'];
4285 $lines[] = 'Disallow: /';
4286 }
4287
4288 $lines[] = self::AI_BLOCK_END;
4289
4290 return implode("\n", $lines) . "\n";
4291 }
4292
4293 /**
4294 * Remove the fenced AI crawler region from a robots.txt body.
4295 *
4296 * Tolerates a missing closing fence rather than leaving the rest of the
4297 * file swallowed: a truncated write, or someone deleting the END line by
4298 * hand, would otherwise make every subsequent read drop everything below
4299 * the opening fence.
4300 *
4301 * @since 2.5.0
4302 *
4303 * @param string $body Robots.txt body.
4304 * @return string Body with the region removed.
4305 */
4306 public function strip_ai_crawler_block(string $body): string {
4307 if (false === strpos($body, self::AI_BLOCK_BEGIN)) {
4308 return $body;
4309 }
4310
4311 $pattern = '/\R*' . preg_quote(self::AI_BLOCK_BEGIN, '/')
4312 . '.*?(?:' . preg_quote(self::AI_BLOCK_END, '/') . '|\z)\R*/s';
4313
4314 return trim((string) preg_replace($pattern, "\n\n", $body, 1));
4315 }
4316
4317 /**
4318 * Put the current AI crawler region into a robots.txt body.
4319 *
4320 * Replaces an existing region in place so the block keeps its position in
4321 * a hand-ordered file, and appends when there is none. Everything outside
4322 * the fences is returned untouched — that is the whole point of fencing
4323 * it, since the body is also a free-text field the user edits.
4324 *
4325 * @since 2.5.0
4326 *
4327 * @param string $body Robots.txt body (fences optional).
4328 * @param array $settings Site settings.
4329 * @return string Body carrying the current region.
4330 */
4331 private function apply_ai_crawler_block(string $body, array $settings): string {
4332 $stripped = $this->strip_ai_crawler_block($body);
4333 $block = $this->build_ai_crawler_block($settings);
4334
4335 if ('' === $block) {
4336 return $stripped;
4337 }
4338
4339 if ('' === trim($stripped)) {
4340 return trim($block);
4341 }
4342
4343 return rtrim($stripped) . "\n\n" . trim($block);
4344 }
4345
4346 /**
4347 * The auto-generated header prepended to the served robots.txt.
4348 *
4349 * Kept separate from the body so it is only ever added at render time with
4350 * a fresh timestamp, never stored or shown in the editable textarea.
4351 *
4352 * @return string
4353 */
4354 private function robots_txt_header(): string {
4355 return "# Robots.txt generated by ThinkRank SEO\n"
4356 . "# " . gmdate('Y-m-d H:i:s') . " UTC\n\n";
4357 }
4358
4359 /**
4360 * Strip our auto-generated header from a robots.txt string.
4361 *
4362 * Used when surfacing existing content for editing so the header/timestamp
4363 * doesn't round-trip back into storage.
4364 *
4365 * @param string $content Raw robots.txt content.
4366 * @return string Body without the ThinkRank header.
4367 */
4368 private function strip_robots_header(string $content): string {
4369 $pattern = '/^# Robots\.txt generated by ThinkRank SEO\r?\n# [^\r\n]* UTC\r?\n\r?\n/';
4370 return trim((string) preg_replace($pattern, '', $content, 1));
4371 }
4372
4373 /**
4374 * The body that should populate the editor for the current site.
4375 *
4376 * Prefers what is actually being served: the physical file if one exists
4377 * (header stripped), otherwise the effective body. This is what the admin
4378 * screen shows so the textarea is never blank while /robots.txt has content.
4379 *
4380 * @return string
4381 */
4382 public function get_served_robots_body(): string {
4383 $settings = $this->get_settings('site');
4384
4385 // The AI block is stripped from every one of these paths. A physical
4386 // robots.txt we wrote carries it, and the stored override is whatever
4387 // the textarea last held — so without this the block round-trips into
4388 // the editor, gets saved as ordinary body text, and is then appended
4389 // to a second time on the next render.
4390 $custom = trim((string) ($settings['robots_txt_content'] ?? ''));
4391 if ($custom !== '') {
4392 return $this->strip_ai_crawler_block($this->strip_robots_header($custom));
4393 }
4394
4395 $robots_file = ABSPATH . 'robots.txt';
4396 if (file_exists($robots_file)) {
4397 // 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.
4398 $raw = (string) @file_get_contents($robots_file);
4399 if ($raw !== '') {
4400 return $this->strip_ai_crawler_block($this->strip_robots_header($raw));
4401 }
4402 }
4403
4404 return $this->strip_ai_crawler_block(trim($this->generate_robots_txt()['content']));
4405 }
4406 private function get_site_identity_data(array $settings): array {
4407 return [
4408 'site_name' => $settings['site_name'] ?? get_bloginfo('name'),
4409 'site_description' => $settings['site_description'] ?? get_bloginfo('description'),
4410 'tagline' => $settings['tagline'] ?? get_bloginfo('description'),
4411 'logo_url' => $settings['logo_url'] ?? '',
4412 'favicon_url' => $settings['favicon_url'] ?? '',
4413 'apple_touch_icon_url' => $settings['apple_touch_icon_url'] ?? ''
4414 ];
4415 }
4416
4417 /**
4418 * Optimize individual identity element
4419 *
4420 * @since 1.0.0
4421 *
4422 * @param string $element Element name
4423 * @param mixed $value Element value
4424 * @param array $config Element configuration
4425 * @return array Optimization results
4426 */
4427 private function optimize_identity_element(string $element, $value, array $config): array {
4428 $optimization = [
4429 'optimized_value' => $value,
4430 'validation' => [
4431 'valid' => true,
4432 'errors' => [],
4433 'warnings' => []
4434 ],
4435 'suggestions' => []
4436 ];
4437
4438 switch ($config['type']) {
4439 case 'text':
4440 $optimization = $this->optimize_text_element($element, $value, $config, $optimization);
4441 break;
4442 case 'image':
4443 $optimization = $this->optimize_image_element($element, $value, $config, $optimization);
4444 break;
4445 }
4446
4447 return $optimization;
4448 }
4449
4450 /**
4451 * Optimize text identity element
4452 *
4453 * @since 1.0.0
4454 *
4455 * @param string $element Element name
4456 * @param string $value Element value
4457 * @param array $config Element configuration
4458 * @param array $optimization Current optimization
4459 * @return array Updated optimization
4460 */
4461 private function optimize_text_element(string $element, string $value, array $config, array $optimization): array {
4462 if (empty($value) && !empty($config['required'])) {
4463 $optimization['validation']['errors'][] = "{$element} is required";
4464 $optimization['validation']['valid'] = false;
4465 }
4466
4467 if (!empty($value) && isset($config['max_length'])) {
4468 // The warning says "characters", so measure and cut in characters:
4469 // strlen()/substr() fired early on non-Latin values and the
4470 // suggested replacement was cut mid-character (#687).
4471 if (mb_strlen($value) > $config['max_length']) {
4472 $optimization['validation']['warnings'][] = "{$element} exceeds maximum length of {$config['max_length']} characters";
4473 $optimization['optimized_value'] = \ThinkRank\Core\Seo_Text::trim_to_length($value, (int) $config['max_length']);
4474 }
4475 }
4476
4477 // SEO-specific optimizations
4478 if ($element === 'site_name' && !empty($value)) {
4479 // Remove excessive punctuation
4480 $optimization['optimized_value'] = preg_replace('/[!@#$%^&*()]+/', '', $value);
4481 }
4482
4483 return $optimization;
4484 }
4485
4486 /**
4487 * Optimize image identity element
4488 *
4489 * @since 1.0.0
4490 *
4491 * @param string $element Element name
4492 * @param string $value Element value
4493 * @param array $config Element configuration
4494 * @param array $optimization Current optimization
4495 * @return array Updated optimization
4496 */
4497 private function optimize_image_element(string $element, string $value, array $config, array $optimization): array {
4498 if (empty($value)) {
4499 if (!empty($config['required'])) {
4500 $optimization['validation']['errors'][] = "{$element} is required";
4501 $optimization['validation']['valid'] = false;
4502 }
4503 return $optimization;
4504 }
4505
4506 // Validate URL
4507 if (!filter_var($value, FILTER_VALIDATE_URL)) {
4508 $optimization['validation']['errors'][] = "{$element} must be a valid URL";
4509 $optimization['validation']['valid'] = false;
4510 return $optimization;
4511 }
4512
4513 // Check if it's a local image
4514 $attachment_id = Attachment_Lookup::id_from_url($value);
4515 if ($attachment_id) {
4516 $image_meta = wp_get_attachment_metadata($attachment_id);
4517
4518 if ($image_meta && isset($image_meta['width'], $image_meta['height'])) {
4519 // Check recommended size, against the configured file itself
4520 // rather than the upload it may have been generated from.
4521 if (isset($config['recommended_size'])) {
4522 [$rec_width, $rec_height] = explode('x', $config['recommended_size']);
4523 $image_file = Attachment_Lookup::describe($attachment_id, $value);
4524
4525 if ($image_file['width'] !== (int) $rec_width || $image_file['height'] !== (int) $rec_height) {
4526 $optimization['suggestions'][] = "Consider using {$config['recommended_size']} size for optimal {$element}";
4527 }
4528 }
4529
4530 // Check file size
4531 if (isset($config['max_size'])) {
4532 $file_path = get_attached_file($attachment_id);
4533 if ($file_path && file_exists($file_path)) {
4534 $file_size = filesize($file_path);
4535 $max_size_bytes = $this->parse_size_string($config['max_size']);
4536
4537 if ($file_size > $max_size_bytes) {
4538 $optimization['validation']['warnings'][] = "{$element} file size exceeds {$config['max_size']}";
4539 }
4540 }
4541 }
4542 }
4543 }
4544
4545 return $optimization;
4546 }
4547
4548 /**
4549 * Parse size string to bytes
4550 *
4551 * @since 1.0.0
4552 *
4553 * @param string $size_string Size string (e.g., '2MB', '500KB')
4554 * @return int Size in bytes
4555 */
4556 private function parse_size_string(string $size_string): int {
4557 $size_string = strtoupper(trim($size_string));
4558 $size = (int) $size_string;
4559
4560 if (strpos($size_string, 'KB') !== false) {
4561 return $size * 1024;
4562 } elseif (strpos($size_string, 'MB') !== false) {
4563 return $size * 1024 * 1024;
4564 } elseif (strpos($size_string, 'GB') !== false) {
4565 return $size * 1024 * 1024 * 1024;
4566 }
4567
4568 return $size;
4569 }
4570
4571 /**
4572 * Calculate identity optimization score
4573 *
4574 * @since 1.0.0
4575 *
4576 * @param array $validations Element validations
4577 * @return int Score (0-100)
4578 */
4579 private function calculate_identity_score(array $validations): int {
4580 $total_score = 0;
4581 $element_count = 0;
4582
4583 foreach ($validations as $validation) {
4584 $element_score = 100;
4585 $element_score -= count($validation['errors']) * 30;
4586 $element_score -= count($validation['warnings']) * 15;
4587
4588 $total_score += max(0, $element_score);
4589 $element_count++;
4590 }
4591
4592 return $element_count > 0 ? (int) round($total_score / $element_count) : 0;
4593 }
4594
4595 /**
4596 * Calculate validation score
4597 *
4598 * @since 1.0.0
4599 *
4600 * @param array $validation Validation results
4601 * @return int Score (0-100)
4602 */
4603 private function calculate_validation_score(array $validation): int {
4604 $score = 100;
4605 $score -= count($validation['errors']) * 20;
4606 $score -= count($validation['warnings']) * 10;
4607 $score -= count($validation['suggestions']) * 5;
4608
4609 return max(0, $score);
4610 }
4611 }
4612