PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.2
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 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.0.2, at includes/seo/class-site-identity-manager.php

3,634 lines 133.1 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 // Ensure dependencies are loaded
20 if (!class_exists('ThinkRank\\SEO\\Abstract_SEO_Manager')) {
21 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-abstract-seo-manager.php';
22 }
23
24 if (!interface_exists('ThinkRank\\SEO\\Interfaces\\SEO_Manager_Interface')) {
25 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/interfaces/class-seo-manager-interface.php';
26 }
27
28 /**
29 * Site Identity Manager Class
30 *
31 * Manages all aspects of site identity including title formats, breadcrumbs,
32 * robots.txt, and global SEO settings with context-aware optimization.
33 *
34 * @since 1.0.0
35 */
36 class Site_Identity_Manager extends Abstract_SEO_Manager {
37
38 /**
39 * WordPress filesystem instance
40 *
41 * @since 1.0.0
42 * @var \WP_Filesystem_Base|null
43 */
44 private $filesystem = null;
45
46 /**
47 * Title format templates with dynamic placeholders
48 *
49 * @since 1.0.0
50 * @var array
51 */
52 private array $title_templates = [
53 'default' => '%title% %separator% %sitename%',
54 'reverse' => '%sitename% %separator% %title%',
55 'title_only' => '%title%',
56 'sitename_only' => '%sitename%',
57 'custom' => '%title% %separator% %sitename% %separator% %tagline%',
58 'category' => '%title% %separator% %category% %separator% %sitename%',
59 'author' => '%title% %separator% %author% %separator% %sitename%',
60 'date' => '%title% %separator% %date% %separator% %sitename%',
61 'search' => 'Search Results for "%searchterm%" %separator% %sitename%',
62 '404' => 'Page Not Found %separator% %sitename%'
63 ];
64
65 /**
66 * Available title separators with their specifications
67 *
68 * @since 1.0.0
69 * @var array
70 */
71 public static array $title_separators = [
72 'pipe' => [
73 'symbol' => '|',
74 'name' => 'Pipe',
75 'description' => 'Vertical bar separator (most common)',
76 'seo_score' => 10
77 ],
78 'dash' => [
79 'symbol' => '-',
80 'name' => 'Dash',
81 'description' => 'Hyphen separator (clean and readable)',
82 'seo_score' => 9
83 ],
84 'bullet' => [
85 'symbol' => '',
86 'name' => 'Bullet',
87 'description' => 'Bullet point separator (modern)',
88 'seo_score' => 8
89 ],
90 'colon' => [
91 'symbol' => ':',
92 'name' => 'Colon',
93 'description' => 'Colon separator (formal)',
94 'seo_score' => 7
95 ],
96 'greater' => [
97 'symbol' => '>',
98 'name' => 'Greater Than',
99 'description' => 'Arrow-like separator (hierarchical)',
100 'seo_score' => 6
101 ],
102 'tilde' => [
103 'symbol' => '~',
104 'name' => 'Tilde',
105 'description' => 'Wave separator (unique)',
106 'seo_score' => 5
107 ]
108 ];
109
110 /**
111 * Get the currently active title separator symbol
112 *
113 * @since 1.0.0
114 * @return string Separator symbol
115 */
116 public static function get_active_separator_symbol(): string {
117 $manager = new self();
118 $settings = $manager->get_settings('site');
119 $separator_key = $settings['title_separator'] ?? 'pipe';
120
121 return self::$title_separators[$separator_key]['symbol'] ?? '|';
122 }
123
124 /**
125 * Breadcrumb types and their configurations
126 *
127 * @since 1.0.0
128 * @var array
129 */
130 private array $breadcrumb_types = [
131 'hierarchical' => [
132 'name' => 'Hierarchical',
133 'description' => 'Based on page hierarchy and categories',
134 'schema_type' => 'BreadcrumbList',
135 'seo_value' => 10
136 ],
137 'taxonomy' => [
138 'name' => 'Taxonomy-based',
139 'description' => 'Based on post categories and tags',
140 'schema_type' => 'BreadcrumbList',
141 'seo_value' => 9
142 ],
143 'path' => [
144 'name' => 'URL Path',
145 'description' => 'Based on URL structure',
146 'schema_type' => 'BreadcrumbList',
147 'seo_value' => 8
148 ],
149 'custom' => [
150 'name' => 'Custom',
151 'description' => 'Manually defined breadcrumb structure',
152 'schema_type' => 'BreadcrumbList',
153 'seo_value' => 7
154 ]
155 ];
156
157 /**
158 * Robots.txt directives and their specifications
159 *
160 * @since 1.0.0
161 * @var array
162 */
163 private array $robots_directives = [
164 'user_agent' => [
165 'required' => true,
166 'description' => 'Specifies which web crawler the rules apply to',
167 'examples' => ['*', 'Googlebot', 'Bingbot', 'Yandexbot']
168 ],
169 'disallow' => [
170 'required' => false,
171 'description' => 'Specifies paths that should not be crawled',
172 'examples' => ['/admin/', '/wp-admin/', '/wp-includes/', '/private/']
173 ],
174 'allow' => [
175 'required' => false,
176 'description' => 'Specifies paths that should be crawled (overrides disallow)',
177 'examples' => ['/wp-admin/admin-ajax.php', '/wp-content/uploads/']
178 ],
179 'sitemap' => [
180 'required' => false,
181 'description' => 'Specifies the location of XML sitemaps',
182 'examples' => ['/sitemap.xml', '/sitemap_index.xml']
183 ],
184 'crawl_delay' => [
185 'required' => false,
186 'description' => 'Specifies delay between requests (in seconds)',
187 'examples' => ['1', '5', '10']
188 ]
189 ];
190
191 /**
192 * Site identity elements configuration
193 *
194 * @since 1.0.0
195 * @var array
196 */
197 private array $identity_elements = [
198 'logo' => [
199 'type' => 'image',
200 'required' => false,
201 'description' => 'Site logo for branding and schema markup',
202 'recommended_size' => '600x60',
203 'max_size' => '2MB'
204 ],
205 'favicon' => [
206 'type' => 'image',
207 'required' => false,
208 'description' => 'Site favicon for browser tabs',
209 'recommended_size' => '32x32',
210 'formats' => ['ico', 'png']
211 ],
212 'apple_touch_icon' => [
213 'type' => 'image',
214 'required' => false,
215 'description' => 'Apple touch icon for iOS devices',
216 'recommended_size' => '180x180',
217 'format' => 'png'
218 ],
219 'site_name' => [
220 'type' => 'text',
221 'required' => true,
222 'description' => 'Official site name for branding',
223 'max_length' => 60
224 ],
225 'tagline' => [
226 'type' => 'text',
227 'required' => false,
228 'description' => 'Site tagline or slogan',
229 'max_length' => 160
230 ],
231 'description' => [
232 'type' => 'text',
233 'required' => false,
234 'description' => 'Site description for meta tags',
235 'max_length' => 160
236 ]
237 ];
238
239 /**
240 * Constructor
241 *
242 * @since 1.0.0
243 */
244 public function __construct() {
245 parent::__construct('site_identity');
246 }
247
248 /**
249 * Initialize WordPress filesystem
250 *
251 * @since 1.0.0
252 * @return bool True if filesystem is initialized, false otherwise
253 */
254 private function init_filesystem(): bool {
255 if ($this->filesystem !== null) {
256 return true;
257 }
258
259 global $wp_filesystem;
260
261 if (!function_exists('WP_Filesystem')) {
262 require_once ABSPATH . 'wp-admin/includes/file.php';
263 }
264
265 $credentials = request_filesystem_credentials('', '', false, false, null);
266 if (!WP_Filesystem($credentials)) {
267 return false;
268 }
269
270 $this->filesystem = $wp_filesystem;
271 return true;
272 }
273
274 /**
275 * Check if directory is writable using WP_Filesystem
276 *
277 * @since 1.0.0
278 * @param string $path Directory path to check
279 * @return bool True if writable, false otherwise
280 */
281 private function is_directory_writable(string $path): bool {
282 if (!$this->init_filesystem()) {
283 return false;
284 }
285
286 return $this->filesystem->is_writable($path);
287 }
288
289 /**
290 * Check if file is writable using WP_Filesystem
291 *
292 * @since 1.0.0
293 * @param string $file File path to check
294 * @return bool True if writable, false otherwise
295 */
296 private function is_file_writable(string $file): bool {
297 if (!$this->init_filesystem()) {
298 return false;
299 }
300
301 return $this->filesystem->is_writable($file);
302 }
303 public function generate_title(string $template_name = 'default', array $data = [], string $context = 'site'): string {
304 // Get template
305 $template = $this->title_templates[$template_name] ?? $this->title_templates['default'];
306
307 // Get site settings
308 $settings = $this->get_settings('site');
309 $separator = $this->get_title_separator($settings['title_separator'] ?? 'pipe');
310
311 // Prepare placeholder data
312 $placeholders = $this->prepare_title_placeholders($data, $context, $settings);
313
314 // Replace placeholders
315 $title = $this->replace_title_placeholders($template, $placeholders, $separator);
316
317 // Clean and optimize title
318 $title = $this->optimize_title($title, $context);
319
320 return $title;
321 }
322
323 /**
324 * Generate breadcrumb navigation with schema markup
325 *
326 * @since 1.0.0
327 *
328 * @param string $type Breadcrumb type
329 * @param array $options Breadcrumb options
330 * @return array Breadcrumb data with schema markup
331 */
332 public function generate_breadcrumbs(string $type = 'hierarchical', array $options = []): array {
333 $breadcrumbs = [
334 'items' => [],
335 'schema' => [],
336 'html' => '',
337 'type' => $type,
338 'count' => 0
339 ];
340
341 // Get breadcrumb settings
342 $settings = $this->get_settings('site');
343 $breadcrumb_settings = $settings['breadcrumbs'] ?? [];
344
345 // Generate breadcrumb items based on type
346 switch ($type) {
347 case 'hierarchical':
348 $breadcrumbs['items'] = $this->generate_hierarchical_breadcrumbs($options);
349 break;
350 case 'taxonomy':
351 $breadcrumbs['items'] = $this->generate_taxonomy_breadcrumbs($options);
352 break;
353 case 'path':
354 $breadcrumbs['items'] = $this->generate_path_breadcrumbs($options);
355 break;
356 case 'custom':
357 $breadcrumbs['items'] = $this->generate_custom_breadcrumbs($options);
358 break;
359 }
360
361 // Generate schema markup
362 $breadcrumbs['schema'] = $this->generate_breadcrumb_schema($breadcrumbs['items']);
363
364 // Generate HTML output
365 $breadcrumbs['html'] = $this->generate_breadcrumb_html($breadcrumbs['items'], $breadcrumb_settings);
366
367 // Set count
368 $breadcrumbs['count'] = count($breadcrumbs['items']);
369
370 return $breadcrumbs;
371 }
372
373 /**
374 * Generate and manage robots.txt content
375 *
376 * @since 1.0.0
377 *
378 * @param array $custom_rules Optional custom rules to add
379 * @return array Robots.txt data and validation
380 */
381 public function generate_robots_txt(array $custom_rules = []): array {
382 $robots_data = [
383 'content' => '',
384 'rules' => [],
385 'validation' => [],
386 'file_exists' => false,
387 'writable' => false
388 ];
389
390 // Check if robots.txt file exists and is writable
391 $robots_file = ABSPATH . 'robots.txt';
392 $robots_data['file_exists'] = file_exists($robots_file);
393 $robots_data['writable'] = $this->is_directory_writable(dirname($robots_file));
394
395 // Get site settings
396 $settings = $this->get_settings('site');
397
398 // Generate default rules (pass full settings so sitemap_url is available)
399 $default_rules = $this->generate_default_robots_rules($settings);
400
401 // Merge with custom rules
402 $all_rules = array_merge($default_rules, $custom_rules);
403
404 // Validate rules
405 $robots_data['validation'] = $this->validate_robots_rules($all_rules);
406
407 // Generate robots.txt content
408 $robots_data['content'] = $this->build_robots_txt_content($all_rules);
409 $robots_data['rules'] = $all_rules;
410
411 return $robots_data;
412 }
413
414 /**
415 * Resolve the robots.txt that should actually be served.
416 *
417 * The Robots.txt textarea (`robots_txt_content`) is the source of truth the
418 * admin sees and edits; per the UI, an empty value means "auto-generate".
419 * Both the virtual `robots_txt` filter and the physical file are rendered
420 * through here so what is served always matches what the textarea shows —
421 * previously the served output was regenerated from rules and silently
422 * ignored any manual edit.
423 *
424 * @since 1.20.0
425 * @return string Robots.txt body, always newline-terminated.
426 */
427 public function render_robots_txt(): string {
428 $settings = $this->get_settings('site');
429
430 // A site-wide crawl block — "Allow Search Engines" off, or WordPress's
431 // "Discourage search engines" (Settings → Reading, blog_public=0) — must
432 // win over any custom robots.txt content. Otherwise a stored override
433 // that permits crawling would silently defeat the block on every serving
434 // and persistence path. When blocked, force the generated output, which
435 // resolves to `User-agent: * / Disallow: /` via generate_default_robots_rules().
436 $allow_search = $settings['allow_search_engines'] ?? true;
437 $fully_blocked = empty($allow_search) || !get_option('blog_public');
438
439 $custom = trim((string) ($settings['robots_txt_content'] ?? ''));
440 // A user edit may still carry the old header if it was stored before the
441 // header/body split — strip it so we don't emit two headers.
442 $body = ($custom !== '' && !$fully_blocked)
443 ? $this->strip_robots_header($custom)
444 : trim($this->generate_robots_txt()['content']);
445
446 if ($body === '') {
447 return '';
448 }
449
450 return $this->robots_txt_header() . $body . "\n";
451 }
452
453 /**
454 * Resolve the robots.txt actually served to crawlers, with its origin.
455 *
456 * Lets an API/MCP consumer see the effective output without crawling the
457 * URL. Mirrors serving precedence: a physical robots.txt in the web root is
458 * served verbatim by the web server; otherwise the rendered content (custom
459 * override or generated defaults) is served through the `robots_txt` filter.
460 *
461 * @since 1.20.0
462 * @return array{content: string, is_default: bool, source: string} Effective
463 * robots.txt, whether it is ThinkRank's generated default (vs. a
464 * custom override), and where it originates from.
465 */
466 public function get_effective_robots_txt(): array {
467 // A real file in the web root wins — the web server serves it directly.
468 $robots_file = ABSPATH . 'robots.txt';
469 if (file_exists($robots_file) && is_readable($robots_file)) {
470 // 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.
471 return [
472 // 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.
473 'content' => (string) file_get_contents($robots_file),
474 'is_default' => false,
475 'source' => 'file',
476 ];
477 }
478
479 $settings = $this->get_settings('site');
480
481 // Management disabled — WordPress serves its own core default.
482 if (empty($settings['robots_txt_enabled'])) {
483 return [
484 'content' => '',
485 'is_default' => true,
486 'source' => 'wordpress',
487 ];
488 }
489
490 // A non-empty stored override replaces the generated defaults.
491 $custom = trim((string) ($settings['robots_txt_content'] ?? ''));
492
493 return [
494 'content' => $this->render_robots_txt(),
495 'is_default' => $custom === '',
496 'source' => $custom === '' ? 'generated' : 'custom',
497 ];
498 }
499
500 /**
501 * Describe how /robots.txt is actually delivered, and whether that still
502 * matches the saved settings.
503 *
504 * The admin screen edits settings, but a physical robots.txt in the web root
505 * is served directly by the web server and bypasses the `robots_txt` filter
506 * entirely. When those two drift, the editor is showing content no crawler
507 * ever sees — the conflict this exists to surface.
508 *
509 * @since 1.31.0
510 *
511 * @return array{content: string, source: string, is_default: bool, in_sync: bool, out_of_sync_reason: string, url: string}
512 * The served content and its origin, whether it still reflects the
513 * body the editor is showing, why it does not when it does not
514 * ('file_drift' or 'crawl_blocked'), and the public URL it is
515 * served from.
516 */
517 public function get_robots_txt_delivery(): array {
518 $effective = $this->get_effective_robots_txt();
519 $settings = $this->get_settings('site');
520
521 // Compare bodies, not raw strings: the auto-generated header carries a
522 // regeneration timestamp that always differs and means nothing here.
523 $served = $this->strip_robots_header($effective['content']);
524
525 // Measure against the body the editor is displaying — get_served_robots_body()
526 // — not against render_robots_txt(). Two things made the old comparison
527 // report "in sync" while the screen showed rules no crawler receives:
528 // a physical file was compared to a freshly rendered body rather than
529 // to the stored override the textarea shows, and a site-wide crawl
530 // block makes render_robots_txt() return the generated "Disallow: /"
531 // on both sides of the comparison, so it always matched.
532 $expected = $this->get_served_robots_body();
533
534 // Management off: WordPress serves its own default and the editor is not
535 // claiming anything is live, so there is nothing to be out of sync with.
536 $managed = !empty($settings['robots_txt_enabled']);
537 $in_sync = !$managed || $served === $expected;
538
539 $reason = '';
540 if (!$in_sync) {
541 // A crawl block is a deliberate override, not a stale file, and the
542 // admin needs to be told which of the two they are looking at.
543 $blocked = empty($settings['allow_search_engines'] ?? true) || !get_option('blog_public');
544 $reason = $blocked ? 'crawl_blocked' : 'file_drift';
545 }
546
547 return [
548 'content' => $effective['content'],
549 'source' => $effective['source'],
550 'is_default' => $effective['is_default'],
551 'in_sync' => $in_sync,
552 'out_of_sync_reason' => $reason,
553 'url' => home_url('/robots.txt'),
554 ];
555 }
556
557 /**
558 * Keep the physical robots.txt file in step with the saved settings.
559 *
560 * When management is enabled the physical file is the source of truth the
561 * web server serves, so this makes sure it exists and matches the effective
562 * content — creating it if missing. When management is disabled it removes
563 * any existing file so WordPress serves its default again. Callers invoke
564 * this after saving robots settings so a plain Save both creates and
565 * refreshes the file without a separate "Generate" step.
566 *
567 * @since 1.20.0
568 * @return bool True if the file was written or removed as intended.
569 */
570 public function sync_robots_txt_file(): bool {
571 $robots_file = ABSPATH . 'robots.txt';
572 $settings = $this->get_settings('site');
573
574 // Management turned off: drop any existing file so WordPress serves its
575 // default again, rather than leaving a stale ThinkRank file behind.
576 if (empty($settings['robots_txt_enabled'])) {
577 if (file_exists($robots_file) && $this->init_filesystem()) {
578 $this->filesystem->delete($robots_file);
579 }
580 return true;
581 }
582
583 $content = $this->render_robots_txt();
584 if ($content === '') {
585 return false;
586 }
587
588 // Run the effective content through the standard robots_txt filter so
589 // lines added by other integrations (ThinkRank Pro's News/Video
590 // Publisher Sitemaps at priority 999, and any third-party plugin) are
591 // baked into the physical file. A physical robots.txt bypasses core's
592 // do_robots()/robots_txt filter entirely, so without this those lines
593 // are silently dropped. ThinkRank's own filter_robots_txt callback just
594 // re-returns this same content (it calls render_robots_txt(), which does
595 // not re-apply the filter), so there is no recursion or double-append.
596 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML/core hook, not ours to name.
597 $content = (string) apply_filters('robots_txt', $content, (bool) get_option('blog_public'));
598 if ($content === '') {
599 return false;
600 }
601
602 // write_robots_txt() creates the file when absent and overwrites it
603 // otherwise, so this covers both first-time creation and re-sync.
604 $result = $this->write_robots_txt($content);
605 return !empty($result['success']);
606 }
607
608 /**
609 * Write robots.txt content to filesystem
610 *
611 * @since 1.0.0
612 *
613 * @param string $content Robots.txt content to write
614 * @return array Write operation result
615 */
616 public function write_robots_txt(string $content): array {
617 $result = [
618 'success' => false,
619 'message' => '',
620 'file_path' => '',
621 'permissions' => []
622 ];
623
624 $robots_file = ABSPATH . 'robots.txt';
625
626 // Security: Validate file path to prevent path traversal attacks
627 $real_robots_file = realpath(dirname($robots_file)) . DIRECTORY_SEPARATOR . basename($robots_file);
628 $allowed_dir = realpath(ABSPATH);
629
630 if (!$allowed_dir || strpos(dirname($real_robots_file), $allowed_dir) !== 0) {
631 $result['message'] = 'Invalid file path detected for security reasons.';
632 return $result;
633 }
634
635 $result['file_path'] = $robots_file;
636
637 // Check directory permissions
638 $result['permissions'] = [
639 'directory_writable' => $this->is_directory_writable(ABSPATH),
640 'file_exists' => file_exists($robots_file),
641 'file_writable' => file_exists($robots_file) ? $this->is_file_writable($robots_file) : null
642 ];
643
644 // Check if we can write to the directory
645 if (!$result['permissions']['directory_writable']) {
646 $result['message'] = 'WordPress root directory is not writable. Please check file permissions.';
647 return $result;
648 }
649
650 // Check if existing file is writable (if it exists)
651 if ($result['permissions']['file_exists'] && !$result['permissions']['file_writable']) {
652 $result['message'] = 'Existing robots.txt file is not writable. Please check file permissions.';
653 return $result;
654 }
655
656 try {
657 // Write new content using WP_Filesystem
658 if (!$this->init_filesystem()) {
659 $result['message'] = 'Could not initialize WordPress filesystem.';
660 return $result;
661 }
662
663 $write_success = $this->filesystem->put_contents($robots_file, $content, FS_CHMOD_FILE);
664
665 if ($write_success) {
666 $result['success'] = true;
667 $result['message'] = 'Robots.txt file written successfully.';
668 $result['bytes_written'] = strlen($content);
669 } else {
670 $result['message'] = 'Failed to write robots.txt file.';
671 }
672 } catch (\Exception $e) {
673 $result['message'] = 'Error writing robots.txt file: ' . $e->getMessage();
674 }
675
676 return $result;
677 }
678
679 /**
680 * Optimize site identity data with comprehensive analysis
681 *
682 * @since 1.0.0
683 *
684 * @param array $identity_data Site identity data to optimize
685 * @param array $options Optimization options including section focus
686 * @return array Optimized identity data with validation
687 */
688 public function optimize_site_identity(array $identity_data, array $options = []): array {
689 $optimization = [
690 'optimized_data' => [],
691 'validation' => [],
692 'suggestions' => [],
693 'warnings' => [],
694 'improvements' => [],
695 'score' => 0,
696 'section_scores' => []
697 ];
698
699 // Determine optimization focus
700 $focus = $options['focus'] ?? 'all';
701
702 // Section-specific optimization
703 if ($focus === 'title_formats' || $focus === 'all') {
704 $title_optimization = $this->optimize_title_formats($identity_data);
705 $optimization['section_scores']['title_formats'] = $title_optimization['score'];
706 $optimization['suggestions'] = array_merge($optimization['suggestions'], $title_optimization['suggestions']);
707 $optimization['warnings'] = array_merge($optimization['warnings'], $title_optimization['warnings']);
708 }
709
710 if ($focus === 'breadcrumbs' || $focus === 'all') {
711 $breadcrumb_optimization = $this->optimize_breadcrumbs($identity_data);
712 $optimization['section_scores']['breadcrumbs'] = $breadcrumb_optimization['score'];
713 $optimization['suggestions'] = array_merge($optimization['suggestions'], $breadcrumb_optimization['suggestions']);
714 $optimization['warnings'] = array_merge($optimization['warnings'], $breadcrumb_optimization['warnings']);
715 }
716
717 if ($focus === 'robots_txt' || $focus === 'all') {
718 $robots_optimization = $this->optimize_robots_txt($identity_data);
719 $optimization['section_scores']['robots_txt'] = $robots_optimization['score'];
720 $optimization['suggestions'] = array_merge($optimization['suggestions'], $robots_optimization['suggestions']);
721 $optimization['warnings'] = array_merge($optimization['warnings'], $robots_optimization['warnings']);
722 }
723
724 if ($focus === 'site_assets' || $focus === 'all') {
725 $assets_optimization = $this->optimize_site_assets($identity_data);
726 $optimization['section_scores']['site_assets'] = $assets_optimization['score'];
727 $optimization['suggestions'] = array_merge($optimization['suggestions'], $assets_optimization['suggestions']);
728 $optimization['warnings'] = array_merge($optimization['warnings'], $assets_optimization['warnings']);
729 }
730
731 // Legacy element-by-element optimization for basic site info
732 if ($focus === 'site_info' || $focus === 'all') {
733 foreach ($this->identity_elements as $element => $config) {
734 if (isset($identity_data[$element])) {
735 $element_optimization = $this->optimize_identity_element(
736 $element,
737 $identity_data[$element],
738 $config
739 );
740
741 $optimization['optimized_data'][$element] = $element_optimization['optimized_value'];
742 $optimization['validation'][$element] = $element_optimization['validation'];
743 $optimization['suggestions'] = array_merge(
744 $optimization['suggestions'],
745 $element_optimization['suggestions']
746 );
747 }
748 }
749 }
750
751 // Calculate overall optimization score
752 if (!empty($optimization['section_scores'])) {
753 $optimization['score'] = (int) round(array_sum($optimization['section_scores']) / count($optimization['section_scores']));
754 } else {
755 $optimization['score'] = $this->calculate_identity_score($optimization['validation']);
756 }
757
758 // Store optimization results in seo_analysis table
759 $this->store_optimization_results($optimization, $focus);
760
761 return $optimization;
762 }
763
764 /**
765 * Optimize title formats with enhanced rules
766 *
767 * @since 1.0.0
768 *
769 * @param array $settings Title format settings
770 * @return array Optimization results
771 */
772 public function optimize_title_formats(array $settings): array {
773 $optimization = [
774 'score' => 100,
775 'suggestions' => [],
776 'warnings' => [],
777 'improvements' => []
778 ];
779
780 // Check separator choice (applies to every context template).
781 $separator = $settings['title_separator'] ?? 'pipe';
782 $separator_data = self::$title_separators[$separator] ?? null;
783 if ($separator_data) {
784 $seo_score = $separator_data['seo_score'] ?? 5;
785 if ($seo_score < 8) {
786 $optimization['suggestions'][] = "Consider using '|' or '-' separators for better SEO performance";
787 $optimization['score'] -= (10 - $seo_score);
788 }
789 }
790
791 // Analyze the per-context templates the Title Formats UI actually edits
792 // and the front end actually renders — not the legacy `title_template`
793 // enum, which this screen never sets.
794 $context_labels = [
795 'homepage_title' => 'Homepage',
796 'post_title' => 'Post',
797 'page_title' => 'Page',
798 'category_title' => 'Category',
799 'tag_title' => 'Tag',
800 'author_title' => 'Author',
801 'search_title' => 'Search',
802 'archive_title' => 'Archive',
803 ];
804
805 $configured = 0;
806 foreach ($context_labels as $key => $label) {
807 $template = isset($settings[$key]) ? trim((string) $settings[$key]) : '';
808 if ($template === '') {
809 continue; // Unconfigured — the front end falls back for this context.
810 }
811 $configured++;
812
813 // Brand recognition: the title should carry the site name.
814 if (strpos($template, '%site_title%') === false && strpos($template, '%site_name%') === false) {
815 $optimization['suggestions'][] = "{$label} title has no site name — add %site_title% for brand recognition";
816 $optimization['score'] -= 5;
817 }
818
819 // Length check against the ~60-char guideline, measured on the
820 // resolved title for THIS context (with representative sample data).
821 $sample_length = strlen($this->generate_sample_title($settings, $key));
822 if ($sample_length > 60) {
823 $optimization['warnings'][] = "{$label} title renders about {$sample_length} characters (over the 60-character limit)";
824 $optimization['score'] -= 10;
825 } elseif ($sample_length > 0 && $sample_length < 20) {
826 $optimization['suggestions'][] = "{$label} title renders only about {$sample_length} characters — consider adding more context";
827 $optimization['score'] -= 3;
828 }
829 }
830
831 // No context templates set at all — ThinkRank won't control any titles.
832 if ($configured === 0) {
833 $optimization['suggestions'][] = 'No title formats are configured — set templates so ThinkRank controls your page titles';
834 $optimization['score'] -= 10;
835 }
836
837 $optimization['score'] = max(0, min(100, $optimization['score']));
838
839 return $optimization;
840 }
841
842 /**
843 * Optimize breadcrumb settings with UX best practices
844 *
845 * @since 1.0.0
846 *
847 * @param array $settings Breadcrumb settings
848 * @return array Optimization results
849 */
850 public function optimize_breadcrumbs(array $settings): array {
851 $optimization = [
852 'score' => 100,
853 'suggestions' => [],
854 'warnings' => [],
855 'improvements' => []
856 ];
857
858 // Check if breadcrumbs are enabled
859 if (!($settings['breadcrumbs_enabled'] ?? true)) {
860 $optimization['suggestions'][] = 'Enable breadcrumbs to improve user navigation and SEO (recommended by Google)';
861 $optimization['score'] = 20; // Major penalty for disabled breadcrumbs
862 return $optimization;
863 }
864
865 // Validate breadcrumb type
866 $type = $settings['breadcrumb_type'] ?? 'hierarchical';
867 $type_scores = [
868 'hierarchical' => 100,
869 'category_based' => 90,
870 'simple' => 70,
871 'custom' => 80
872 ];
873
874 $type_score = $type_scores[$type] ?? 60;
875 $optimization['score'] = min($optimization['score'], $type_score);
876
877 if ($type === 'simple') {
878 $optimization['suggestions'][] = 'Consider hierarchical breadcrumbs for better site structure representation';
879 }
880
881 // Validate separator choice
882 $separator = $settings['breadcrumb_separator'] ?? '';
883 $separator_ux = [
884 '' => ['score' => 100, 'note' => 'Clear directional indicator'],
885 '>' => ['score' => 95, 'note' => 'Simple and effective'],
886 '/' => ['score' => 85, 'note' => 'Familiar but can confuse with URLs'],
887 '|' => ['score' => 75, 'note' => 'Less intuitive for navigation'],
888 '»' => ['score' => 90, 'note' => 'Distinctive double arrow']
889 ];
890
891 $sep_data = $separator_ux[$separator] ?? ['score' => 50, 'note' => 'Unusual choice'];
892 $optimization['score'] = min($optimization['score'], $sep_data['score']);
893
894 if ($sep_data['score'] < 95) {
895 $optimization['suggestions'][] = "Separator '{$separator}': {$sep_data['note']}";
896 }
897
898 // Validate home text
899 $home_text = $settings['breadcrumb_home_text'] ?? 'Home';
900 if (empty($home_text)) {
901 $optimization['warnings'][] = 'Empty home text reduces accessibility for screen readers';
902 $optimization['score'] -= 15;
903 } elseif (strlen($home_text) > 20) {
904 $optimization['suggestions'][] = 'Keep home text concise (current: ' . strlen($home_text) . ' chars)';
905 $optimization['score'] -= 5;
906 }
907
908 // Check prefix usage
909 $prefix = $settings['breadcrumb_prefix'] ?? '';
910 if (!empty($prefix) && strlen($prefix) > 50) {
911 $optimization['suggestions'][] = 'Breadcrumb prefix is quite long (' . strlen($prefix) . ' chars) - consider shortening';
912 $optimization['score'] -= 5;
913 }
914
915 // Current page display
916 if (!($settings['show_current_page'] ?? true)) {
917 $optimization['suggestions'][] = 'Show current page in breadcrumbs for better user orientation';
918 $optimization['score'] -= 10;
919 }
920
921 return $optimization;
922 }
923
924 /**
925 * Optimize robots.txt settings with technical SEO best practices
926 *
927 * @since 1.0.0
928 *
929 * @param array $settings Robots.txt settings
930 * @return array Optimization results
931 */
932 public function optimize_robots_txt(array $settings): array {
933 $optimization = [
934 'score' => 100,
935 'suggestions' => [],
936 'warnings' => [],
937 'improvements' => []
938 ];
939
940 // Check if robots.txt management is enabled
941 if (!($settings['robots_txt_enabled'] ?? true)) {
942 $optimization['suggestions'][] = 'Enable robots.txt management for better SEO control and automated updates';
943 $optimization['score'] = 30;
944 return $optimization;
945 }
946
947 // Critical: Search engine access
948 if (!($settings['allow_search_engines'] ?? true)) {
949 $optimization['warnings'][] = 'CRITICAL: Search engines are blocked - your site will not be indexed by Google, Bing, etc.';
950 $optimization['score'] = 10; // Severe penalty
951 }
952
953 // Sitemap URL validation (now from sitemap settings)
954 $sitemap_urls = $this->get_sitemap_urls_for_robots();
955 if (empty($sitemap_urls)) {
956 $optimization['suggestions'][] = 'Enable sitemap generation to include sitemap URLs in robots.txt';
957 $optimization['score'] -= 15;
958 } else {
959 // Validate first sitemap accessibility (representative check)
960 $first_sitemap = $sitemap_urls[0];
961 $sitemap_response = wp_remote_head($first_sitemap, ['timeout' => 10]);
962 if (is_wp_error($sitemap_response) || wp_remote_retrieve_response_code($sitemap_response) !== 200) {
963 $optimization['warnings'][] = 'Primary sitemap URL is not accessible - check sitemap generation';
964 $optimization['score'] -= 10;
965 }
966 }
967
968 // File system permissions
969 $robots_file = ABSPATH . 'robots.txt';
970 $robots_dir = dirname($robots_file);
971
972 if (!$this->is_directory_writable($robots_dir)) {
973 $optimization['warnings'][] = 'WordPress root directory is not writable - robots.txt cannot be managed automatically';
974 $optimization['score'] -= 15;
975 } elseif (file_exists($robots_file) && !$this->is_file_writable($robots_file)) {
976 $optimization['warnings'][] = 'Existing robots.txt file is not writable - cannot update automatically';
977 $optimization['score'] -= 10;
978 }
979
980 // Content analysis
981 $custom_content = $settings['robots_txt_content'] ?? '';
982 if (!empty($custom_content)) {
983 // Check for dangerous patterns
984 if (preg_match('/User-agent:\s*\*\s*\n\s*Disallow:\s*\/\s*$/m', $custom_content)) {
985 $optimization['warnings'][] = 'Blocking all content for all crawlers - this will prevent search engine indexing';
986 $optimization['score'] -= 30;
987 }
988
989 // Check for sitemap declaration in content
990 if (!empty($sitemap_urls) && strpos($custom_content, 'Sitemap:') === false) {
991 $optimization['suggestions'][] = 'Sitemap URLs are automatically included in generated robots.txt';
992 $optimization['score'] -= 5;
993 }
994 }
995
996 return $optimization;
997 }
998
999 /**
1000 * Optimize site assets (logo, favicon, apple touch icon)
1001 *
1002 * @since 1.0.0
1003 *
1004 * @param array $settings Site assets settings
1005 * @return array Optimization results
1006 */
1007 public function optimize_site_assets(array $settings): array {
1008 $optimization = [
1009 'score' => 100,
1010 'suggestions' => [],
1011 'warnings' => [],
1012 'improvements' => []
1013 ];
1014
1015 // Check site logo
1016 $logo_url = $settings['logo_url'] ?? '';
1017 if (empty($logo_url)) {
1018 $optimization['suggestions'][] = 'Add a site logo for better branding and professional appearance';
1019 $optimization['score'] -= 20;
1020 } else {
1021 // Validate logo URL and dimensions
1022 if (!filter_var($logo_url, FILTER_VALIDATE_URL)) {
1023 $optimization['warnings'][] = 'Logo URL format is invalid';
1024 $optimization['score'] -= 15;
1025 }
1026 }
1027
1028 // Check favicon
1029 $favicon_url = $settings['favicon_url'] ?? '';
1030 if (empty($favicon_url)) {
1031 $optimization['suggestions'][] = 'Add a favicon for better browser tab identification';
1032 $optimization['score'] -= 15;
1033 }
1034
1035 // Check Apple touch icon
1036 $apple_icon_url = $settings['apple_touch_icon_url'] ?? '';
1037 if (empty($apple_icon_url)) {
1038 $optimization['suggestions'][] = 'Add an Apple touch icon for better iOS device experience';
1039 $optimization['score'] -= 10;
1040 }
1041
1042 // Additional logo analysis for local images
1043 if (!empty($logo_url) && filter_var($logo_url, FILTER_VALIDATE_URL)) {
1044 $attachment_id = attachment_url_to_postid($logo_url);
1045 if ($attachment_id) {
1046 $image_meta = wp_get_attachment_metadata($attachment_id);
1047 $width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0;
1048 $height = isset($image_meta['height']) ? (int) $image_meta['height'] : 0;
1049
1050 // SVG logos store 0x0 metadata — no dimension/ratio analysis
1051 // is possible (and dividing by 0 is fatal).
1052 if ($image_meta && $width > 0 && $height > 0) {
1053 if ($width < 112 || $height < 112) {
1054 $optimization['warnings'][] = "Logo dimensions ({$width}x{$height}) are below recommended minimum (112x112)";
1055 $optimization['score'] -= 10;
1056 }
1057
1058 if ($width > 1920 || $height > 1920) {
1059 $optimization['suggestions'][] = "Logo dimensions ({$width}x{$height}) are very large - consider optimizing for faster loading";
1060 $optimization['score'] -= 5;
1061 }
1062
1063 // Aspect ratio check
1064 $ratio = $width / $height;
1065 if ($ratio < 0.5 || $ratio > 2.0) {
1066 $optimization['suggestions'][] = 'Logo aspect ratio should be between 1:2 and 2:1 for optimal display';
1067 $optimization['score'] -= 5;
1068 }
1069 }
1070 }
1071 }
1072
1073 return $optimization;
1074 }
1075
1076 /**
1077 * Optimize local SEO settings for better local search visibility
1078 *
1079 * @since 1.0.0
1080 *
1081 * @param array $settings Local SEO settings
1082 * @return array Optimization results
1083 */
1084 public function optimize_local_seo(array $settings): array {
1085 $optimization = [
1086 'score' => 100,
1087 'suggestions' => [],
1088 'warnings' => [],
1089 'improvements' => [],
1090 'optimized_data' => []
1091 ];
1092
1093 // Check if local SEO is enabled
1094 if (empty($settings['local_seo_enabled'])) {
1095 $optimization['warnings'][] = 'Local SEO is disabled - enable it to improve local search visibility';
1096 $optimization['score'] -= 20;
1097 return $optimization;
1098 }
1099
1100 // Validate business name (required for local SEO)
1101 if (empty($settings['business_name'])) {
1102 $optimization['warnings'][] = 'Business name is required for local SEO';
1103 $optimization['score'] -= 25;
1104 } else {
1105 // Optimize business name
1106 $optimized_name = $this->optimize_business_name($settings['business_name']);
1107 if ($optimized_name !== $settings['business_name']) {
1108 $optimization['optimized_data']['business_name'] = $optimized_name;
1109 $optimization['suggestions'][] = 'Business name optimized for better local search visibility';
1110 }
1111 }
1112
1113 // Validate complete address (NAP consistency)
1114 $address_score = $this->validate_business_address($settings, $optimization);
1115 $optimization['score'] -= (100 - $address_score);
1116
1117 // Validate phone number
1118 if (empty($settings['business_phone'])) {
1119 $optimization['warnings'][] = 'Business phone number is missing - important for local SEO and NAP consistency';
1120 $optimization['score'] -= 15;
1121 } else {
1122 $optimized_phone = $this->optimize_phone_number($settings['business_phone']);
1123 if ($optimized_phone !== $settings['business_phone']) {
1124 $optimization['optimized_data']['business_phone'] = $optimized_phone;
1125 $optimization['suggestions'][] = 'Phone number formatted for better consistency';
1126 }
1127 }
1128
1129 // Validate business hours
1130 if (empty($settings['business_hours']) || !is_array($settings['business_hours'])) {
1131 $optimization['suggestions'][] = 'Add business hours to improve local search visibility and customer experience';
1132 $optimization['score'] -= 10;
1133 } else {
1134 $hours_validation = $this->validate_business_hours($settings['business_hours']);
1135 if (!$hours_validation['valid']) {
1136 $optimization['warnings'] = array_merge($optimization['warnings'], $hours_validation['warnings']);
1137 $optimization['score'] -= $hours_validation['penalty'];
1138 }
1139 }
1140
1141 // Check for geo-coordinates
1142 if (empty($settings['business_latitude']) || empty($settings['business_longitude'])) {
1143 $optimization['suggestions'][] = 'Add latitude and longitude coordinates for precise location targeting';
1144 $optimization['score'] -= 10;
1145 } else {
1146 // Validate coordinates
1147 if (!$this->validate_coordinates($settings['business_latitude'], $settings['business_longitude'])) {
1148 $optimization['warnings'][] = 'Invalid latitude or longitude coordinates';
1149 $optimization['score'] -= 15;
1150 }
1151 }
1152
1153 // Business type validation
1154 if (empty($settings['business_type'])) {
1155 $optimization['suggestions'][] = 'Select a specific business type for better schema markup';
1156 $optimization['score'] -= 5;
1157 }
1158
1159 // Email validation
1160 if (!empty($settings['business_email']) && !is_email($settings['business_email'])) {
1161 $optimization['warnings'][] = 'Business email format is invalid';
1162 $optimization['score'] -= 10;
1163 }
1164
1165 // Local SEO best practices
1166 $this->add_local_seo_best_practices($optimization, $settings);
1167
1168 return $optimization;
1169 }
1170
1171 /**
1172 * Optimize business name for local SEO
1173 *
1174 * @param string $business_name Original business name
1175 * @return string Optimized business name
1176 */
1177 private function optimize_business_name(string $business_name): string {
1178 // Remove excessive punctuation and normalize spacing
1179 $optimized = preg_replace('/[^\w\s\-&.,]/', '', $business_name);
1180 $optimized = preg_replace('/\s+/', ' ', $optimized);
1181 $optimized = trim($optimized);
1182
1183 // Ensure proper capitalization
1184 $optimized = ucwords(strtolower($optimized));
1185
1186 return $optimized;
1187 }
1188
1189 /**
1190 * Validate business address components
1191 *
1192 * @param array $settings Business settings
1193 * @param array &$optimization Optimization results (passed by reference)
1194 * @return int Address completeness score (0-100)
1195 */
1196 private function validate_business_address(array $settings, array &$optimization): int {
1197 $score = 100;
1198 $required_fields = ['business_address', 'business_city', 'business_state', 'business_country'];
1199 $missing_fields = [];
1200
1201 foreach ($required_fields as $field) {
1202 if (empty($settings[$field])) {
1203 $missing_fields[] = str_replace('business_', '', $field);
1204 $score -= 20;
1205 }
1206 }
1207
1208 if (!empty($missing_fields)) {
1209 $optimization['warnings'][] = 'Missing address components: ' . implode(', ', $missing_fields) . ' - important for NAP consistency';
1210 }
1211
1212 // Postal code is recommended but not required
1213 if (empty($settings['business_postal_code'])) {
1214 $optimization['suggestions'][] = 'Add postal code for more precise location targeting';
1215 $score -= 5;
1216 }
1217
1218 return max(0, $score);
1219 }
1220
1221 /**
1222 * Optimize phone number format for consistency
1223 *
1224 * @param string $phone_number Original phone number
1225 * @return string Optimized phone number
1226 */
1227 private function optimize_phone_number(string $phone_number): string {
1228 // Remove all non-numeric characters except + for international numbers
1229 $cleaned = preg_replace('/[^\d+]/', '', $phone_number);
1230
1231 // If it's a US number (10 digits), format as (XXX) XXX-XXXX
1232 if (preg_match('/^(\d{10})$/', $cleaned, $matches)) {
1233 return '(' . substr($matches[1], 0, 3) . ') ' . substr($matches[1], 3, 3) . '-' . substr($matches[1], 6);
1234 }
1235
1236 // If it's a US number with country code, format as +1 (XXX) XXX-XXXX
1237 if (preg_match('/^1(\d{10})$/', $cleaned, $matches)) {
1238 return '+1 (' . substr($matches[1], 0, 3) . ') ' . substr($matches[1], 3, 3) . '-' . substr($matches[1], 6);
1239 }
1240
1241 // For international numbers, keep the + and return as-is
1242 return $cleaned;
1243 }
1244
1245 /**
1246 * Validate business hours format and completeness
1247 *
1248 * @param array $business_hours Business hours array
1249 * @return array Validation results
1250 */
1251 private function validate_business_hours(array $business_hours): array {
1252 $validation = [
1253 'valid' => true,
1254 'warnings' => [],
1255 'penalty' => 0
1256 ];
1257
1258 $days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
1259 $open_days = 0;
1260
1261 foreach ($days as $day) {
1262 if (!isset($business_hours[$day])) {
1263 continue;
1264 }
1265
1266 $day_data = $business_hours[$day];
1267
1268 if (empty($day_data['closed'])) {
1269 $open_days++;
1270
1271 // Validate time format
1272 if (empty($day_data['open']) || empty($day_data['close'])) {
1273 $validation['warnings'][] = "Missing opening or closing time for {$day}";
1274 $validation['penalty'] += 2;
1275 } else {
1276 // Validate time format (HH:MM)
1277 if (!preg_match('/^\d{2}:\d{2}$/', $day_data['open']) || !preg_match('/^\d{2}:\d{2}$/', $day_data['close'])) {
1278 $validation['warnings'][] = "Invalid time format for {$day} (use HH:MM format)";
1279 $validation['penalty'] += 2;
1280 }
1281 }
1282 }
1283 }
1284
1285 if ($open_days === 0) {
1286 $validation['warnings'][] = 'No business hours specified - all days marked as closed';
1287 $validation['penalty'] += 10;
1288 }
1289
1290 if ($validation['penalty'] > 0) {
1291 $validation['valid'] = false;
1292 }
1293
1294 return $validation;
1295 }
1296
1297 /**
1298 * Validate latitude and longitude coordinates
1299 *
1300 * @param string $latitude Latitude coordinate
1301 * @param string $longitude Longitude coordinate
1302 * @return bool True if coordinates are valid
1303 */
1304 private function validate_coordinates(string $latitude, string $longitude): bool {
1305 $lat = floatval($latitude);
1306 $lng = floatval($longitude);
1307
1308 // Validate latitude range (-90 to 90)
1309 if ($lat < -90 || $lat > 90) {
1310 return false;
1311 }
1312
1313 // Validate longitude range (-180 to 180)
1314 if ($lng < -180 || $lng > 180) {
1315 return false;
1316 }
1317
1318 return true;
1319 }
1320
1321 /**
1322 * Add local SEO best practices suggestions
1323 *
1324 * @param array &$optimization Optimization results (passed by reference)
1325 * @param array $settings Business settings
1326 * @return void
1327 */
1328 private function add_local_seo_best_practices(array &$optimization, array $settings): void {
1329 // Check for Google My Business integration
1330 if (empty($settings['google_my_business_url'])) {
1331 $optimization['suggestions'][] = 'Consider adding your Google My Business profile URL for better local visibility';
1332 }
1333
1334 // Check for social media profiles
1335 $social_platforms = ['facebook_url', 'twitter_url', 'instagram_url', 'linkedin_url'];
1336 $has_social = false;
1337 foreach ($social_platforms as $platform) {
1338 if (!empty($settings[$platform])) {
1339 $has_social = true;
1340 break;
1341 }
1342 }
1343
1344 if (!$has_social) {
1345 $optimization['suggestions'][] = 'Add social media profiles to improve local business credibility';
1346 }
1347
1348 // Check for business description
1349 if (empty($settings['business_description'])) {
1350 $optimization['suggestions'][] = 'Add a business description for better context in local search results';
1351 }
1352
1353 // Service area suggestions
1354 if (empty($settings['service_areas'])) {
1355 $optimization['suggestions'][] = 'Define service areas if your business serves multiple locations';
1356 }
1357 }
1358
1359 /**
1360 * Generate a sample rendered title for a given context template, so the
1361 * optimizer can measure the length users will actually see.
1362 *
1363 * Resolves the per-context template (e.g. `post_title`) with representative
1364 * sample values for the same variable tokens the front-end renderer fills
1365 * in (see SEO_Manager::get_title_placeholders()).
1366 *
1367 * @since 1.0.0
1368 *
1369 * @param array $settings Title format settings
1370 * @param string $context_key Per-context template key (e.g. 'post_title')
1371 * @return string Resolved sample title (empty string when the template is unset)
1372 */
1373 private function generate_sample_title(array $settings, string $context_key = 'post_title'): string {
1374 $template = isset($settings[$context_key]) ? trim((string) $settings[$context_key]) : '';
1375 if ($template === '') {
1376 return '';
1377 }
1378
1379 $separator = $settings['title_separator'] ?? 'pipe';
1380 $separator_symbol = self::$title_separators[$separator]['symbol'] ?? '|';
1381
1382 $site_name = $settings['site_name'] ?? '';
1383 if ($site_name === '') {
1384 $site_name = get_bloginfo('name') ?: 'Your Site Name';
1385 }
1386 $site_description = $settings['site_description'] ?? '';
1387 if ($site_description === '') {
1388 $site_description = get_bloginfo('description') ?: 'Your Site Description';
1389 }
1390 $tagline = $settings['tagline'] ?? '';
1391 if ($tagline === '') {
1392 $tagline = $site_description;
1393 }
1394
1395 // Representative sample values for the variable tokens the front end
1396 // substitutes per request. Keys mirror get_title_placeholders().
1397 $sample_data = [
1398 '%site_title%' => $site_name,
1399 '%site_name%' => $site_name,
1400 '%site_description%' => $site_description,
1401 '%tagline%' => $tagline,
1402 '%sep%' => ' ' . $separator_symbol . ' ',
1403 '%separator%' => ' ' . $separator_symbol . ' ',
1404 '%post_title%' => 'How to Optimize Your Website for Better SEO Results',
1405 '%page_title%' => 'About Our Company',
1406 '%category_title%' => 'SEO Tips',
1407 '%category%' => 'SEO Tips',
1408 '%tag_title%' => 'On-Page SEO',
1409 '%tag%' => 'On-Page SEO',
1410 '%author_name%' => 'Jane Doe',
1411 '%author%' => 'Jane Doe',
1412 '%search_term%' => 'keyword research',
1413 '%search_phrase%' => 'keyword research',
1414 '%archive_title%' => 'July 2026',
1415 '%date%' => gmdate('F Y'),
1416 ];
1417
1418 $title = str_replace(array_keys($sample_data), array_values($sample_data), $template);
1419
1420 // Collapse whitespace left by any empty/unresolved tokens, then trim.
1421 $title = preg_replace('/\s+/', ' ', $title);
1422
1423 return trim($title);
1424 }
1425
1426 /**
1427 * Store optimization results in seo_analysis table
1428 *
1429 * @since 1.0.0
1430 *
1431 * @param array $optimization Optimization results
1432 * @param string $focus Optimization focus section
1433 * @return void
1434 */
1435 private function store_optimization_results(array $optimization, string $focus): void {
1436 global $wpdb;
1437
1438 $table_name = $wpdb->prefix . 'thinkrank_seo_analysis';
1439
1440 // Only store if we have meaningful results
1441 if (empty($optimization['suggestions']) && empty($optimization['warnings'])) {
1442 return;
1443 }
1444
1445 $analysis_type = 'site_identity_rule_optimization';
1446 if ($focus !== 'all') {
1447 $analysis_type .= '_' . $focus;
1448 }
1449
1450 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Site identity analysis storage requires direct database access
1451 $wpdb->insert(
1452 $table_name,
1453 [
1454 'context_type' => 'site',
1455 'context_id' => null,
1456 'analysis_type' => $analysis_type,
1457 'analysis_data' => wp_json_encode($optimization),
1458 'score' => $optimization['score'],
1459 'status' => 'completed',
1460 'recommendations' => wp_json_encode($optimization['suggestions']),
1461 'validation_errors' => wp_json_encode($optimization['warnings']),
1462 'analyzed_by' => get_current_user_id()
1463 ],
1464 ['%s', '%d', '%s', '%s', '%d', '%s', '%s', '%s', '%d']
1465 );
1466 }
1467
1468 /**
1469 * Validate SEO settings (implements interface)
1470 *
1471 * @since 1.0.0
1472 *
1473 * @param array $settings Settings array to validate
1474 * @param string $tab_context Optional tab context for specific validation
1475 * @return array Validation results
1476 */
1477 public function validate_settings(array $settings, string $tab_context = ''): array {
1478 // If tab context is provided, use tab-specific validation
1479 if (!empty($tab_context)) {
1480 return $this->get_tab_specific_validation($settings, $tab_context);
1481 }
1482
1483 // Default comprehensive validation for backward compatibility
1484 $validation = [
1485 'valid' => true,
1486 'errors' => [],
1487 'warnings' => [],
1488 'suggestions' => [],
1489 'score' => 100
1490 ];
1491
1492 // Validate title template
1493 if (isset($settings['title_template'])) {
1494 if (!isset($this->title_templates[$settings['title_template']])) {
1495 $validation['errors'][] = 'Invalid title template specified';
1496 $validation['valid'] = false;
1497 }
1498 }
1499
1500 // Validate title separator
1501 if (isset($settings['title_separator'])) {
1502 if (!isset(self::$title_separators[$settings['title_separator']])) {
1503 $validation['errors'][] = __('Invalid title separator specified.', 'thinkrank');
1504 $validation['valid'] = false;
1505 }
1506 }
1507
1508 // Validate site name
1509 if (isset($settings['site_name'])) {
1510 if (empty($settings['site_name'])) {
1511 $validation['errors'][] = 'Site name is required';
1512 $validation['valid'] = false;
1513 } elseif (strlen($settings['site_name']) > 60) {
1514 $validation['warnings'][] = 'Site name is longer than 60 characters, may be truncated';
1515 }
1516 }
1517
1518 // Validate site description
1519 if (isset($settings['site_description']) && !empty($settings['site_description'])) {
1520 if (strlen($settings['site_description']) > 160) {
1521 $validation['warnings'][] = 'Site description is longer than 160 characters, may be truncated';
1522 } elseif (strlen($settings['site_description']) < 120) {
1523 $validation['suggestions'][] = 'Consider making site description longer (120-160 characters)';
1524 }
1525 }
1526
1527 // Validate logo URL
1528 if (isset($settings['logo_url']) && !empty($settings['logo_url'])) {
1529 if (!filter_var($settings['logo_url'], FILTER_VALIDATE_URL)) {
1530 $validation['errors'][] = 'Logo URL must be a valid URL';
1531 $validation['valid'] = false;
1532 }
1533 }
1534
1535 // Validate breadcrumb settings
1536 if (isset($settings['breadcrumb_type'])) {
1537 if (!isset($this->breadcrumb_types[$settings['breadcrumb_type']])) {
1538 $validation['errors'][] = 'Invalid breadcrumb type specified';
1539 $validation['valid'] = false;
1540 }
1541 }
1542
1543 // Validate robots.txt settings
1544 if (isset($settings['robots_txt_enabled']) && $settings['robots_txt_enabled']) {
1545 if (!$this->is_directory_writable(ABSPATH)) {
1546 $validation['warnings'][] = 'WordPress root directory is not writable, robots.txt cannot be automatically managed';
1547 }
1548 }
1549
1550 // Validate local SEO settings if enabled
1551 if (isset($settings['local_seo_enabled']) && $settings['local_seo_enabled']) {
1552 $local_seo_validation = $this->validate_local_seo_settings($settings);
1553 $validation['errors'] = array_merge($validation['errors'], $local_seo_validation['errors']);
1554 $validation['warnings'] = array_merge($validation['warnings'], $local_seo_validation['warnings']);
1555 $validation['suggestions'] = array_merge($validation['suggestions'], $local_seo_validation['suggestions']);
1556
1557 if (!$local_seo_validation['valid']) {
1558 $validation['valid'] = false;
1559 }
1560 }
1561
1562 // Calculate validation score
1563 $validation['score'] = $this->calculate_validation_score($validation);
1564
1565 // Add detailed field validation breakdown for generic validation
1566 $validation['field_details'] = $this->get_detailed_field_validation($settings, '');
1567
1568 return $validation;
1569 }
1570
1571 /**
1572 * Get tab-specific validation
1573 *
1574 * @since 1.0.0
1575 *
1576 * @param array $settings Settings array to validate
1577 * @param string $tab_context Tab context for specific validation
1578 * @return array Tab-specific validation results
1579 */
1580 private function get_tab_specific_validation(array $settings, string $tab_context): array {
1581 $validation = [
1582 'valid' => true,
1583 'errors' => [],
1584 'warnings' => [],
1585 'suggestions' => [],
1586 'score' => 100
1587 ];
1588
1589 // Get tab-specific field details
1590 $field_details = $this->get_detailed_field_validation($settings, $tab_context);
1591
1592 // Convert field details to validation format
1593 foreach ($field_details as $field) {
1594 switch ($field['status']) {
1595 case 'error':
1596 $validation['errors'][] = $field['label'];
1597 $validation['valid'] = false;
1598 $validation['score'] -= 20;
1599 break;
1600 case 'warning':
1601 $validation['warnings'][] = $field['label'];
1602 $validation['score'] -= 10;
1603 break;
1604 case 'suggestion':
1605 $validation['suggestions'][] = $field['label'];
1606 $validation['score'] -= 5;
1607 break;
1608 }
1609 }
1610
1611 // Ensure score doesn't go below 0
1612 $validation['score'] = max(0, $validation['score']);
1613
1614 // Add field details for frontend display
1615 $validation['field_details'] = $field_details;
1616
1617 return $validation;
1618 }
1619
1620 /**
1621 * Get detailed field validation breakdown
1622 *
1623 * @since 1.0.0
1624 *
1625 * @param array $settings Settings array to validate
1626 * @param string $tab_context Tab context for specific validation
1627 * @return array Detailed field validation results
1628 */
1629 private function get_detailed_field_validation(array $settings, string $tab_context = ''): array {
1630 $field_details = [];
1631
1632 // Return tab-specific validation based on context
1633 switch ($tab_context) {
1634 case 'local-seo':
1635 return $this->get_business_info_validation($settings);
1636 case 'hero-section':
1637 return $this->get_hero_section_validation($settings);
1638 case 'title-formats':
1639 return $this->get_title_formats_validation($settings);
1640 case 'breadcrumbs':
1641 return $this->get_breadcrumbs_validation($settings);
1642 default:
1643 // Default basic info validation
1644 return $this->get_basic_info_validation($settings);
1645 }
1646 }
1647
1648 /**
1649 * Get Business Info specific validation
1650 *
1651 * @since 1.0.0
1652 *
1653 * @param array $settings Settings array to validate
1654 * @return array Business Info validation results
1655 */
1656 private function get_business_info_validation(array $settings): array {
1657 $field_details = [];
1658
1659 // Check if Local SEO is enabled
1660 if (empty($settings['local_seo_enabled'])) {
1661 $field_details[] = [
1662 'field' => 'local_seo_enabled',
1663 'label' => 'Local SEO is disabled. Enable to configure business information.',
1664 'status' => 'warning',
1665 'icon' => ''
1666 ];
1667 return $field_details;
1668 }
1669
1670 // Business Name validation
1671 if (!empty($settings['business_name'])) {
1672 $field_details[] = [
1673 'field' => 'business_name',
1674 'label' => 'Business name is properly configured.',
1675 'status' => 'valid',
1676 'icon' => ''
1677 ];
1678 } else {
1679 $field_details[] = [
1680 'field' => 'business_name',
1681 'label' => 'Business name is required for local SEO.',
1682 'status' => 'error',
1683 'icon' => ''
1684 ];
1685 }
1686
1687 // Business Type validation
1688 if (!empty($settings['business_type']) && $settings['business_type'] !== 'LocalBusiness') {
1689 $field_details[] = [
1690 'field' => 'business_type',
1691 'label' => 'Business type is selected for proper schema markup.',
1692 'status' => 'valid',
1693 'icon' => ''
1694 ];
1695 } else {
1696 $field_details[] = [
1697 'field' => 'business_type',
1698 'label' => 'Specific business type selection recommended for better schema markup.',
1699 'status' => 'suggestion',
1700 'icon' => ''
1701 ];
1702 }
1703
1704 // Address validation (NAP consistency)
1705 $address_fields = ['business_address', 'business_city', 'business_state', 'business_country'];
1706 $address_complete = true;
1707 foreach ($address_fields as $field) {
1708 if (empty($settings[$field])) {
1709 $address_complete = false;
1710 break;
1711 }
1712 }
1713
1714 if ($address_complete) {
1715 $field_details[] = [
1716 'field' => 'business_address',
1717 'label' => 'Complete business address is configured for NAP consistency.',
1718 'status' => 'valid',
1719 'icon' => ''
1720 ];
1721 } else {
1722 $field_details[] = [
1723 'field' => 'business_address',
1724 'label' => 'Complete address (street, city, state, country) required for local SEO.',
1725 'status' => 'error',
1726 'icon' => ''
1727 ];
1728 }
1729
1730 // Phone validation
1731 if (!empty($settings['business_phone'])) {
1732 if ($this->validate_phone_format($settings['business_phone'])) {
1733 $field_details[] = [
1734 'field' => 'business_phone',
1735 'label' => 'Business phone number is properly formatted.',
1736 'status' => 'valid',
1737 'icon' => ''
1738 ];
1739 } else {
1740 $field_details[] = [
1741 'field' => 'business_phone',
1742 'label' => 'Business phone number format could be improved.',
1743 'status' => 'warning',
1744 'icon' => ''
1745 ];
1746 }
1747 } else {
1748 $field_details[] = [
1749 'field' => 'business_phone',
1750 'label' => 'Business phone number is important for local SEO and customer contact.',
1751 'status' => 'warning',
1752 'icon' => ''
1753 ];
1754 }
1755
1756 // Email validation
1757 if (!empty($settings['business_email'])) {
1758 if (is_email($settings['business_email'])) {
1759 $field_details[] = [
1760 'field' => 'business_email',
1761 'label' => 'Business email address is valid.',
1762 'status' => 'valid',
1763 'icon' => ''
1764 ];
1765 } else {
1766 $field_details[] = [
1767 'field' => 'business_email',
1768 'label' => 'Business email address format is invalid.',
1769 'status' => 'error',
1770 'icon' => ''
1771 ];
1772 }
1773 } else {
1774 $field_details[] = [
1775 'field' => 'business_email',
1776 'label' => 'Business email address recommended for contact information.',
1777 'status' => 'suggestion',
1778 'icon' => ''
1779 ];
1780 }
1781
1782 // Coordinates validation
1783 if (!empty($settings['business_latitude']) && !empty($settings['business_longitude'])) {
1784 if ($this->validate_coordinates($settings['business_latitude'], $settings['business_longitude'])) {
1785 $field_details[] = [
1786 'field' => 'business_coordinates',
1787 'label' => 'Business coordinates are properly configured for precise location.',
1788 'status' => 'valid',
1789 'icon' => ''
1790 ];
1791 } else {
1792 $field_details[] = [
1793 'field' => 'business_coordinates',
1794 'label' => 'Business coordinates appear to be invalid.',
1795 'status' => 'error',
1796 'icon' => ''
1797 ];
1798 }
1799 } else {
1800 $field_details[] = [
1801 'field' => 'business_coordinates',
1802 'label' => 'Business coordinates recommended for precise location targeting.',
1803 'status' => 'suggestion',
1804 'icon' => ''
1805 ];
1806 }
1807
1808 return $field_details;
1809 }
1810
1811 /**
1812 * Get Basic Info validation (default)
1813 *
1814 * @since 1.0.0
1815 *
1816 * @param array $settings Settings array to validate
1817 * @return array Basic Info validation results
1818 */
1819 private function get_basic_info_validation(array $settings): array {
1820 $field_details = [];
1821
1822 // Site Name validation
1823 if (!empty($settings['site_name'])) {
1824 $field_details[] = [
1825 'field' => 'site_name',
1826 'label' => 'Site name is properly configured.',
1827 'status' => 'valid',
1828 'icon' => ''
1829 ];
1830 } else {
1831 $field_details[] = [
1832 'field' => 'site_name',
1833 'label' => 'Site name is required.',
1834 'status' => 'error',
1835 'icon' => ''
1836 ];
1837 }
1838
1839 // Site Description validation
1840 if (!empty($settings['site_description'])) {
1841 $length = strlen($settings['site_description']);
1842 if ($length >= 120 && $length <= 160) {
1843 $field_details[] = [
1844 'field' => 'site_description',
1845 'label' => 'Site description is properly configured.',
1846 'status' => 'valid',
1847 'icon' => ''
1848 ];
1849 } else {
1850 $field_details[] = [
1851 'field' => 'site_description',
1852 'label' => 'Site description length could be optimized (120-160 characters recommended).',
1853 'status' => 'warning',
1854 'icon' => ''
1855 ];
1856 }
1857 } else {
1858 $field_details[] = [
1859 'field' => 'site_description',
1860 'label' => 'Site description is recommended for better SEO.',
1861 'status' => 'warning',
1862 'icon' => ''
1863 ];
1864 }
1865
1866 // Tagline validation
1867 if (!empty($settings['tagline'])) {
1868 $field_details[] = [
1869 'field' => 'tagline',
1870 'label' => 'Site tagline is configured.',
1871 'status' => 'valid',
1872 'icon' => ''
1873 ];
1874 } else {
1875 $field_details[] = [
1876 'field' => 'tagline',
1877 'label' => 'Site tagline recommended for better branding.',
1878 'status' => 'suggestion',
1879 'icon' => ''
1880 ];
1881 }
1882
1883 // Default Meta Description validation
1884 if (!empty($settings['default_meta_description'])) {
1885 $length = strlen($settings['default_meta_description']);
1886 if ($length >= 120 && $length <= 160) {
1887 $field_details[] = [
1888 'field' => 'default_meta_description',
1889 'label' => 'Default meta description is properly configured.',
1890 'status' => 'valid',
1891 'icon' => ''
1892 ];
1893 } else {
1894 $field_details[] = [
1895 'field' => 'default_meta_description',
1896 'label' => 'Default meta description length could be optimized (120-160 characters recommended).',
1897 'status' => 'warning',
1898 'icon' => ''
1899 ];
1900 }
1901 } else {
1902 $field_details[] = [
1903 'field' => 'default_meta_description',
1904 'label' => 'Default meta description recommended for pages without specific descriptions.',
1905 'status' => 'suggestion',
1906 'icon' => ''
1907 ];
1908 }
1909
1910 return $field_details;
1911 }
1912
1913 /**
1914 * Get Hero Section validation
1915 *
1916 * @since 1.0.0
1917 *
1918 * @param array $settings Settings array to validate
1919 * @return array Hero Section validation results
1920 */
1921 private function get_hero_section_validation(array $settings): array {
1922 $field_details = [];
1923
1924 // Hero Title validation
1925 if (!empty($settings['hero_title'])) {
1926 $field_details[] = [
1927 'field' => 'hero_title',
1928 'label' => 'Hero title is configured.',
1929 'status' => 'valid',
1930 'icon' => ''
1931 ];
1932 } else {
1933 $field_details[] = [
1934 'field' => 'hero_title',
1935 'label' => 'Hero title recommended for better homepage presentation.',
1936 'status' => 'suggestion',
1937 'icon' => ''
1938 ];
1939 }
1940
1941 // Hero Subtitle validation (correct field name)
1942 if (!empty($settings['hero_subtitle'])) {
1943 $field_details[] = [
1944 'field' => 'hero_subtitle',
1945 'label' => 'Hero subtitle is configured.',
1946 'status' => 'valid',
1947 'icon' => ''
1948 ];
1949 } else {
1950 $field_details[] = [
1951 'field' => 'hero_subtitle',
1952 'label' => 'Hero subtitle recommended for better user engagement.',
1953 'status' => 'suggestion',
1954 'icon' => ''
1955 ];
1956 }
1957
1958 // CTA Text validation
1959 if (!empty($settings['hero_cta_text'])) {
1960 $field_details[] = [
1961 'field' => 'hero_cta_text',
1962 'label' => 'Call-to-action text is configured.',
1963 'status' => 'valid',
1964 'icon' => ''
1965 ];
1966 } else {
1967 $field_details[] = [
1968 'field' => 'hero_cta_text',
1969 'label' => 'Call-to-action text recommended for better conversion.',
1970 'status' => 'suggestion',
1971 'icon' => ''
1972 ];
1973 }
1974
1975 // CTA URL validation
1976 if (!empty($settings['hero_cta_url'])) {
1977 if (filter_var($settings['hero_cta_url'], FILTER_VALIDATE_URL) || strpos($settings['hero_cta_url'], '/') === 0) {
1978 $field_details[] = [
1979 'field' => 'hero_cta_url',
1980 'label' => 'Call-to-action URL is properly configured.',
1981 'status' => 'valid',
1982 'icon' => ''
1983 ];
1984 } else {
1985 $field_details[] = [
1986 'field' => 'hero_cta_url',
1987 'label' => 'Call-to-action URL format appears invalid.',
1988 'status' => 'warning',
1989 'icon' => ''
1990 ];
1991 }
1992 } else {
1993 $field_details[] = [
1994 'field' => 'hero_cta_url',
1995 'label' => 'Call-to-action URL recommended for better conversion.',
1996 'status' => 'suggestion',
1997 'icon' => ''
1998 ];
1999 }
2000
2001 // Hero Background Image validation
2002 if (!empty($settings['hero_background_image'])) {
2003 $field_details[] = [
2004 'field' => 'hero_background_image',
2005 'label' => 'Hero background image is configured.',
2006 'status' => 'valid',
2007 'icon' => ''
2008 ];
2009 } else {
2010 $field_details[] = [
2011 'field' => 'hero_background_image',
2012 'label' => 'Hero background image recommended for visual appeal.',
2013 'status' => 'suggestion',
2014 'icon' => ''
2015 ];
2016 }
2017
2018 // Site Logo validation (from Site Assets section)
2019 if (!empty($settings['logo_url'])) {
2020 if (filter_var($settings['logo_url'], FILTER_VALIDATE_URL)) {
2021 $field_details[] = [
2022 'field' => 'logo_url',
2023 'label' => 'Site logo is properly configured.',
2024 'status' => 'valid',
2025 'icon' => ''
2026 ];
2027 } else {
2028 $field_details[] = [
2029 'field' => 'logo_url',
2030 'label' => 'Site logo URL format appears invalid.',
2031 'status' => 'warning',
2032 'icon' => ''
2033 ];
2034 }
2035 } else {
2036 $field_details[] = [
2037 'field' => 'logo_url',
2038 'label' => 'Site logo recommended for branding and schema markup.',
2039 'status' => 'suggestion',
2040 'icon' => ''
2041 ];
2042 }
2043
2044 // Favicon validation
2045 if (!empty($settings['favicon_url'])) {
2046 $field_details[] = [
2047 'field' => 'favicon_url',
2048 'label' => 'Favicon is configured.',
2049 'status' => 'valid',
2050 'icon' => ''
2051 ];
2052 } else {
2053 $field_details[] = [
2054 'field' => 'favicon_url',
2055 'label' => 'Favicon recommended for browser tab identification.',
2056 'status' => 'suggestion',
2057 'icon' => ''
2058 ];
2059 }
2060
2061 // Apple Touch Icon validation
2062 if (!empty($settings['apple_touch_icon_url'])) {
2063 $field_details[] = [
2064 'field' => 'apple_touch_icon_url',
2065 'label' => 'Apple touch icon is configured.',
2066 'status' => 'valid',
2067 'icon' => ''
2068 ];
2069 } else {
2070 $field_details[] = [
2071 'field' => 'apple_touch_icon_url',
2072 'label' => 'Apple touch icon recommended for iOS devices.',
2073 'status' => 'suggestion',
2074 'icon' => ''
2075 ];
2076 }
2077
2078 return $field_details;
2079 }
2080
2081 /**
2082 * Get Title Formats validation
2083 *
2084 * @since 1.0.0
2085 *
2086 * @param array $settings Settings array to validate
2087 * @return array Title Formats validation results
2088 */
2089 private function get_title_formats_validation(array $settings): array {
2090 $field_details = [];
2091
2092 // Title Separator validation
2093 if (!empty($settings['title_separator'])) {
2094 $field_details[] = [
2095 'field' => 'title_separator',
2096 'label' => 'Title separator is properly configured.',
2097 'status' => 'valid',
2098 'icon' => ''
2099 ];
2100 } else {
2101 $field_details[] = [
2102 'field' => 'title_separator',
2103 'label' => 'Title separator is required.',
2104 'status' => 'error',
2105 'icon' => ''
2106 ];
2107 }
2108
2109 // Homepage Title validation
2110 if (!empty($settings['homepage_title'])) {
2111 $field_details[] = [
2112 'field' => 'homepage_title',
2113 'label' => 'Homepage title format is configured.',
2114 'status' => 'valid',
2115 'icon' => ''
2116 ];
2117 } else {
2118 $field_details[] = [
2119 'field' => 'homepage_title',
2120 'label' => 'Homepage title format recommended.',
2121 'status' => 'suggestion',
2122 'icon' => ''
2123 ];
2124 }
2125
2126 // Post Title validation
2127 if (!empty($settings['post_title'])) {
2128 $field_details[] = [
2129 'field' => 'post_title',
2130 'label' => 'Post title format is configured.',
2131 'status' => 'valid',
2132 'icon' => ''
2133 ];
2134 } else {
2135 $field_details[] = [
2136 'field' => 'post_title',
2137 'label' => 'Post title format recommended.',
2138 'status' => 'suggestion',
2139 'icon' => ''
2140 ];
2141 }
2142
2143 // Page Title validation
2144 if (!empty($settings['page_title'])) {
2145 $field_details[] = [
2146 'field' => 'page_title',
2147 'label' => 'Page title format is configured.',
2148 'status' => 'valid',
2149 'icon' => ''
2150 ];
2151 } else {
2152 $field_details[] = [
2153 'field' => 'page_title',
2154 'label' => 'Page title format recommended.',
2155 'status' => 'suggestion',
2156 'icon' => ''
2157 ];
2158 }
2159
2160 // Category Title validation
2161 if (!empty($settings['category_title'])) {
2162 $field_details[] = [
2163 'field' => 'category_title',
2164 'label' => 'Category title format is configured.',
2165 'status' => 'valid',
2166 'icon' => ''
2167 ];
2168 } else {
2169 $field_details[] = [
2170 'field' => 'category_title',
2171 'label' => 'Category title format recommended.',
2172 'status' => 'suggestion',
2173 'icon' => ''
2174 ];
2175 }
2176
2177 // Search Title validation
2178 if (!empty($settings['search_title'])) {
2179 $field_details[] = [
2180 'field' => 'search_title',
2181 'label' => 'Search title format is configured.',
2182 'status' => 'valid',
2183 'icon' => ''
2184 ];
2185 } else {
2186 $field_details[] = [
2187 'field' => 'search_title',
2188 'label' => 'Search title format recommended.',
2189 'status' => 'suggestion',
2190 'icon' => ''
2191 ];
2192 }
2193
2194 return $field_details;
2195 }
2196
2197 /**
2198 * Get Breadcrumbs validation
2199 *
2200 * @since 1.0.0
2201 *
2202 * @param array $settings Settings array to validate
2203 * @return array Breadcrumbs validation results
2204 */
2205 private function get_breadcrumbs_validation(array $settings): array {
2206 $field_details = [];
2207
2208 // Breadcrumbs enabled validation
2209 if (!empty($settings['breadcrumbs_enabled'])) {
2210 $field_details[] = [
2211 'field' => 'breadcrumbs_enabled',
2212 'label' => 'Breadcrumbs are enabled for better navigation.',
2213 'status' => 'valid',
2214 'icon' => ''
2215 ];
2216
2217 // Only validate other fields if breadcrumbs are enabled
2218 // Breadcrumb Type validation
2219 if (!empty($settings['breadcrumb_type'])) {
2220 $field_details[] = [
2221 'field' => 'breadcrumb_type',
2222 'label' => 'Breadcrumb type is properly configured.',
2223 'status' => 'valid',
2224 'icon' => ''
2225 ];
2226 } else {
2227 $field_details[] = [
2228 'field' => 'breadcrumb_type',
2229 'label' => 'Breadcrumb type selection is required.',
2230 'status' => 'error',
2231 'icon' => ''
2232 ];
2233 }
2234
2235 // Home Text validation
2236 if (!empty($settings['breadcrumb_home_text'])) {
2237 $field_details[] = [
2238 'field' => 'breadcrumb_home_text',
2239 'label' => 'Home breadcrumb text is configured.',
2240 'status' => 'valid',
2241 'icon' => ''
2242 ];
2243 } else {
2244 $field_details[] = [
2245 'field' => 'breadcrumb_home_text',
2246 'label' => 'Home breadcrumb text recommended for clarity.',
2247 'status' => 'suggestion',
2248 'icon' => ''
2249 ];
2250 }
2251
2252 // Breadcrumb Separator validation
2253 if (!empty($settings['breadcrumb_separator'])) {
2254 $field_details[] = [
2255 'field' => 'breadcrumb_separator',
2256 'label' => 'Breadcrumb separator is configured.',
2257 'status' => 'valid',
2258 'icon' => ''
2259 ];
2260 } else {
2261 $field_details[] = [
2262 'field' => 'breadcrumb_separator',
2263 'label' => 'Breadcrumb separator recommended for better formatting.',
2264 'status' => 'suggestion',
2265 'icon' => ''
2266 ];
2267 }
2268
2269 // Breadcrumb Prefix validation (optional)
2270 if (!empty($settings['breadcrumb_prefix'])) {
2271 $field_details[] = [
2272 'field' => 'breadcrumb_prefix',
2273 'label' => 'Breadcrumb prefix is configured.',
2274 'status' => 'valid',
2275 'icon' => ''
2276 ];
2277 } else {
2278 $field_details[] = [
2279 'field' => 'breadcrumb_prefix',
2280 'label' => 'Breadcrumb prefix is optional but can improve user guidance.',
2281 'status' => 'suggestion',
2282 'icon' => ''
2283 ];
2284 }
2285
2286 // Show Current Page validation
2287 $field_details[] = [
2288 'field' => 'show_current_page',
2289 'label' => isset($settings['show_current_page']) ?
2290 'Current page display preference is configured.' :
2291 'Current page display preference is set to default.',
2292 'status' => 'valid',
2293 'icon' => ''
2294 ];
2295 } else {
2296 $field_details[] = [
2297 'field' => 'breadcrumbs_enabled',
2298 'label' => 'Breadcrumbs recommended for better user experience and SEO.',
2299 'status' => 'suggestion',
2300 'icon' => ''
2301 ];
2302 }
2303
2304 return $field_details;
2305 }
2306
2307 /**
2308 * Validate local SEO settings
2309 *
2310 * @since 1.0.0
2311 *
2312 * @param array $settings Settings array to validate
2313 * @return array Local SEO validation results
2314 */
2315 private function validate_local_seo_settings(array $settings): array {
2316 $validation = [
2317 'valid' => true,
2318 'errors' => [],
2319 'warnings' => [],
2320 'suggestions' => []
2321 ];
2322
2323 // Business name is what makes the LocalBusiness schema useful, but it
2324 // cannot be a blocking error: the toggle is what reveals the business
2325 // fields, so requiring the name up front makes enabling Local SEO
2326 // impossible. The frontend already skips the output while the name is
2327 // empty (see Seo_Manager::output_local_seo_meta_tags()).
2328 if (empty($settings['business_name'])) {
2329 $validation['warnings'][] = 'Business name is missing - required before local business schema is output';
2330 } elseif (strlen($settings['business_name']) > 100) {
2331 $validation['warnings'][] = 'Business name is very long, consider shortening for better display';
2332 }
2333
2334 // Validate business address components (NAP consistency)
2335 $required_address_fields = [
2336 'business_address' => 'Business address',
2337 'business_city' => 'Business city',
2338 'business_state' => 'Business state/province',
2339 'business_country' => 'Business country'
2340 ];
2341
2342 foreach ($required_address_fields as $field => $label) {
2343 if (empty($settings[$field])) {
2344 $validation['warnings'][] = "{$label} is missing - important for NAP consistency and local search";
2345 }
2346 }
2347
2348 // Validate postal code (recommended)
2349 if (empty($settings['business_postal_code'])) {
2350 $validation['suggestions'][] = 'Add postal code for more precise location targeting';
2351 }
2352
2353 // Validate phone number
2354 if (empty($settings['business_phone'])) {
2355 $validation['warnings'][] = 'Business phone number is missing - important for local SEO and customer contact';
2356 } elseif (!$this->validate_phone_format($settings['business_phone'])) {
2357 $validation['suggestions'][] = 'Phone number format could be improved for consistency';
2358 }
2359
2360 // Validate email address
2361 if (!empty($settings['business_email']) && !is_email($settings['business_email'])) {
2362 $validation['errors'][] = 'Business email address format is invalid';
2363 $validation['valid'] = false;
2364 }
2365
2366 // Validate coordinates if provided
2367 if (!empty($settings['business_latitude']) || !empty($settings['business_longitude'])) {
2368 if (empty($settings['business_latitude']) || empty($settings['business_longitude'])) {
2369 $validation['warnings'][] = 'Both latitude and longitude are required for geo-location';
2370 } elseif (!$this->validate_coordinates($settings['business_latitude'], $settings['business_longitude'])) {
2371 $validation['errors'][] = 'Invalid latitude or longitude coordinates';
2372 $validation['valid'] = false;
2373 }
2374 } else {
2375 $validation['suggestions'][] = 'Add latitude and longitude coordinates for precise location targeting';
2376 }
2377
2378 // Validate business hours
2379 if (!empty($settings['business_hours']) && is_array($settings['business_hours'])) {
2380 $hours_validation = $this->validate_business_hours($settings['business_hours']);
2381 if (!$hours_validation['valid']) {
2382 $validation['warnings'] = array_merge($validation['warnings'], $hours_validation['warnings']);
2383 }
2384 } else {
2385 $validation['suggestions'][] = 'Add business hours to improve local search visibility';
2386 }
2387
2388 // Validate business type
2389 if (empty($settings['business_type'])) {
2390 $validation['suggestions'][] = 'Select a specific business type for better schema markup';
2391 }
2392
2393 return $validation;
2394 }
2395
2396 /**
2397 * Validate phone number format
2398 *
2399 * @since 1.0.0
2400 *
2401 * @param string $phone_number Phone number to validate
2402 * @return bool True if format is acceptable
2403 */
2404 private function validate_phone_format(string $phone_number): bool {
2405 // Remove all non-numeric characters except + for international numbers
2406 $cleaned = preg_replace('/[^\d+]/', '', $phone_number);
2407
2408 // Check for common valid formats
2409 return (
2410 preg_match('/^\d{10}$/', $cleaned) || // 10 digits (US)
2411 preg_match('/^1\d{10}$/', $cleaned) || // 1 + 10 digits (US with country code)
2412 preg_match('/^\+\d{7,15}$/', $cleaned) // International format
2413 );
2414 }
2415
2416 /**
2417 * Get output data for frontend rendering (implements interface)
2418 *
2419 * @since 1.0.0
2420 *
2421 * @param string $context_type The context type
2422 * @param int|null $context_id Optional. Context ID
2423 * @return array Output data ready for frontend rendering
2424 */
2425 public function get_output_data(string $context_type, ?int $context_id): array {
2426 $settings = $this->get_settings($context_type, $context_id);
2427
2428 $output = [
2429 'title' => '',
2430 'breadcrumbs' => [],
2431 'identity' => [],
2432 'robots_txt' => [],
2433 'enabled' => $settings['enabled'] ?? true
2434 ];
2435
2436 if (!$output['enabled']) {
2437 return $output;
2438 }
2439
2440 // Generate title for current context
2441 $title_data = $this->extract_title_data($context_type, $context_id);
2442 $output['title'] = $this->generate_title(
2443 $settings['title_template'] ?? 'default',
2444 $title_data,
2445 $context_type
2446 );
2447
2448 // Generate breadcrumbs if enabled
2449 if (!empty($settings['breadcrumbs_enabled'])) {
2450 $breadcrumb_options = [
2451 'context_type' => $context_type,
2452 'context_id' => $context_id
2453 ];
2454 $output['breadcrumbs'] = $this->generate_breadcrumbs(
2455 $settings['breadcrumb_type'] ?? 'hierarchical',
2456 $breadcrumb_options
2457 );
2458 }
2459
2460 // Get site identity data
2461 $output['identity'] = $this->get_site_identity_data($settings);
2462
2463 // Get robots.txt data if enabled
2464 if (!empty($settings['robots_txt_enabled'])) {
2465 $output['robots_txt'] = $this->generate_robots_txt($settings['custom_robots_rules'] ?? []);
2466 }
2467
2468 return $output;
2469 }
2470
2471 /**
2472 * Keys the Site Identity screens store beyond the 16 defaults.
2473 *
2474 * Title formats, breadcrumb configuration, the hero fields, the business
2475 * block and the wizard's identity fields are all real settings written by
2476 * this manager, none of which get_default_settings() names — it seeds only
2477 * the values a fresh install needs. Gating on defaults alone would stop
2478 * every one of them saving (#452).
2479 *
2480 * @since 2.0.1
2481 *
2482 * @return string[]
2483 */
2484 protected function additional_setting_keys(): array {
2485 return [
2486 // Title formats, one per context.
2487 'homepage_title', 'post_title', 'page_title', 'category_title',
2488 'tag_title', 'author_title', 'search_title', 'archive_title',
2489 // Breadcrumbs.
2490 'breadcrumb_prefix', 'show_current_page',
2491 // Identity, as written by the setup wizard and the importers.
2492 'alternate_name', 'identity_type', 'represents',
2493 'default_meta_description', 'default_social_image',
2494 'social_media_accounts',
2495 // Schema toggles that live on this screen.
2496 'organization_schema', 'knowledge_graph',
2497 // Robots rules composed by the Robots.txt panel.
2498 'custom_robots_rules',
2499 // Hero section.
2500 'hero_title', 'hero_subtitle', 'hero_cta_text', 'hero_cta_url',
2501 'hero_background_image',
2502 // Local SEO / business details.
2503 'local_seo_enabled', 'business_type', 'business_name',
2504 'business_address', 'business_city', 'business_state',
2505 'business_postal_code', 'business_country', 'business_phone',
2506 'business_email', 'business_latitude', 'business_longitude',
2507 'business_price_range', 'business_hours',
2508 ];
2509 }
2510
2511 /**
2512 * Get default settings for a context type (implements interface)
2513 *
2514 * @since 1.0.0
2515 *
2516 * @param string $context_type The context type to get defaults for
2517 * @return array Default settings array
2518 */
2519 public function get_default_settings(string $context_type): array {
2520 $defaults = [
2521 'enabled' => true,
2522 'title_template' => 'default',
2523 'title_separator' => 'pipe',
2524 'site_name' => get_bloginfo('name'),
2525 'site_description' => get_bloginfo('description'),
2526 'tagline' => get_bloginfo('description'),
2527 'breadcrumbs_enabled' => true,
2528 'breadcrumb_type' => 'hierarchical',
2529 'breadcrumb_home_text' => 'Home',
2530 'breadcrumb_separator' => '>',
2531 'robots_txt_enabled' => true,
2532 'allow_search_engines' => true,
2533 'robots_txt_content' => '',
2534 'logo_url' => '',
2535 'favicon_url' => '',
2536 'apple_touch_icon_url' => ''
2537 ];
2538
2539 // Context-specific defaults
2540 switch ($context_type) {
2541 case 'site':
2542 // Site-wide defaults are already set above
2543 break;
2544 case 'post':
2545 $defaults['title_template'] = 'default';
2546 $defaults['breadcrumb_type'] = 'taxonomy';
2547 break;
2548 case 'page':
2549 $defaults['title_template'] = 'default';
2550 $defaults['breadcrumb_type'] = 'hierarchical';
2551 break;
2552 case 'product':
2553 $defaults['title_template'] = 'category';
2554 $defaults['breadcrumb_type'] = 'taxonomy';
2555 break;
2556 }
2557
2558 return $defaults;
2559 }
2560
2561 /**
2562 * Get settings schema definition (implements interface)
2563 *
2564 * @since 1.0.0
2565 *
2566 * @param string $context_type The context type to get schema for
2567 * @return array Settings schema definition
2568 */
2569 public function get_settings_schema(string $context_type): array {
2570 return [
2571 'enabled' => [
2572 'type' => 'boolean',
2573 'title' => 'Enable Site Identity',
2574 'description' => 'Enable site identity management features',
2575 'default' => true
2576 ],
2577 'title_template' => [
2578 'type' => 'string',
2579 'title' => 'Title Template',
2580 'description' => 'Template for generating page titles',
2581 'enum' => array_keys($this->title_templates),
2582 'default' => 'default'
2583 ],
2584 'title_separator' => [
2585 'type' => 'string',
2586 'title' => 'Title Separator',
2587 'description' => 'Character used to separate title elements',
2588 'enum' => array_keys(self::$title_separators),
2589 'default' => 'pipe'
2590 ],
2591 'site_name' => [
2592 'type' => 'string',
2593 'title' => 'Site Name',
2594 'description' => 'Official name of the website',
2595 'maxLength' => 60,
2596 'default' => get_bloginfo('name')
2597 ],
2598 'site_description' => [
2599 'type' => 'string',
2600 'title' => 'Site Description',
2601 'description' => 'Brief description of the website',
2602 'maxLength' => 160,
2603 'default' => get_bloginfo('description')
2604 ],
2605 'breadcrumbs_enabled' => [
2606 'type' => 'boolean',
2607 'title' => 'Enable Breadcrumbs',
2608 'description' => 'Enable breadcrumb navigation generation',
2609 'default' => true
2610 ],
2611 'breadcrumb_type' => [
2612 'type' => 'string',
2613 'title' => 'Breadcrumb Type',
2614 'description' => 'Type of breadcrumb navigation to generate',
2615 'enum' => array_keys($this->breadcrumb_types),
2616 'default' => 'hierarchical'
2617 ],
2618 'robots_txt_enabled' => [
2619 'type' => 'boolean',
2620 'title' => 'Enable Robots.txt Management',
2621 'description' => 'Enable automatic robots.txt generation and management',
2622 'default' => true
2623 ],
2624 'logo_url' => [
2625 'type' => 'string',
2626 'title' => 'Logo URL',
2627 'description' => 'URL of the site logo image',
2628 'format' => 'uri',
2629 'default' => ''
2630 ],
2631 'favicon_url' => [
2632 'type' => 'string',
2633 'title' => 'Favicon URL',
2634 'description' => 'URL of the site favicon',
2635 'format' => 'uri',
2636 'default' => ''
2637 ]
2638 ];
2639 }
2640
2641 /**
2642 * Prepare title placeholders for replacement
2643 *
2644 * @since 1.0.0
2645 *
2646 * @param array $data Content data
2647 * @param string $context Context type
2648 * @param array $settings Site settings
2649 * @return array Placeholder values
2650 */
2651 private function prepare_title_placeholders(array $data, string $context, array $settings): array {
2652 $placeholders = [
2653 '%title%' => $data['title'] ?? '',
2654 // `?:` rather than `??`: these are persisted as '' rather than left
2655 // unset, and '' is not null, so the null-coalesce never reached the
2656 // WordPress fallback (#398).
2657 '%sitename%' => ($settings['site_name'] ?? '') ?: get_bloginfo('name'),
2658 '%tagline%' => ($settings['tagline'] ?? '') ?: get_bloginfo('description'),
2659 '%separator%' => '', // Will be replaced with actual separator
2660 '%category%' => '',
2661 '%author%' => '',
2662 '%date%' => '',
2663 '%searchterm%' => ''
2664 ];
2665
2666 // Context-specific placeholders
2667 switch ($context) {
2668 case 'post':
2669 case 'page':
2670 case 'product':
2671 if (!empty($data['context_id'])) {
2672 $post = get_post($data['context_id']);
2673 if ($post) {
2674 $placeholders['%title%'] = get_the_title($post);
2675 $placeholders['%author%'] = get_the_author_meta('display_name', $post->post_author);
2676 $placeholders['%date%'] = get_the_date('F j, Y', $post);
2677
2678 // Get primary category
2679 $categories = get_the_category($post->ID);
2680 if (!empty($categories)) {
2681 $placeholders['%category%'] = $categories[0]->name;
2682 }
2683 }
2684 }
2685 break;
2686 case 'search':
2687 $placeholders['%searchterm%'] = get_search_query();
2688 break;
2689 }
2690
2691 return $placeholders;
2692 }
2693
2694 /**
2695 * Replace title placeholders with actual values
2696 *
2697 * @since 1.0.0
2698 *
2699 * @param string $template Title template
2700 * @param array $placeholders Placeholder values
2701 * @param string $separator Title separator
2702 * @return string Processed title
2703 */
2704 private function replace_title_placeholders(string $template, array $placeholders, string $separator): string {
2705 // Replace separator placeholder
2706 $placeholders['%separator%'] = $separator;
2707
2708 // Replace all placeholders
2709 $title = str_replace(array_keys($placeholders), array_values($placeholders), $template);
2710
2711 // Clean up empty placeholders and extra separators
2712 $title = preg_replace('/\s*' . preg_quote($separator, '/') . '\s*' . preg_quote($separator, '/') . '\s*/', ' ' . $separator . ' ', $title);
2713 $title = preg_replace('/^\s*' . preg_quote($separator, '/') . '\s*|\s*' . preg_quote($separator, '/') . '\s*$/', '', $title);
2714
2715 return trim($title);
2716 }
2717
2718 /**
2719 * Get title separator symbol
2720 *
2721 * @since 1.0.0
2722 *
2723 * @param string $separator_key Separator key
2724 * @return string Separator symbol
2725 */
2726 private function get_title_separator(string $separator_key): string {
2727 return self::$title_separators[$separator_key]['symbol'] ?? self::$title_separators['pipe']['symbol'];
2728 }
2729
2730 /**
2731 * Optimize title for SEO
2732 *
2733 * @since 1.0.0
2734 *
2735 * @param string $title Title to optimize
2736 * @param string $context Context type
2737 * @return string Optimized title
2738 */
2739 private function optimize_title(string $title, string $context): string {
2740 // Remove extra whitespace
2741 $title = preg_replace('/\s+/', ' ', $title);
2742 $title = trim($title);
2743
2744 // Ensure title is not too long (60 characters max for SEO)
2745 if (strlen($title) > 60) {
2746 // Try to truncate at word boundary
2747 $title = wp_trim_words($title, 8, '...');
2748 if (strlen($title) > 60) {
2749 $title = substr($title, 0, 57) . '...';
2750 }
2751 }
2752
2753 // Ensure title is not empty
2754 if (empty($title)) {
2755 $title = get_bloginfo('name');
2756 }
2757
2758 return $title;
2759 }
2760
2761 /**
2762 * Extract title data from context
2763 *
2764 * @since 1.0.0
2765 *
2766 * @param string $context_type Context type
2767 * @param int|null $context_id Context ID
2768 * @return array Title data
2769 */
2770 private function extract_title_data(string $context_type, ?int $context_id): array {
2771 $data = [
2772 'title' => '',
2773 'context_type' => $context_type,
2774 'context_id' => $context_id
2775 ];
2776
2777 switch ($context_type) {
2778 case 'site':
2779 $data['title'] = get_bloginfo('name');
2780 break;
2781 case 'post':
2782 case 'page':
2783 case 'product':
2784 if ($context_id) {
2785 $data['title'] = get_the_title($context_id);
2786 }
2787 break;
2788 case 'search':
2789 $data['title'] = 'Search Results';
2790 break;
2791 case '404':
2792 $data['title'] = 'Page Not Found';
2793 break;
2794 }
2795
2796 return $data;
2797 }
2798
2799 /**
2800 * Generate hierarchical breadcrumbs
2801 *
2802 * @since 1.0.0
2803 *
2804 * @param array $options Breadcrumb options
2805 * @return array Breadcrumb items
2806 */
2807 private function generate_hierarchical_breadcrumbs(array $options): array {
2808 $breadcrumbs = [];
2809
2810 // Add home breadcrumb
2811 $breadcrumbs[] = [
2812 'title' => 'Home',
2813 'url' => home_url(),
2814 'position' => 1
2815 ];
2816
2817 $context_type = $options['context_type'] ?? '';
2818 $context_id = $options['context_id'] ?? null;
2819
2820 if ($context_type === 'post' || $context_type === 'page' || $context_type === 'product') {
2821 if ($context_id) {
2822 $post = get_post($context_id);
2823 if ($post) {
2824 // Add parent pages for hierarchical content
2825 $ancestors = get_post_ancestors($post);
2826 $ancestors = array_reverse($ancestors);
2827
2828 $position = 2;
2829 foreach ($ancestors as $ancestor_id) {
2830 $breadcrumbs[] = [
2831 'title' => get_the_title($ancestor_id),
2832 'url' => get_permalink($ancestor_id),
2833 'position' => $position++
2834 ];
2835 }
2836
2837 // Add current page
2838 $breadcrumbs[] = [
2839 'title' => get_the_title($post),
2840 'url' => get_permalink($post),
2841 'position' => $position,
2842 'current' => true
2843 ];
2844 }
2845 }
2846 }
2847
2848 return $breadcrumbs;
2849 }
2850
2851 /**
2852 * Generate taxonomy-based breadcrumbs
2853 *
2854 * @since 1.0.0
2855 *
2856 * @param array $options Breadcrumb options
2857 * @return array Breadcrumb items
2858 */
2859 private function generate_taxonomy_breadcrumbs(array $options): array {
2860 $breadcrumbs = [];
2861
2862 // Add home breadcrumb
2863 $breadcrumbs[] = [
2864 'title' => 'Home',
2865 'url' => home_url(),
2866 'position' => 1
2867 ];
2868
2869 $context_type = $options['context_type'] ?? '';
2870 $context_id = $options['context_id'] ?? null;
2871
2872 if (($context_type === 'post' || $context_type === 'product') && $context_id) {
2873 $post = get_post($context_id);
2874 if ($post) {
2875 // Get primary category
2876 $categories = get_the_category($post->ID);
2877 if (!empty($categories)) {
2878 $primary_category = $categories[0];
2879
2880 // Add category hierarchy
2881 $category_ancestors = get_ancestors($primary_category->term_id, 'category');
2882 $category_ancestors = array_reverse($category_ancestors);
2883
2884 $position = 2;
2885 foreach ($category_ancestors as $ancestor_id) {
2886 $ancestor = get_category($ancestor_id);
2887 $breadcrumbs[] = [
2888 'title' => $ancestor->name,
2889 'url' => get_category_link($ancestor_id),
2890 'position' => $position++
2891 ];
2892 }
2893
2894 // Add primary category
2895 $breadcrumbs[] = [
2896 'title' => $primary_category->name,
2897 'url' => get_category_link($primary_category->term_id),
2898 'position' => $position++
2899 ];
2900 }
2901
2902 // Add current post
2903 $breadcrumbs[] = [
2904 'title' => get_the_title($post),
2905 'url' => get_permalink($post),
2906 'position' => $position,
2907 'current' => true
2908 ];
2909 }
2910 }
2911
2912 return $breadcrumbs;
2913 }
2914
2915 /**
2916 * Generate path-based breadcrumbs
2917 *
2918 * @since 1.0.0
2919 *
2920 * @param array $options Breadcrumb options
2921 * @return array Breadcrumb items
2922 */
2923 private function generate_path_breadcrumbs(array $options): array {
2924 $breadcrumbs = [];
2925
2926 // Add home breadcrumb
2927 $breadcrumbs[] = [
2928 'title' => 'Home',
2929 'url' => home_url(),
2930 'position' => 1
2931 ];
2932
2933 // Get current URL path
2934 $current_url = home_url(add_query_arg([]));
2935 $path = wp_parse_url($current_url, PHP_URL_PATH);
2936 $path_parts = array_filter(explode('/', trim($path, '/')));
2937
2938 $position = 2;
2939 $cumulative_path = '';
2940
2941 foreach ($path_parts as $part) {
2942 $cumulative_path .= '/' . $part;
2943 $url = home_url($cumulative_path);
2944
2945 // Try to get a meaningful title
2946 $title = ucwords(str_replace(['-', '_'], ' ', $part));
2947
2948 $breadcrumbs[] = [
2949 'title' => $title,
2950 'url' => $url,
2951 'position' => $position++,
2952 'current' => $cumulative_path === $path
2953 ];
2954 }
2955
2956 return $breadcrumbs;
2957 }
2958
2959 /**
2960 * Generate custom breadcrumbs
2961 *
2962 * @since 1.0.0
2963 *
2964 * @param array $options Breadcrumb options
2965 * @return array Breadcrumb items
2966 */
2967 private function generate_custom_breadcrumbs(array $options): array {
2968 // Return custom breadcrumbs if provided in options
2969 return $options['custom_breadcrumbs'] ?? [];
2970 }
2971
2972 /**
2973 * Generate breadcrumb schema markup
2974 *
2975 * @since 1.0.0
2976 *
2977 * @param array $breadcrumb_items Breadcrumb items
2978 * @return array Schema markup
2979 */
2980 private function generate_breadcrumb_schema(array $breadcrumb_items): array {
2981 $schema = [
2982 '@context' => 'https://schema.org',
2983 '@type' => 'BreadcrumbList',
2984 'itemListElement' => []
2985 ];
2986
2987 foreach ($breadcrumb_items as $item) {
2988 $schema['itemListElement'][] = [
2989 '@type' => 'ListItem',
2990 'position' => $item['position'],
2991 'name' => $item['title'],
2992 'item' => $item['url']
2993 ];
2994 }
2995
2996 return $schema;
2997 }
2998
2999 /**
3000 * Generate breadcrumb HTML
3001 *
3002 * @since 1.0.0
3003 *
3004 * @param array $breadcrumb_items Breadcrumb items
3005 * @param array $settings Breadcrumb settings
3006 * @return string HTML output
3007 */
3008 private function generate_breadcrumb_html(array $breadcrumb_items, array $settings): string {
3009 if (empty($breadcrumb_items)) {
3010 return '';
3011 }
3012
3013 $separator = $settings['separator'] ?? '>';
3014 $html = '<nav class="thinkrank-breadcrumbs" aria-label="Breadcrumb">';
3015 $html .= '<ol class="breadcrumb-list">';
3016
3017 foreach ($breadcrumb_items as $item) {
3018 $html .= '<li class="breadcrumb-item">';
3019
3020 if (!empty($item['current'])) {
3021 $html .= '<span class="breadcrumb-current" aria-current="page">' . esc_html($item['title']) . '</span>';
3022 } else {
3023 $html .= '<a href="' . esc_url($item['url']) . '">' . esc_html($item['title']) . '</a>';
3024 }
3025
3026 if ($item['position'] < count($breadcrumb_items)) {
3027 $html .= ' <span class="breadcrumb-separator">' . esc_html($separator) . '</span> ';
3028 }
3029
3030 $html .= '</li>';
3031 }
3032
3033 $html .= '</ol>';
3034 $html .= '</nav>';
3035
3036 return $html;
3037 }
3038
3039 /**
3040 * Generate default robots.txt rules
3041 *
3042 * @since 1.0.0
3043 *
3044 * @param array $settings Robots.txt settings
3045 * @return array Default rules
3046 */
3047 private function generate_default_robots_rules(array $settings): array {
3048 $rules = [];
3049
3050 // Full block: when the admin turns off "Allow Search Engines" or enables
3051 // WordPress's "Discourage search engines" (Settings → Reading, stored as
3052 // blog_public=0), serve a robots.txt that disallows everything rather
3053 // than the default per-path rules — otherwise the toggle has no effect.
3054 $allow_search = $settings['allow_search_engines'] ?? true;
3055 if (empty($allow_search) || !get_option('blog_public')) {
3056 $rules[] = ['directive' => 'user_agent', 'value' => '*'];
3057 $rules[] = ['directive' => 'disallow', 'value' => '/'];
3058 return $rules;
3059 }
3060
3061 // Default user agent rule
3062 $rules[] = [
3063 'directive' => 'user_agent',
3064 'value' => '*'
3065 ];
3066
3067 // WordPress core disallows.
3068 //
3069 // Deliberately minimal, matching Yoast/Rank Math defaults. We do NOT
3070 // block /wp-includes/, /wp-content/plugins/, or /wp-content/themes/:
3071 // those paths serve the CSS and JS Google must fetch to render pages,
3072 // and blocking them causes "blocked resource" warnings and can hurt
3073 // rankings. /wp-json/ is left crawlable for the same reason (embeds,
3074 // oEmbed, structured previews). Only wp-admin (bar admin-ajax) and the
3075 // handful of non-content endpoints below are disallowed.
3076 $default_disallows = [
3077 '/wp-admin/',
3078 '/xmlrpc.php',
3079 '/readme.html',
3080 '/license.txt',
3081 ];
3082
3083 // WooCommerce: keep cart/checkout/account and add-to-cart query URLs out
3084 // of the index to avoid crawl noise and duplicate/session URLs (parity
3085 // with Rank Math's WooCommerce robots defaults).
3086 if (class_exists('WooCommerce')) {
3087 $default_disallows[] = '/cart/';
3088 $default_disallows[] = '/checkout/';
3089 $default_disallows[] = '/my-account/';
3090 $default_disallows[] = '/*add-to-cart=*';
3091 }
3092
3093 foreach ($default_disallows as $disallow) {
3094 $rules[] = [
3095 'directive' => 'disallow',
3096 'value' => $disallow
3097 ];
3098 }
3099
3100 // Allow specific files
3101 $default_allows = [
3102 '/wp-admin/admin-ajax.php',
3103 '/wp-content/uploads/'
3104 ];
3105
3106 foreach ($default_allows as $allow) {
3107 $rules[] = [
3108 'directive' => 'allow',
3109 'value' => $allow
3110 ];
3111 }
3112
3113 // Add sitemap URLs from sitemap settings (auto-sync)
3114 $sitemap_urls = $this->get_sitemap_urls_for_robots();
3115
3116 foreach ($sitemap_urls as $sitemap_url) {
3117 if (!empty($sitemap_url)) {
3118 $rules[] = [
3119 'directive' => 'sitemap',
3120 'value' => $sitemap_url
3121 ];
3122 }
3123 }
3124
3125 // Add crawl delay if specified
3126 if (!empty($settings['crawl_delay'])) {
3127 $rules[] = [
3128 'directive' => 'crawl_delay',
3129 'value' => (int) $settings['crawl_delay']
3130 ];
3131 }
3132
3133 return $rules;
3134 }
3135
3136 /**
3137 * Get sitemap URLs from sitemap settings for robots.txt integration
3138 *
3139 * @since 1.0.0
3140 * @return array Array of sitemap URLs
3141 */
3142 private function get_sitemap_urls_for_robots(): array {
3143 try {
3144 // Get sitemap settings
3145 $sitemap_generator = new \ThinkRank\SEO\Sitemap_Generator();
3146 $sitemap_settings = $sitemap_generator->get_settings('site');
3147
3148 // If sitemap is disabled, return default
3149 if (empty($sitemap_settings['enabled'])) {
3150 return [home_url('/sitemap.xml')];
3151 }
3152
3153 $sitemap_urls = [];
3154 $site_url = home_url();
3155
3156 // Extract enabled sitemap URLs. When the index is enabled it is the
3157 // only entry worth advertising: every child sitemap is already
3158 // listed inside it, so naming them again in robots.txt is pure
3159 // redundancy and drifts out of date as soon as a post type is added.
3160 $index_url = '';
3161 if (!empty($sitemap_settings['sitemap_urls']) && is_array($sitemap_settings['sitemap_urls'])) {
3162 foreach ($sitemap_settings['sitemap_urls'] as $sitemap) {
3163 if (empty($sitemap['enabled']) || empty($sitemap['url'])) {
3164 continue;
3165 }
3166
3167 if (($sitemap['type'] ?? '') === 'index') {
3168 $index_url = $site_url . $sitemap['url'];
3169 continue;
3170 }
3171
3172 $sitemap_urls[] = $site_url . $sitemap['url'];
3173 }
3174 }
3175
3176 if ($index_url !== '') {
3177 // The index alone — it covers the children and, on a segmented
3178 // install, the local business sitemap too.
3179 return [$index_url];
3180 }
3181
3182 // Fallback to default if no URLs found
3183 if (empty($sitemap_urls)) {
3184 $sitemap_urls[] = home_url('/sitemap.xml');
3185 }
3186
3187 // No index on this install, so the local business sitemap has no
3188 // other discovery path — advertise it directly.
3189 if (file_exists(ABSPATH . 'local-sitemap.xml')) {
3190 $local_url = home_url('/local-sitemap.xml');
3191 if (!in_array($local_url, $sitemap_urls, true)) {
3192 $sitemap_urls[] = $local_url;
3193 }
3194 }
3195
3196 return $sitemap_urls;
3197 } catch (\Exception $e) {
3198 // Fallback to default on error
3199 return [home_url('/sitemap.xml')];
3200 }
3201 }
3202
3203 /**
3204 * Validate robots.txt rules
3205 *
3206 * @since 1.0.0
3207 *
3208 * @param array $rules Rules to validate
3209 * @return array Validation results
3210 */
3211 private function validate_robots_rules(array $rules): array {
3212 $validation = [
3213 'valid' => true,
3214 'errors' => [],
3215 'warnings' => [],
3216 'suggestions' => []
3217 ];
3218
3219 $has_user_agent = false;
3220
3221 foreach ($rules as $rule) {
3222 $directive = $rule['directive'] ?? '';
3223 $value = $rule['value'] ?? '';
3224
3225 // Check if directive is valid
3226 if (!isset($this->robots_directives[$directive])) {
3227 $validation['errors'][] = "Unknown robots.txt directive: {$directive}";
3228 $validation['valid'] = false;
3229 continue;
3230 }
3231
3232 // Check for required user-agent
3233 if ($directive === 'user_agent') {
3234 $has_user_agent = true;
3235 }
3236
3237 // Validate directive-specific rules
3238 switch ($directive) {
3239 case 'disallow':
3240 case 'allow':
3241 if (!str_starts_with($value, '/')) {
3242 $validation['warnings'][] = "Path '{$value}' should start with '/'";
3243 }
3244 break;
3245 case 'sitemap':
3246 if (!filter_var($value, FILTER_VALIDATE_URL)) {
3247 $validation['errors'][] = "Invalid sitemap URL: {$value}";
3248 $validation['valid'] = false;
3249 }
3250 break;
3251 case 'crawl_delay':
3252 if (!is_numeric($value) || $value < 0) {
3253 $validation['errors'][] = "Crawl delay must be a positive number";
3254 $validation['valid'] = false;
3255 }
3256 break;
3257 }
3258 }
3259
3260 if (!$has_user_agent) {
3261 $validation['errors'][] = 'robots.txt must include at least one User-agent directive';
3262 $validation['valid'] = false;
3263 }
3264
3265 return $validation;
3266 }
3267
3268 /**
3269 * Build robots.txt content from rules
3270 *
3271 * @since 1.0.0
3272 *
3273 * @param array $rules Robots.txt rules
3274 * @return string Robots.txt content
3275 */
3276 private function build_robots_txt_content(array $rules): string {
3277 // Body only — no header. The "# generated by ThinkRank SEO" + timestamp
3278 // block is added at render time (see robots_txt_header), so it never
3279 // gets baked into the stored/editable content and can't show a stale
3280 // timestamp on every update.
3281 $content = '';
3282
3283 $current_user_agent = '';
3284 $sitemap_started = false;
3285
3286 foreach ($rules as $rule) {
3287 $directive = $rule['directive'] ?? '';
3288 $value = $rule['value'] ?? '';
3289
3290 switch ($directive) {
3291 case 'user_agent':
3292 if ($current_user_agent !== $value) {
3293 $content .= "\nUser-agent: {$value}\n";
3294 $current_user_agent = $value;
3295 }
3296 break;
3297 case 'disallow':
3298 $content .= "Disallow: {$value}\n";
3299 break;
3300 case 'allow':
3301 $content .= "Allow: {$value}\n";
3302 break;
3303 case 'crawl_delay':
3304 $content .= "Crawl-delay: {$value}\n";
3305 break;
3306 case 'sitemap':
3307 // One blank line separates the Sitemap block from the
3308 // preceding group, and none appear inside it. A blank line
3309 // terminates a record in the robots.txt grammar, so putting
3310 // one between every directive was invalid formatting.
3311 if (!$sitemap_started) {
3312 $content .= "\n";
3313 $sitemap_started = true;
3314 }
3315 $content .= "Sitemap: {$value}\n";
3316 break;
3317 }
3318 }
3319
3320 return ltrim($content, "\n");
3321 }
3322
3323 /**
3324 * Parse a robots.txt body back into the {directive, value} rule shape.
3325 *
3326 * generate_robots_txt() returns `rules` alongside `content`, but callers
3327 * replace `content` with the body actually being served (a stored override
3328 * or a physical file). The generated rules then described something the
3329 * response no longer contained. Re-deriving them from the served body keeps
3330 * the two halves of the payload describing the same document.
3331 *
3332 * @since 2.0.1
3333 *
3334 * @param string $content Robots.txt body (header optional).
3335 * @return array<int, array{directive: string, value: string}> Parsed rules.
3336 */
3337 public function parse_robots_txt_rules(string $content): array {
3338 $map = [
3339 'user-agent' => 'user_agent',
3340 'disallow' => 'disallow',
3341 'allow' => 'allow',
3342 'crawl-delay' => 'crawl_delay',
3343 'sitemap' => 'sitemap',
3344 ];
3345
3346 $rules = [];
3347
3348 foreach (preg_split('/\r\n|\r|\n/', $this->strip_robots_header($content)) as $line) {
3349 $line = trim($line);
3350
3351 // Blank lines separate groups and `#` starts a comment; neither is
3352 // a rule.
3353 if ($line === '' || str_starts_with($line, '#')) {
3354 continue;
3355 }
3356
3357 $parts = explode(':', $line, 2);
3358 if (count($parts) !== 2) {
3359 continue;
3360 }
3361
3362 $field = strtolower(trim($parts[0]));
3363 if (!isset($map[$field])) {
3364 continue;
3365 }
3366
3367 $rules[] = [
3368 'directive' => $map[$field],
3369 // Sitemap values are absolute URLs and contain the `:` the
3370 // limited explode above deliberately preserved.
3371 'value' => trim($parts[1]),
3372 ];
3373 }
3374
3375 return $rules;
3376 }
3377
3378 /**
3379 * The auto-generated header prepended to the served robots.txt.
3380 *
3381 * Kept separate from the body so it is only ever added at render time with
3382 * a fresh timestamp, never stored or shown in the editable textarea.
3383 *
3384 * @return string
3385 */
3386 private function robots_txt_header(): string {
3387 return "# Robots.txt generated by ThinkRank SEO\n"
3388 . "# " . gmdate('Y-m-d H:i:s') . " UTC\n\n";
3389 }
3390
3391 /**
3392 * Strip our auto-generated header from a robots.txt string.
3393 *
3394 * Used when surfacing existing content for editing so the header/timestamp
3395 * doesn't round-trip back into storage.
3396 *
3397 * @param string $content Raw robots.txt content.
3398 * @return string Body without the ThinkRank header.
3399 */
3400 private function strip_robots_header(string $content): string {
3401 $pattern = '/^# Robots\.txt generated by ThinkRank SEO\r?\n# [^\r\n]* UTC\r?\n\r?\n/';
3402 return trim((string) preg_replace($pattern, '', $content, 1));
3403 }
3404
3405 /**
3406 * The body that should populate the editor for the current site.
3407 *
3408 * Prefers what is actually being served: the physical file if one exists
3409 * (header stripped), otherwise the effective body. This is what the admin
3410 * screen shows so the textarea is never blank while /robots.txt has content.
3411 *
3412 * @return string
3413 */
3414 public function get_served_robots_body(): string {
3415 $settings = $this->get_settings('site');
3416
3417 $custom = trim((string) ($settings['robots_txt_content'] ?? ''));
3418 if ($custom !== '') {
3419 return $this->strip_robots_header($custom);
3420 }
3421
3422 $robots_file = ABSPATH . 'robots.txt';
3423 if (file_exists($robots_file)) {
3424 // 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.
3425 $raw = (string) @file_get_contents($robots_file);
3426 if ($raw !== '') {
3427 return $this->strip_robots_header($raw);
3428 }
3429 }
3430
3431 return trim($this->generate_robots_txt()['content']);
3432 }
3433 private function get_site_identity_data(array $settings): array {
3434 return [
3435 'site_name' => $settings['site_name'] ?? get_bloginfo('name'),
3436 'site_description' => $settings['site_description'] ?? get_bloginfo('description'),
3437 'tagline' => $settings['tagline'] ?? get_bloginfo('description'),
3438 'logo_url' => $settings['logo_url'] ?? '',
3439 'favicon_url' => $settings['favicon_url'] ?? '',
3440 'apple_touch_icon_url' => $settings['apple_touch_icon_url'] ?? ''
3441 ];
3442 }
3443
3444 /**
3445 * Optimize individual identity element
3446 *
3447 * @since 1.0.0
3448 *
3449 * @param string $element Element name
3450 * @param mixed $value Element value
3451 * @param array $config Element configuration
3452 * @return array Optimization results
3453 */
3454 private function optimize_identity_element(string $element, $value, array $config): array {
3455 $optimization = [
3456 'optimized_value' => $value,
3457 'validation' => [
3458 'valid' => true,
3459 'errors' => [],
3460 'warnings' => []
3461 ],
3462 'suggestions' => []
3463 ];
3464
3465 switch ($config['type']) {
3466 case 'text':
3467 $optimization = $this->optimize_text_element($element, $value, $config, $optimization);
3468 break;
3469 case 'image':
3470 $optimization = $this->optimize_image_element($element, $value, $config, $optimization);
3471 break;
3472 }
3473
3474 return $optimization;
3475 }
3476
3477 /**
3478 * Optimize text identity element
3479 *
3480 * @since 1.0.0
3481 *
3482 * @param string $element Element name
3483 * @param string $value Element value
3484 * @param array $config Element configuration
3485 * @param array $optimization Current optimization
3486 * @return array Updated optimization
3487 */
3488 private function optimize_text_element(string $element, string $value, array $config, array $optimization): array {
3489 if (empty($value) && !empty($config['required'])) {
3490 $optimization['validation']['errors'][] = "{$element} is required";
3491 $optimization['validation']['valid'] = false;
3492 }
3493
3494 if (!empty($value) && isset($config['max_length'])) {
3495 if (strlen($value) > $config['max_length']) {
3496 $optimization['validation']['warnings'][] = "{$element} exceeds maximum length of {$config['max_length']} characters";
3497 $optimization['optimized_value'] = substr($value, 0, $config['max_length']);
3498 }
3499 }
3500
3501 // SEO-specific optimizations
3502 if ($element === 'site_name' && !empty($value)) {
3503 // Remove excessive punctuation
3504 $optimization['optimized_value'] = preg_replace('/[!@#$%^&*()]+/', '', $value);
3505 }
3506
3507 return $optimization;
3508 }
3509
3510 /**
3511 * Optimize image identity element
3512 *
3513 * @since 1.0.0
3514 *
3515 * @param string $element Element name
3516 * @param string $value Element value
3517 * @param array $config Element configuration
3518 * @param array $optimization Current optimization
3519 * @return array Updated optimization
3520 */
3521 private function optimize_image_element(string $element, string $value, array $config, array $optimization): array {
3522 if (empty($value)) {
3523 if (!empty($config['required'])) {
3524 $optimization['validation']['errors'][] = "{$element} is required";
3525 $optimization['validation']['valid'] = false;
3526 }
3527 return $optimization;
3528 }
3529
3530 // Validate URL
3531 if (!filter_var($value, FILTER_VALIDATE_URL)) {
3532 $optimization['validation']['errors'][] = "{$element} must be a valid URL";
3533 $optimization['validation']['valid'] = false;
3534 return $optimization;
3535 }
3536
3537 // Check if it's a local image
3538 $attachment_id = attachment_url_to_postid($value);
3539 if ($attachment_id) {
3540 $image_meta = wp_get_attachment_metadata($attachment_id);
3541
3542 if ($image_meta && isset($image_meta['width'], $image_meta['height'])) {
3543 // Check recommended size
3544 if (isset($config['recommended_size'])) {
3545 [$rec_width, $rec_height] = explode('x', $config['recommended_size']);
3546
3547 if ((int) $image_meta['width'] !== (int) $rec_width || (int) $image_meta['height'] !== (int) $rec_height) {
3548 $optimization['suggestions'][] = "Consider using {$config['recommended_size']} size for optimal {$element}";
3549 }
3550 }
3551
3552 // Check file size
3553 if (isset($config['max_size'])) {
3554 $file_path = get_attached_file($attachment_id);
3555 if ($file_path && file_exists($file_path)) {
3556 $file_size = filesize($file_path);
3557 $max_size_bytes = $this->parse_size_string($config['max_size']);
3558
3559 if ($file_size > $max_size_bytes) {
3560 $optimization['validation']['warnings'][] = "{$element} file size exceeds {$config['max_size']}";
3561 }
3562 }
3563 }
3564 }
3565 }
3566
3567 return $optimization;
3568 }
3569
3570 /**
3571 * Parse size string to bytes
3572 *
3573 * @since 1.0.0
3574 *
3575 * @param string $size_string Size string (e.g., '2MB', '500KB')
3576 * @return int Size in bytes
3577 */
3578 private function parse_size_string(string $size_string): int {
3579 $size_string = strtoupper(trim($size_string));
3580 $size = (int) $size_string;
3581
3582 if (strpos($size_string, 'KB') !== false) {
3583 return $size * 1024;
3584 } elseif (strpos($size_string, 'MB') !== false) {
3585 return $size * 1024 * 1024;
3586 } elseif (strpos($size_string, 'GB') !== false) {
3587 return $size * 1024 * 1024 * 1024;
3588 }
3589
3590 return $size;
3591 }
3592
3593 /**
3594 * Calculate identity optimization score
3595 *
3596 * @since 1.0.0
3597 *
3598 * @param array $validations Element validations
3599 * @return int Score (0-100)
3600 */
3601 private function calculate_identity_score(array $validations): int {
3602 $total_score = 0;
3603 $element_count = 0;
3604
3605 foreach ($validations as $validation) {
3606 $element_score = 100;
3607 $element_score -= count($validation['errors']) * 30;
3608 $element_score -= count($validation['warnings']) * 15;
3609
3610 $total_score += max(0, $element_score);
3611 $element_count++;
3612 }
3613
3614 return $element_count > 0 ? (int) round($total_score / $element_count) : 0;
3615 }
3616
3617 /**
3618 * Calculate validation score
3619 *
3620 * @since 1.0.0
3621 *
3622 * @param array $validation Validation results
3623 * @return int Score (0-100)
3624 */
3625 private function calculate_validation_score(array $validation): int {
3626 $score = 100;
3627 $score -= count($validation['errors']) * 20;
3628 $score -= count($validation['warnings']) * 10;
3629 $score -= count($validation['suggestions']) * 5;
3630
3631 return max(0, $score);
3632 }
3633 }
3634