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

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