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

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