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

class-site-identity-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.0.0, at includes/seo/class-site-identity-manager.php

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