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

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