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

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

1,703 lines 59.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Frontend SEO Manager Class
4 *
5 * Handles frontend SEO meta tag output and WordPress integration
6 *
7 * @package ThinkRank\Frontend
8 * @since 1.0.0
9 */
10
11 declare(strict_types=1);
12
13 namespace ThinkRank\Frontend;
14
15 // Prevent direct access
16 if (!defined('ABSPATH')) {
17 exit;
18 }
19
20 /**
21 * Frontend SEO Manager Class
22 *
23 * Single Responsibility: Output SEO meta tags and integrate with WordPress SEO
24 *
25 * @since 1.0.0
26 */
27 class SEO_Manager {
28
29 /**
30 * Current post ID
31 *
32 * @var int|null
33 */
34 private ?int $current_post_id = null;
35
36 /**
37 * Current post metadata
38 *
39 * @var array
40 */
41 private array $current_metadata = [];
42
43 /**
44 * Site Identity Manager instance
45 *
46 * @var \ThinkRank\SEO\Site_Identity_Manager|null
47 */
48 private ?\ThinkRank\SEO\Site_Identity_Manager $site_identity_manager = null;
49
50 /**
51 * Social Meta Manager instance
52 *
53 * @var \ThinkRank\SEO\Social_Meta_Manager|null
54 */
55 private ?\ThinkRank\SEO\Social_Meta_Manager $social_manager = null;
56
57 /**
58 * Schema Management System instance
59 *
60 * @var \ThinkRank\SEO\Schema_Management_System|null
61 */
62 private ?\ThinkRank\SEO\Schema_Management_System $schema_manager = null;
63
64 /**
65 * Site identity data cache
66 *
67 * @var array|null
68 */
69 private ?array $site_identity_data = null;
70
71 /**
72 * Current page context
73 *
74 * @var string
75 */
76 private string $current_context = 'site';
77
78 /**
79 * Initialize SEO manager
80 *
81 * @return void
82 */
83 public function init(): void {
84 // Initialize Site Identity Manager
85 $this->initialize_site_identity_manager();
86
87 // Initialize Social Meta Manager
88 $this->initialize_social_meta_manager();
89
90 // Initialize Schema Manager for enhanced schema output
91 $this->initialize_schema_manager();
92
93 // Initialize Google Analytics Tracking Manager
94 $this->initialize_google_analytics_tracking();
95
96 // Initialize current post and context data first
97 add_action('wp', [$this, 'initialize_current_context']);
98
99 // Use HIGH PRIORITY hooks to override other SEO plugins
100 // Priority 1-5 ensures ThinkRank runs before other SEO plugins
101
102 // Override WordPress title with HIGH priority
103 add_filter('pre_get_document_title', [$this, 'override_document_title'], 1);
104 add_filter('wp_title', [$this, 'override_wp_title'], 1, 2);
105
106 // Output meta tags with HIGH priority
107 add_action('wp_head', [$this, 'output_meta_description'], 1);
108 add_action('wp_head', [$this, 'output_seo_meta_tags'], 2);
109 add_action('wp_head', [$this, 'output_open_graph_tags'], 3);
110 add_action('wp_head', [$this, 'output_twitter_card_tags'], 4);
111 add_action('wp_head', [$this, 'output_platform_meta_tags'], 5);
112 add_action('wp_head', [$this, 'output_canonical_url'], 6);
113
114 // Add Site Identity specific outputs
115 add_action('wp_head', [$this, 'output_site_schema_markup'], 7);
116 add_action('wp_head', [$this, 'output_breadcrumb_schema'], 8);
117
118 // Add closing comment (runs last)
119 add_action('wp_head', [$this, 'output_closing_comment'], 99);
120
121 // Add breadcrumb display hook
122 add_action('thinkrank_breadcrumbs', [$this, 'display_breadcrumbs']);
123
124 // Add robots.txt filter hook
125 add_filter('robots_txt', [$this, 'filter_robots_txt'], 10, 2);
126 }
127
128 /**
129 * Initialize Site Identity Manager
130 *
131 * @return void
132 */
133 private function initialize_site_identity_manager(): void {
134 if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) {
135 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-site-identity-manager.php';
136 }
137
138 $this->site_identity_manager = new \ThinkRank\SEO\Site_Identity_Manager();
139 }
140
141 /**
142 * Initialize Social Meta Manager
143 *
144 * @return void
145 */
146 private function initialize_social_meta_manager(): void {
147 if (!class_exists('ThinkRank\\SEO\\Social_Meta_Manager')) {
148 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-social-meta-manager.php';
149 }
150
151 $this->social_manager = new \ThinkRank\SEO\Social_Meta_Manager();
152 }
153
154 /**
155 * Initialize Schema Manager for enhanced schema output
156 *
157 * @return void
158 */
159 private function initialize_schema_manager(): void {
160 if (!class_exists('ThinkRank\\SEO\\Schema_Management_System')) {
161 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-management-system.php';
162 }
163
164 // Initialize Schema Manager and store reference for integration
165 $this->schema_manager = new \ThinkRank\SEO\Schema_Management_System();
166 }
167
168 /**
169 * Initialize Google Analytics Tracking Manager
170 *
171 * @return void
172 */
173 private function initialize_google_analytics_tracking(): void {
174 if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
175 require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-google-analytics-tracking-manager.php';
176 }
177
178 // Initialize Google Analytics Tracking Manager
179 new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
180 }
181
182 /**
183 * Initialize current context and post data
184 *
185 * @return void
186 */
187 public function initialize_current_context(): void {
188 // Determine current context
189 $this->current_context = $this->detect_current_context();
190
191 // Initialize post data if singular
192 if (is_singular()) {
193 $post_id = get_the_ID();
194 if ($post_id) {
195 $this->current_post_id = $post_id;
196 $this->current_metadata = $this->get_post_seo_metadata($post_id);
197 }
198 }
199
200 // Load site identity data
201 $this->load_site_identity_data();
202 }
203
204 /**
205 * Detect current page context
206 *
207 * @return string Current context type
208 */
209 private function detect_current_context(): string {
210 if (is_home() || is_front_page()) {
211 return 'homepage';
212 } elseif (is_single()) {
213 return 'post';
214 } elseif (is_page()) {
215 return 'page';
216 } elseif (is_category()) {
217 return 'category';
218 } elseif (is_tag()) {
219 return 'tag';
220 } elseif (is_author()) {
221 return 'author';
222 } elseif (is_search()) {
223 return 'search';
224 } elseif (is_archive()) {
225 return 'archive';
226 }
227
228 return 'site';
229 }
230
231 /**
232 * Load site identity data
233 *
234 * @return void
235 */
236 private function load_site_identity_data(): void {
237 if ($this->site_identity_manager && $this->site_identity_data === null) {
238 $this->site_identity_data = $this->site_identity_manager->get_output_data('site', null);
239 }
240 }
241
242 /**
243 * Get SEO metadata for a post
244 *
245 * @param int $post_id Post ID
246 * @return array SEO metadata
247 */
248 private function get_post_seo_metadata(int $post_id): array {
249 return [
250 'title' => get_post_meta($post_id, '_thinkrank_seo_title', true),
251 'description' => get_post_meta($post_id, '_thinkrank_meta_description', true),
252 'focus_keyword' => get_post_meta($post_id, '_thinkrank_focus_keyword', true),
253 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true),
254 ];
255 }
256
257 /**
258 * Override WordPress document title (HIGH PRIORITY)
259 * Post-specific metadata takes priority over Site Identity templates
260 *
261 * @param string $title Original title
262 * @return string Modified title
263 */
264 public function override_document_title($title): string {
265 // First priority: Post-specific ThinkRank metadata
266 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
267 return $this->current_metadata['title'];
268 }
269
270 // Second priority: Site Identity templates
271 $generated_title = $this->generate_context_title();
272 if ($generated_title) {
273 return $generated_title;
274 }
275
276 return $title;
277 }
278
279 /**
280 * Override WordPress wp_title (HIGH PRIORITY)
281 * Post-specific metadata takes priority over Site Identity templates
282 *
283 * @param string $title Original title
284 * @param string $sep Title separator
285 * @return string Modified title
286 */
287 public function override_wp_title(string $title, string $sep = ''): string {
288 // First priority: Post-specific ThinkRank metadata
289 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
290 $site_name = get_bloginfo('name');
291 return $this->current_metadata['title'] . ($sep ? " $sep " : ' | ') . $site_name;
292 }
293
294 // Second priority: Site Identity templates
295 $generated_title = $this->generate_context_title();
296 if ($generated_title) {
297 return $generated_title;
298 }
299
300 return $title;
301 }
302
303 /**
304 * Output meta description (HIGH PRIORITY)
305 * Uses post-specific metadata or Site Identity default fallback
306 *
307 * @return void
308 */
309 public function output_meta_description(): void {
310 $description = $this->get_meta_description();
311
312 if ($description) {
313 // Output main ThinkRank SEO header comment (only once)
314 static $header_output = false;
315 if (!$header_output) {
316 echo "<!-- Search Engine Optimization by ThinkRank - https://thinkrank.ai/ -->\n";
317 $header_output = true;
318 }
319
320 // Ensure description is within optimal length (150-160 characters)
321 if (strlen($description) > 160) {
322 $description = wp_trim_words($description, 25, '...');
323 }
324
325 echo "<!-- ThinkRank SEO Meta Description -->\n";
326 echo '<meta name="description" content="' . esc_attr($description) . '" />' . "\n";
327 echo "<!-- /ThinkRank SEO Meta Description -->\n";
328 }
329 }
330
331 /**
332 * Output SEO meta tags
333 *
334 * @return void
335 */
336 public function output_seo_meta_tags(): void {
337 echo "<!-- ThinkRank SEO Meta Tags -->\n";
338
339 // Output robots meta tag with proper directives
340 $robots_content = $this->get_robots_meta_content();
341 echo '<meta name="robots" content="' . esc_attr($robots_content) . '" />' . "\n";
342
343 // Output focus keyword as meta keywords (if available and not empty)
344 if (!empty($this->current_metadata['focus_keyword'])) {
345 $keywords = trim($this->current_metadata['focus_keyword']);
346 if (!empty($keywords)) {
347 echo '<meta name="keywords" content="' . esc_attr($keywords) . '" />' . "\n";
348 }
349 }
350
351 // Output local SEO meta tags if business info is available
352 $this->output_local_seo_meta_tags();
353
354 // Output generator meta tag
355 echo '<meta name="generator" content="ThinkRank ' . esc_attr(THINKRANK_VERSION) . '" />' . "\n";
356
357 // Output viewport meta tag if not already present
358 if (!has_action('wp_head', 'wp_site_icon') || !wp_is_mobile()) {
359 echo '<meta name="viewport" content="width=device-width, initial-scale=1.0" />' . "\n";
360 }
361 echo "<!-- /ThinkRank SEO Meta Tags -->\n";
362 }
363
364 /**
365 * Get robots meta content based on context and settings
366 *
367 * @return string Robots meta content
368 */
369 private function get_robots_meta_content(): string {
370 $robots = [];
371
372 // Default directives
373 $robots[] = 'index';
374 $robots[] = 'follow';
375
376 // Add advanced directives for better SEO
377 $robots[] = 'max-snippet:-1';
378 $robots[] = 'max-image-preview:large';
379 $robots[] = 'max-video-preview:-1';
380
381 // Check for specific page settings
382 if (is_singular()) {
383 // Check if post has noindex setting
384 $noindex = get_post_meta(get_the_ID(), '_thinkrank_noindex', true);
385 if ($noindex) {
386 $robots = ['noindex', 'nofollow'];
387 }
388 }
389
390 // Check for archive pages
391 if (is_archive() || is_search()) {
392 // Allow indexing of category/tag archives but be more conservative
393 if (is_paged()) {
394 $robots = ['noindex', 'follow'];
395 }
396 }
397
398 // Apply filters for customization
399 $robots = apply_filters('thinkrank_robots_meta', $robots);
400
401 return implode(', ', $robots);
402 }
403
404 /**
405 * Output local SEO meta tags for business information
406 *
407 * @return void
408 */
409 private function output_local_seo_meta_tags(): void {
410 if (!$this->site_identity_manager) {
411 return;
412 }
413
414 $settings = $this->site_identity_manager->get_settings('site');
415
416 // Only output if local SEO is enabled and business info is available
417 if (empty($settings['local_seo_enabled']) || empty($settings['business_name'])) {
418 return;
419 }
420
421 echo "<!-- ThinkRank Local SEO Meta Tags -->\n";
422
423 // NAP (Name, Address, Phone) Consistency Meta Tags
424 if (!empty($settings['business_name'])) {
425 echo '<meta name="business:name" content="' . esc_attr($settings['business_name']) . '" />' . "\n";
426 }
427
428 // Business address components
429 if (!empty($settings['business_address'])) {
430 echo '<meta name="business:contact_data:street_address" content="' . esc_attr($settings['business_address']) . '" />' . "\n";
431 }
432
433 if (!empty($settings['business_city'])) {
434 echo '<meta name="business:contact_data:locality" content="' . esc_attr($settings['business_city']) . '" />' . "\n";
435 echo '<meta name="geo.placename" content="' . esc_attr($settings['business_city']) . '" />' . "\n";
436 }
437
438 if (!empty($settings['business_state'])) {
439 echo '<meta name="business:contact_data:region" content="' . esc_attr($settings['business_state']) . '" />' . "\n";
440 }
441
442 if (!empty($settings['business_postal_code'])) {
443 echo '<meta name="business:contact_data:postal_code" content="' . esc_attr($settings['business_postal_code']) . '" />' . "\n";
444 }
445
446 if (!empty($settings['business_country'])) {
447 echo '<meta name="business:contact_data:country_name" content="' . esc_attr($settings['business_country']) . '" />' . "\n";
448 }
449
450 // Phone number
451 if (!empty($settings['business_phone'])) {
452 echo '<meta name="business:contact_data:phone_number" content="' . esc_attr($settings['business_phone']) . '" />' . "\n";
453 }
454
455 // Email address
456 if (!empty($settings['business_email'])) {
457 echo '<meta name="business:contact_data:email" content="' . esc_attr($settings['business_email']) . '" />' . "\n";
458 }
459
460 // Geo-location meta tags (if coordinates are available)
461 if (!empty($settings['business_latitude']) && !empty($settings['business_longitude'])) {
462 $coordinates = $settings['business_latitude'] . ';' . $settings['business_longitude'];
463 echo '<meta name="geo.position" content="' . esc_attr($coordinates) . '" />' . "\n";
464 echo '<meta name="ICBM" content="' . esc_attr($settings['business_latitude'] . ', ' . $settings['business_longitude']) . '" />' . "\n";
465 }
466
467 // Regional meta tag (state/country combination)
468 if (!empty($settings['business_state']) && !empty($settings['business_country'])) {
469 $region = strtoupper($settings['business_country']) . '-' . strtoupper($settings['business_state']);
470 echo '<meta name="geo.region" content="' . esc_attr($region) . '" />' . "\n";
471 }
472
473 // Business hours in structured format
474 if (!empty($settings['business_hours']) && is_array($settings['business_hours'])) {
475 $formatted_hours = $this->format_business_hours_for_meta($settings['business_hours']);
476 if (!empty($formatted_hours)) {
477 echo '<meta name="business:hours" content="' . esc_attr($formatted_hours) . '" />' . "\n";
478 }
479 }
480
481 // Business type
482 if (!empty($settings['business_type'])) {
483 echo '<meta name="business:type" content="' . esc_attr($settings['business_type']) . '" />' . "\n";
484 }
485
486 echo "<!-- /ThinkRank Local SEO Meta Tags -->\n";
487 }
488
489 /**
490 * Format business hours for meta tag output
491 *
492 * @param array $business_hours Business hours array
493 * @return string Formatted hours string
494 */
495 private function format_business_hours_for_meta(array $business_hours): string {
496 $formatted_days = [];
497
498 $day_abbreviations = [
499 'monday' => 'Mo',
500 'tuesday' => 'Tu',
501 'wednesday' => 'We',
502 'thursday' => 'Th',
503 'friday' => 'Fr',
504 'saturday' => 'Sa',
505 'sunday' => 'Su'
506 ];
507
508 foreach ($day_abbreviations as $day => $abbrev) {
509 if (isset($business_hours[$day]) && !empty($business_hours[$day])) {
510 $day_data = $business_hours[$day];
511
512 if (!empty($day_data['closed']) || empty($day_data['open']) || empty($day_data['close'])) {
513 continue; // Skip closed days
514 }
515
516 $formatted_days[] = $abbrev . ' ' . $day_data['open'] . '-' . $day_data['close'];
517 }
518 }
519
520 return implode(', ', $formatted_days);
521 }
522
523 /**
524 * Output social media Open Graph tags from Social Meta Manager
525 *
526 * @param array $og_tags Open Graph tags array
527 * @return void
528 */
529 private function output_social_og_tags(array $og_tags): void {
530 echo "<!-- ThinkRank SEO Open Graph Tags (Enhanced) -->\n";
531
532 // Define optimal order for Open Graph tags
533 $og_order = [
534 'og:title', 'og:description', 'og:type', 'og:url', 'og:site_name', 'og:locale',
535 'og:image', 'og:image:width', 'og:image:height', 'og:image:type', 'og:image:alt',
536 'article:published_time', 'article:modified_time', 'article:author', 'article:section'
537 ];
538
539 // Output tags in optimal order
540 foreach ($og_order as $property) {
541 if (!empty($og_tags[$property])) {
542 echo '<meta property="' . esc_attr($property) . '" content="' . esc_attr($og_tags[$property]) . '" />' . "\n";
543 }
544 }
545
546 // Output any remaining tags not in the order list
547 foreach ($og_tags as $property => $content) {
548 if (!empty($content) && !in_array($property, $og_order, true)) {
549 echo '<meta property="' . esc_attr($property) . '" content="' . esc_attr($content) . '" />' . "\n";
550 }
551 }
552
553 echo "<!-- /ThinkRank SEO Open Graph Tags -->\n";
554 }
555
556 /**
557 * Output social media Twitter Card tags from Social Meta Manager
558 *
559 * @param array $twitter_tags Twitter Card tags array
560 * @return void
561 */
562 private function output_social_twitter_tags(array $twitter_tags): void {
563 echo "<!-- ThinkRank SEO Twitter Card Tags (Enhanced) -->\n";
564
565 // Define optimal order for Twitter Card tags
566 $twitter_order = [
567 'twitter:card', 'twitter:title', 'twitter:description', 'twitter:site', 'twitter:creator',
568 'twitter:image', 'twitter:image:alt', 'twitter:player', 'twitter:player:width', 'twitter:player:height'
569 ];
570
571 // Output tags in optimal order
572 foreach ($twitter_order as $name) {
573 if (!empty($twitter_tags[$name])) {
574 echo '<meta name="' . esc_attr($name) . '" content="' . esc_attr($twitter_tags[$name]) . '" />' . "\n";
575 }
576 }
577
578 // Output any remaining tags not in the order list
579 foreach ($twitter_tags as $name => $content) {
580 if (!empty($content) && !in_array($name, $twitter_order, true)) {
581 echo '<meta name="' . esc_attr($name) . '" content="' . esc_attr($content) . '" />' . "\n";
582 }
583 }
584
585 echo "<!-- /ThinkRank SEO Twitter Card Tags -->\n";
586 }
587
588 /**
589 * Output platform-specific meta tags
590 *
591 * @return void
592 */
593 public function output_platform_meta_tags(): void {
594 // Try Social Meta Manager for platform tags
595 if ($this->social_manager) {
596 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
597 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
598
599 $social_data = $this->social_manager->get_output_data(
600 $social_context,
601 $this->current_post_id
602 );
603
604 if ($social_data['enabled'] && !empty($social_data['platform_tags'])) {
605 $this->output_social_platform_tags($social_data['platform_tags']);
606 }
607 }
608 }
609
610 /**
611 * Output social media platform tags from Social Meta Manager
612 *
613 * @param array $platform_tags Platform tags array
614 * @return void
615 */
616 private function output_social_platform_tags(array $platform_tags): void {
617 echo "<!-- ThinkRank SEO Platform Meta Tags -->\n";
618
619 foreach ($platform_tags as $name => $content) {
620 if (!empty($content)) {
621 // Determine if it should be property or name attribute
622 if (strpos($name, 'fb:') === 0) {
623 // Facebook tags use property attribute
624 echo '<meta property="' . esc_attr($name) . '" content="' . esc_attr($content) . '" />' . "\n";
625 } else {
626 // Other platform tags use name attribute
627 echo '<meta name="' . esc_attr($name) . '" content="' . esc_attr($content) . '" />' . "\n";
628 }
629 }
630 }
631
632 echo "<!-- /ThinkRank SEO Platform Meta Tags -->\n";
633 }
634
635 /**
636 * Output Open Graph meta tags (HIGH PRIORITY)
637 * Uses Social Meta Manager with fallback to Site Identity templates
638 *
639 * @return void
640 */
641 public function output_open_graph_tags(): void {
642 // Priority 1: Try Social Meta Manager (Social Media tab settings)
643 if ($this->social_manager) {
644 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
645 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
646
647 $social_data = $this->social_manager->get_output_data(
648 $social_context,
649 $this->current_post_id
650 );
651
652 if ($social_data['enabled'] && !empty($social_data['og_tags'])) {
653 $this->output_social_og_tags($social_data['og_tags']);
654 return;
655 }
656 }
657
658 // Priority 2: Fallback to current implementation
659 $this->output_basic_og_tags();
660 }
661
662 /**
663 * Output basic Open Graph tags (fallback implementation)
664 *
665 * @return void
666 */
667 private function output_basic_og_tags(): void {
668 // Get title using priority system: post-specific > Site Identity > default
669 $title = '';
670 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
671 $title = $this->current_metadata['title'];
672 } else {
673 $title = $this->generate_context_title();
674 }
675 if (!$title) {
676 $title = is_singular() ? get_the_title() : get_bloginfo('name');
677 }
678
679 $description = $this->get_meta_description();
680 if (!$description) {
681 $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
682 }
683
684 $url = is_singular() ? get_permalink() : home_url();
685 $site_name = $this->site_identity_data && !empty($this->site_identity_data['identity']['site_name'])
686 ? $this->site_identity_data['identity']['site_name']
687 : get_bloginfo('name');
688
689 // Determine proper og:type based on context
690 $og_type = 'website';
691 if (is_singular('post')) {
692 $og_type = 'article';
693 } elseif (is_singular('page')) {
694 $og_type = 'website';
695 } elseif (is_home() || is_front_page()) {
696 $og_type = 'website';
697 }
698
699 echo "<!-- ThinkRank SEO Open Graph Meta Tags -->\n";
700 echo "<meta property=\"og:type\" content=\"" . esc_attr($og_type) . "\" />\n";
701 echo "<meta property=\"og:title\" content=\"" . esc_attr($title) . "\" />\n";
702 echo "<meta property=\"og:description\" content=\"" . esc_attr($description) . "\" />\n";
703 echo "<meta property=\"og:url\" content=\"" . esc_url($url) . "\" />\n";
704 echo "<meta property=\"og:site_name\" content=\"" . esc_attr($site_name) . "\" />\n";
705 echo "<meta property=\"og:locale\" content=\"" . esc_attr(get_locale()) . "\" />\n";
706
707 // Add featured image if available (only for singular posts/pages)
708 if (is_singular() && $this->current_post_id) {
709 if (has_post_thumbnail($this->current_post_id)) {
710 $image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
711 echo "<meta property=\"og:image\" content=\"" . esc_url($image_url) . "\" />\n";
712 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($image_url) . "\" />\n";
713
714 // Get image dimensions and alt text
715 $image_id = get_post_thumbnail_id($this->current_post_id);
716 $image_meta = wp_get_attachment_metadata($image_id);
717 if ($image_meta) {
718 echo "<meta property=\"og:image:width\" content=\"" . esc_attr($image_meta['width']) . "\" />\n";
719 echo "<meta property=\"og:image:height\" content=\"" . esc_attr($image_meta['height']) . "\" />\n";
720 echo "<meta property=\"og:image:type\" content=\"image/jpeg\" />\n";
721 }
722
723 // Add image alt text
724 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', true);
725 if ($image_alt) {
726 echo "<meta property=\"og:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n";
727 }
728 }
729
730 // Add article specific tags for posts only
731 if ($og_type === 'article') {
732 echo '<meta property="article:published_time" content="' . esc_attr(get_the_date('c', $this->current_post_id)) . '" />' . "\n";
733 echo '<meta property="article:modified_time" content="' . esc_attr(get_the_modified_date('c', $this->current_post_id)) . '" />' . "\n";
734
735 // Add author
736 $author_id = get_post_field('post_author', $this->current_post_id);
737 $author_name = get_the_author_meta('display_name', $author_id);
738 echo "<meta property=\"article:author\" content=\"" . esc_attr($author_name) . "\" />\n";
739
740 // Add categories as article:section
741 if (is_single()) {
742 $categories = get_the_category($this->current_post_id);
743 if (!empty($categories)) {
744 echo "<meta property=\"article:section\" content=\"" . esc_attr($categories[0]->name) . "\" />\n";
745 }
746 }
747 }
748 }
749 echo "<!-- /ThinkRank SEO Open Graph Meta Tags -->\n";
750 }
751
752 /**
753 * Output Twitter Card meta tags (HIGH PRIORITY)
754 * Uses Social Meta Manager with fallback to Site Identity templates
755 *
756 * @return void
757 */
758 public function output_twitter_card_tags(): void {
759 // Priority 1: Try Social Meta Manager (Social Media tab settings)
760 if ($this->social_manager) {
761 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
762 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
763
764 $social_data = $this->social_manager->get_output_data(
765 $social_context,
766 $this->current_post_id
767 );
768
769
770
771 if ($social_data['enabled'] && !empty($social_data['twitter_tags'])) {
772 $this->output_social_twitter_tags($social_data['twitter_tags']);
773 return;
774 }
775 }
776
777 // Priority 2: Fallback to current implementation
778 $this->output_basic_twitter_tags();
779 }
780
781 /**
782 * Output basic Twitter Card tags (fallback implementation)
783 *
784 * @return void
785 */
786 private function output_basic_twitter_tags(): void {
787 // Get title using priority system: post-specific > Site Identity > default
788 $title = '';
789 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
790 $title = $this->current_metadata['title'];
791 } else {
792 $title = $this->generate_context_title();
793 }
794 if (!$title) {
795 $title = is_singular() ? get_the_title() : get_bloginfo('name');
796 }
797
798 $description = $this->get_meta_description();
799 if (!$description) {
800 $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
801 }
802
803 // Determine card type based on image availability
804 $card_type = 'summary';
805 if (is_singular() && $this->current_post_id && has_post_thumbnail($this->current_post_id)) {
806 $card_type = 'summary_large_image';
807 }
808
809 echo "<!-- ThinkRank SEO Twitter Card Meta Tags -->\n";
810 echo '<meta name="twitter:card" content="' . esc_attr($card_type) . '" />' . "\n";
811 echo "<meta name=\"twitter:title\" content=\"" . esc_attr($title) . "\" />\n";
812 echo "<meta name=\"twitter:description\" content=\"" . esc_attr($description) . "\" />\n";
813
814 // Add featured image if available (only for singular posts/pages)
815 if (is_singular() && $this->current_post_id && has_post_thumbnail($this->current_post_id)) {
816 $image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
817 echo "<meta name=\"twitter:image\" content=\"" . esc_url($image_url) . "\" />\n";
818
819 // Add image alt text for accessibility
820 $image_id = get_post_thumbnail_id($this->current_post_id);
821 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', true);
822 if ($image_alt) {
823 echo "<meta name=\"twitter:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n";
824 }
825 }
826
827 // Add site Twitter handle if configured
828 if ($this->site_identity_data && !empty($this->site_identity_data['social']['twitter_username'])) {
829 $twitter_handle = $this->site_identity_data['social']['twitter_username'];
830 // Ensure handle starts with @
831 if (strpos($twitter_handle, '@') !== 0) {
832 $twitter_handle = '@' . $twitter_handle;
833 }
834 echo "<meta name=\"twitter:site\" content=\"" . esc_attr($twitter_handle) . "\" />\n";
835 }
836 echo "<!-- /ThinkRank SEO Twitter Card Meta Tags -->\n";
837 }
838
839 /**
840 * Output canonical URL
841 *
842 * @return void
843 */
844 public function output_canonical_url(): void {
845 if (!is_singular()) {
846 return;
847 }
848
849 $canonical_url = $this->current_post_id ? get_permalink($this->current_post_id) : get_permalink();
850 echo "<!-- ThinkRank SEO Canonical URL -->\n";
851 echo "<link rel=\"canonical\" href=\"" . esc_url($canonical_url) . "\" />\n";
852 echo "<!-- /ThinkRank SEO Canonical URL -->\n";
853 }
854
855
856
857 /**
858 * Check if ThinkRank has metadata for current post
859 *
860 * @return bool True if has ThinkRank metadata
861 */
862 private function has_thinkrank_metadata(): bool {
863 if (!is_singular()) {
864 return false;
865 }
866
867 return !empty($this->current_metadata['title']) || !empty($this->current_metadata['description']);
868 }
869
870 /**
871 * Check if current page has SEO data (public method for template functions)
872 *
873 * @return bool True if has SEO data
874 */
875 public function has_seo_data(): bool {
876 // Check if Site Identity is enabled and active
877 if ($this->site_identity_data && $this->site_identity_data['enabled']) {
878 return true;
879 }
880
881 // Check if post has ThinkRank metadata
882 return $this->has_thinkrank_metadata();
883 }
884
885 /**
886 * Get current breadcrumbs data (public method for template functions)
887 *
888 * @return array|null Breadcrumb data or null if not available
889 */
890 public function get_current_breadcrumbs(): ?array {
891 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
892 return null;
893 }
894
895 $settings = $this->site_identity_manager->get_settings('site');
896
897 if (empty($settings['breadcrumbs_enabled'])) {
898 return null;
899 }
900
901 return $this->generate_breadcrumbs($settings);
902 }
903
904 /**
905 * Get current SEO metadata
906 *
907 * @return array Current metadata
908 */
909 public function get_current_metadata(): array {
910 return $this->current_metadata;
911 }
912
913 /**
914 * Generate title based on current context using Site Identity templates
915 *
916 * @return string|null Generated title or null if no template available
917 */
918 private function generate_context_title(): ?string {
919 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
920 return null;
921 }
922
923 $template = $this->get_title_template_for_context();
924 if (!$template) {
925 return null;
926 }
927
928 $placeholders = $this->get_title_placeholders();
929 return $this->process_title_template($template, $placeholders);
930 }
931
932 /**
933 * Get title template for current context
934 *
935 * @return string|null Template string or null if not found
936 */
937 private function get_title_template_for_context(): ?string {
938 $settings = $this->site_identity_manager->get_settings('site');
939
940 switch ($this->current_context) {
941 case 'homepage':
942 return $settings['homepage_title'] ?? null;
943 case 'post':
944 return $settings['post_title'] ?? null;
945 case 'page':
946 return $settings['page_title'] ?? null;
947 case 'category':
948 return $settings['category_title'] ?? null;
949 case 'tag':
950 return $settings['tag_title'] ?? null;
951 case 'author':
952 return $settings['author_title'] ?? null;
953 case 'search':
954 return $settings['search_title'] ?? null;
955 case 'archive':
956 return $settings['archive_title'] ?? null;
957 default:
958 return null;
959 }
960 }
961
962 /**
963 * Get title placeholders for current context
964 *
965 * @return array Placeholder values
966 */
967 private function get_title_placeholders(): array {
968 global $post, $wp_query;
969
970 $settings = $this->site_identity_manager->get_settings('site');
971 $separator = $this->get_title_separator($settings['title_separator'] ?? 'pipe');
972
973 $placeholders = [
974 '%site_title%' => $settings['site_name'] ?? get_bloginfo('name'),
975 '%site_name%' => $settings['site_name'] ?? get_bloginfo('name'),
976 '%site_description%' => $settings['site_description'] ?? get_bloginfo('description'),
977 '%tagline%' => $settings['tagline'] ?? get_bloginfo('description'),
978 '%separator%' => ' ' . $separator . ' ',
979 '%date%' => gmdate('F Y'),
980 ];
981
982 // Context-specific placeholders
983 switch ($this->current_context) {
984 case 'post':
985 case 'page':
986 if ($this->current_post_id) {
987 $placeholders['%post_title%'] = get_the_title($this->current_post_id);
988 $placeholders['%page_title%'] = get_the_title($this->current_post_id);
989 $post_author = get_post_field('post_author', $this->current_post_id);
990 $placeholders['%author%'] = get_the_author_meta('display_name', $post_author);
991 $placeholders['%author_name%'] = get_the_author_meta('display_name', $post_author);
992
993 // Get categories for posts
994 $post_type = get_post_type($this->current_post_id);
995 if ($post_type === 'post') {
996 $categories = get_the_category($this->current_post_id);
997 $placeholders['%category%'] = !empty($categories) ? $categories[0]->name : '';
998 }
999 }
1000 break;
1001
1002 case 'category':
1003 $category = get_queried_object();
1004 if ($category) {
1005 $placeholders['%category_title%'] = $category->name;
1006 $placeholders['%category%'] = $category->name;
1007 }
1008 break;
1009
1010 case 'tag':
1011 $tag = get_queried_object();
1012 if ($tag) {
1013 $placeholders['%tag_title%'] = $tag->name;
1014 $placeholders['%tag%'] = $tag->name;
1015 }
1016 break;
1017
1018 case 'author':
1019 $author = get_queried_object();
1020 if ($author) {
1021 $placeholders['%author_name%'] = $author->display_name;
1022 $placeholders['%author%'] = $author->display_name;
1023 }
1024 break;
1025
1026 case 'search':
1027 $placeholders['%search_term%'] = get_search_query();
1028 break;
1029
1030 case 'archive':
1031 $placeholders['%archive_title%'] = get_the_archive_title();
1032 break;
1033 }
1034
1035 return $placeholders;
1036 }
1037
1038 /**
1039 * Process title template with placeholders
1040 *
1041 * @param string $template Template string
1042 * @param array $placeholders Placeholder values
1043 * @return string Processed title
1044 */
1045 private function process_title_template(string $template, array $placeholders): string {
1046 $title = str_replace(array_keys($placeholders), array_values($placeholders), $template);
1047
1048 // Clean up multiple separators and extra spaces
1049 $separator = $placeholders['%separator%'] ?? ' | ';
1050 $title = preg_replace('/\s*' . preg_quote(trim($separator), '/') . '\s*' . preg_quote(trim($separator), '/') . '\s*/', $separator, $title);
1051 $title = preg_replace('/\s+/', ' ', $title);
1052 $title = trim($title);
1053
1054 // Remove trailing separator
1055 $separator_trimmed = trim($separator);
1056 if (substr($title, -strlen($separator_trimmed)) === $separator_trimmed) {
1057 $title = trim(substr($title, 0, -strlen($separator_trimmed)));
1058 }
1059
1060 return $title;
1061 }
1062
1063 /**
1064 * Get title separator symbol
1065 *
1066 * @param string $separator_type Separator type
1067 * @return string Separator symbol
1068 */
1069 private function get_title_separator(string $separator_type): string {
1070 $separators = [
1071 'pipe' => '|',
1072 'dash' => '-',
1073 'ndash' => '',
1074 'mdash' => '',
1075 'middot' => '·',
1076 'bull' => '',
1077 'star' => '*',
1078 'smstar' => '',
1079 'gt' => '>',
1080 'raquo' => '»',
1081 ];
1082
1083 return $separators[$separator_type] ?? '|';
1084 }
1085
1086 /**
1087 * Get meta description with fallback system
1088 *
1089 * @return string|null Meta description or null if none available
1090 */
1091 private function get_meta_description(): ?string {
1092 // First priority: Post-specific ThinkRank metadata
1093 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['description'])) {
1094 return $this->current_metadata['description'];
1095 }
1096
1097 // Second priority: Site Identity default meta description
1098 if ($this->site_identity_data && $this->site_identity_data['enabled']) {
1099 $settings = $this->site_identity_manager->get_settings('site');
1100 $default_description = $settings['default_meta_description'] ?? '';
1101
1102 if (!empty($default_description)) {
1103 return $default_description;
1104 }
1105 }
1106
1107 // Third priority: Generate from content for posts/pages
1108 if (is_singular() && $this->current_post_id) {
1109 $post_content = get_post_field('post_content', $this->current_post_id);
1110 if ($post_content) {
1111 $excerpt = wp_trim_words(wp_strip_all_tags($post_content), 25, '...');
1112 if (!empty($excerpt)) {
1113 return $excerpt;
1114 }
1115 }
1116 }
1117
1118 // Fourth priority: Site description for homepage
1119 if (is_home() || is_front_page()) {
1120 $site_description = get_bloginfo('description');
1121 if (!empty($site_description)) {
1122 return $site_description;
1123 }
1124 }
1125
1126 return null;
1127 }
1128
1129 /**
1130 * Output site-wide schema markup with priority system
1131 *
1132 * Priority: Schema Manager > Site Identity (like Twitter Cards approach)
1133 *
1134 * @return void
1135 */
1136 public function output_site_schema_markup(): void {
1137 $has_schema_manager_output = false;
1138
1139 // PRIORITY 1: Always output site-wide schemas (Organization, Website, LocalBusiness, Person)
1140 if ($this->schema_manager) {
1141 $site_wide_schemas = $this->schema_manager->get_deployed_schemas('site', null);
1142
1143 if (!empty($site_wide_schemas)) {
1144 foreach ($site_wide_schemas as $schema_type => $schema_info) {
1145 $this->output_schema_markup($schema_info['data'], $schema_type, 'Schema Manager');
1146 }
1147 $has_schema_manager_output = true;
1148 }
1149 }
1150
1151 // PRIORITY 2: Also output page-specific schemas (Article, HowTo, FAQ, etc.) on individual posts/pages
1152 if ($this->schema_manager && (is_single() || is_page())) {
1153 $context_type = is_page() ? 'page' : 'post';
1154 $context_id = get_the_ID();
1155
1156 $page_specific_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
1157
1158 if (!empty($page_specific_schemas)) {
1159 foreach ($page_specific_schemas as $schema_type => $schema_info) {
1160 $this->output_schema_markup($schema_info['data'], $schema_type, 'Schema Manager');
1161 }
1162 $has_schema_manager_output = true;
1163 }
1164 }
1165
1166 // Skip Site Identity fallback if any Schema Manager schemas were output
1167 if ($has_schema_manager_output) {
1168 return;
1169 }
1170
1171 // PRIORITY 2: Fall back to Site Identity schemas (like basic Twitter Cards)
1172 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
1173 return;
1174 }
1175
1176 $settings = $this->site_identity_manager->get_settings('site');
1177
1178 // Only output on homepage or if organization schema is enabled
1179 if (!is_home() && !is_front_page() && empty($settings['organization_schema'])) {
1180 return;
1181 }
1182
1183 $schema = $this->generate_organization_schema($settings);
1184
1185 if ($schema) {
1186 $this->output_schema_markup($schema, 'Organization', 'Site Identity');
1187 }
1188 }
1189
1190 /**
1191 * Output schema markup with consistent formatting
1192 *
1193 * @param array $schema_data Schema data
1194 * @param string $schema_type Schema type name
1195 * @param string $source Source of schema (Schema Manager, Site Identity, etc.)
1196 * @return void
1197 */
1198 private function output_schema_markup(array $schema_data, string $schema_type, string $source): void {
1199 if (empty($schema_data)) {
1200 return;
1201 }
1202
1203 echo '<!-- ThinkRank ' . esc_html($source) . ': ' . esc_html($schema_type) . ' Schema -->' . "\n";
1204 echo '<script type="application/ld+json">' . "\n";
1205 echo wp_json_encode($schema_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
1206 echo '</script>' . "\n";
1207 echo '<!-- /ThinkRank ' . esc_html($source) . ': ' . esc_html($schema_type) . ' Schema -->' . "\n";
1208 }
1209
1210 /**
1211 * Generate organization schema markup
1212 *
1213 * @param array $settings Site identity settings
1214 * @return array|null Schema data or null if insufficient data
1215 */
1216 private function generate_organization_schema(array $settings): ?array {
1217 $site_name = $settings['site_name'] ?? get_bloginfo('name');
1218 $site_description = $settings['site_description'] ?? get_bloginfo('description');
1219
1220 if (empty($site_name)) {
1221 return null;
1222 }
1223
1224 $schema = [
1225 '@context' => 'https://schema.org',
1226 '@type' => 'Organization',
1227 '@id' => home_url() . '#organization',
1228 'name' => $site_name,
1229 'url' => home_url(),
1230 ];
1231
1232 // Add description if available
1233 if (!empty($site_description)) {
1234 $schema['description'] = $site_description;
1235 }
1236
1237 // Add logo if available with proper ImageObject structure
1238 if (!empty($settings['logo_url'])) {
1239 $schema['logo'] = [
1240 '@type' => 'ImageObject',
1241 '@id' => home_url() . '#logo',
1242 'url' => $settings['logo_url'],
1243 'contentUrl' => $settings['logo_url'],
1244 'caption' => $site_name . ' Logo'
1245 ];
1246
1247 // Also add as image property
1248 $schema['image'] = $schema['logo'];
1249 }
1250
1251 // Add social media accounts if available
1252 $social_urls = [];
1253 if (!empty($this->site_identity_data['social'])) {
1254 $social_data = $this->site_identity_data['social'];
1255
1256 if (!empty($social_data['facebook_url'])) {
1257 $social_urls[] = $social_data['facebook_url'];
1258 }
1259 if (!empty($social_data['twitter_username'])) {
1260 $twitter_url = 'https://twitter.com/' . ltrim($social_data['twitter_username'], '@');
1261 $social_urls[] = $twitter_url;
1262 }
1263 if (!empty($social_data['linkedin_url'])) {
1264 $social_urls[] = $social_data['linkedin_url'];
1265 }
1266 if (!empty($social_data['instagram_url'])) {
1267 $social_urls[] = $social_data['instagram_url'];
1268 }
1269 if (!empty($social_data['youtube_url'])) {
1270 $social_urls[] = $social_data['youtube_url'];
1271 }
1272 }
1273
1274 if (!empty($social_urls)) {
1275 $schema['sameAs'] = $social_urls;
1276 }
1277
1278 // Add contact information if available
1279 if (!empty($settings['contact_email'])) {
1280 $schema['email'] = $settings['contact_email'];
1281 }
1282
1283 return $schema;
1284 }
1285
1286 /**
1287 * Output breadcrumb schema markup
1288 *
1289 * @return void
1290 */
1291 public function output_breadcrumb_schema(): void {
1292 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
1293 return;
1294 }
1295
1296 $settings = $this->site_identity_manager->get_settings('site');
1297
1298 // Only output if breadcrumbs are enabled
1299 if (empty($settings['breadcrumbs_enabled'])) {
1300 return;
1301 }
1302
1303 $breadcrumbs = $this->generate_breadcrumbs($settings);
1304
1305 if (!empty($breadcrumbs['schema'])) {
1306 echo "<!-- ThinkRank SEO Breadcrumb Schema Markup -->\n";
1307 echo '<script type="application/ld+json">' . "\n";
1308 echo wp_json_encode($breadcrumbs['schema'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
1309 echo '</script>' . "\n";
1310 echo "<!-- /ThinkRank SEO Breadcrumb Schema Markup -->\n";
1311 }
1312 }
1313
1314 /**
1315 * Output closing comment for ThinkRank SEO
1316 *
1317 * @return void
1318 */
1319 public function output_closing_comment(): void {
1320 // Only output if we've output any SEO content
1321 static $header_output = false;
1322 if ($header_output || $this->has_seo_output()) {
1323 echo "<!-- /ThinkRank SEO -->\n";
1324 }
1325 }
1326
1327 /**
1328 * Check if any SEO content has been output
1329 *
1330 * @return bool True if SEO content was output
1331 */
1332 private function has_seo_output(): bool {
1333 // Check if we have meta description or any other SEO data
1334 return !empty($this->get_meta_description()) ||
1335 $this->has_thinkrank_metadata() ||
1336 ($this->site_identity_data && $this->site_identity_data['enabled']);
1337 }
1338
1339 /**
1340 * Display breadcrumbs HTML
1341 *
1342 * @return void
1343 */
1344 public function display_breadcrumbs(): void {
1345 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
1346 return;
1347 }
1348
1349 $settings = $this->site_identity_manager->get_settings('site');
1350
1351 // Only display if breadcrumbs are enabled
1352 if (empty($settings['breadcrumbs_enabled'])) {
1353 return;
1354 }
1355
1356 $breadcrumbs = $this->generate_breadcrumbs($settings);
1357
1358 if (!empty($breadcrumbs['html'])) {
1359 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML is properly escaped in generate_breadcrumb_html method
1360 echo $breadcrumbs['html'];
1361 }
1362 }
1363
1364 /**
1365 * Generate breadcrumbs data
1366 *
1367 * @param array $settings Breadcrumb settings
1368 * @return array Breadcrumb data with HTML and schema
1369 */
1370 private function generate_breadcrumbs(array $settings): array {
1371 $breadcrumbs = [
1372 'items' => [],
1373 'html' => '',
1374 'schema' => null
1375 ];
1376
1377 // Get breadcrumb items
1378 $items = $this->get_breadcrumb_items($settings);
1379
1380 if (empty($items)) {
1381 return $breadcrumbs;
1382 }
1383
1384 $breadcrumbs['items'] = $items;
1385
1386 // Generate HTML
1387 $breadcrumbs['html'] = $this->generate_breadcrumb_html($items, $settings);
1388
1389 // Generate schema
1390 $breadcrumbs['schema'] = $this->generate_breadcrumb_schema($items);
1391
1392 return $breadcrumbs;
1393 }
1394
1395 /**
1396 * Filter WordPress robots.txt output
1397 *
1398 * @param string $output The default robots.txt output
1399 * @param string $public Whether the site is public
1400 * @return string Modified robots.txt content
1401 */
1402 public function filter_robots_txt(string $output, string $public): string {
1403 // Only override if Site Identity is enabled and robots.txt management is enabled
1404 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
1405 return $output;
1406 }
1407
1408 $settings = $this->site_identity_manager->get_settings('site');
1409 if (empty($settings['robots_txt_enabled'])) {
1410 return $output;
1411 }
1412
1413 // Generate ThinkRank robots.txt content
1414 try {
1415 $robots_data = $this->site_identity_manager->generate_robots_txt();
1416
1417 if (!empty($robots_data['content'])) {
1418 return $robots_data['content'];
1419 }
1420 } catch (\Exception $e) {
1421 // Robots.txt generation failed - fallback to default output
1422 }
1423
1424 // Fallback to default output if generation fails
1425 return $output;
1426 }
1427
1428 /**
1429 * Get breadcrumb items for current page
1430 *
1431 * @param array $settings Breadcrumb settings
1432 * @return array Breadcrumb items
1433 */
1434 private function get_breadcrumb_items(array $settings): array {
1435 $items = [];
1436
1437 // Always start with home
1438 $home_text = $settings['breadcrumb_home_text'] ?? 'Home';
1439 $items[] = [
1440 'title' => $home_text,
1441 'url' => home_url(),
1442 'position' => 1
1443 ];
1444
1445 $position = 2;
1446
1447 if (is_single()) {
1448 $current_post_id = get_the_ID();
1449
1450 if ($current_post_id) {
1451 // Add categories for posts
1452 $post_type = get_post_type($current_post_id);
1453 if ($post_type === 'post') {
1454 $categories = get_the_category($current_post_id);
1455 if (!empty($categories)) {
1456 $category = $categories[0];
1457 $items[] = [
1458 'title' => $category->name,
1459 'url' => get_category_link($category->term_id),
1460 'position' => $position++
1461 ];
1462 }
1463 }
1464
1465 // Add current post
1466 if (empty($settings['show_current_page']) || $settings['show_current_page']) {
1467 $items[] = [
1468 'title' => get_the_title($current_post_id),
1469 'url' => get_permalink($current_post_id),
1470 'position' => $position,
1471 'current' => true
1472 ];
1473 }
1474 }
1475
1476 } elseif (is_page()) {
1477 $current_post_id = get_the_ID();
1478
1479 if ($current_post_id) {
1480 // Add parent pages
1481 $parents = [];
1482 $parent_id = wp_get_post_parent_id($current_post_id);
1483
1484 while ($parent_id) {
1485 $parent = get_post($parent_id);
1486 if ($parent) {
1487 $parents[] = [
1488 'title' => get_the_title($parent->ID),
1489 'url' => get_permalink($parent->ID),
1490 'position' => 0 // Will be set later
1491 ];
1492 $parent_id = $parent->post_parent;
1493 } else {
1494 break;
1495 }
1496 }
1497
1498 // Reverse to get correct order
1499 $parents = array_reverse($parents);
1500
1501 // Add parents with correct positions
1502 foreach ($parents as $parent) {
1503 $parent['position'] = $position++;
1504 $items[] = $parent;
1505 }
1506
1507 // Add current page
1508 if (empty($settings['show_current_page']) || $settings['show_current_page']) {
1509 $items[] = [
1510 'title' => get_the_title($current_post_id),
1511 'url' => get_permalink($current_post_id),
1512 'position' => $position,
1513 'current' => true
1514 ];
1515 }
1516 }
1517
1518 } elseif (is_category()) {
1519 $category = get_queried_object();
1520
1521 // Add parent categories
1522 $parents = [];
1523 $parent_id = $category->parent;
1524
1525 while ($parent_id) {
1526 $parent = get_category($parent_id);
1527 if ($parent && !is_wp_error($parent)) {
1528 $parents[] = [
1529 'title' => $parent->name,
1530 'url' => get_category_link($parent->term_id),
1531 'position' => 0 // Will be set later
1532 ];
1533 $parent_id = $parent->parent;
1534 } else {
1535 break;
1536 }
1537 }
1538
1539 // Reverse to get correct order
1540 $parents = array_reverse($parents);
1541
1542 // Add parents with correct positions
1543 foreach ($parents as $parent) {
1544 $parent['position'] = $position++;
1545 $items[] = $parent;
1546 }
1547
1548 // Add current category
1549 if (empty($settings['show_current_page']) || $settings['show_current_page']) {
1550 $items[] = [
1551 'title' => $category->name,
1552 'url' => get_category_link($category->term_id),
1553 'position' => $position,
1554 'current' => true
1555 ];
1556 }
1557 }
1558
1559 return $items;
1560 }
1561
1562 /**
1563 * Generate breadcrumb HTML
1564 *
1565 * @param array $items Breadcrumb items
1566 * @param array $settings Breadcrumb settings
1567 * @return string HTML output
1568 */
1569 private function generate_breadcrumb_html(array $items, array $settings): string {
1570 if (empty($items)) {
1571 return '';
1572 }
1573
1574 $separator = $settings['breadcrumb_separator'] ?? '>';
1575 $prefix = $settings['breadcrumb_prefix'] ?? '';
1576
1577 $html = '<nav class="thinkrank-breadcrumbs" aria-label="Breadcrumb">';
1578
1579 if (!empty($prefix)) {
1580 $html .= '<span class="breadcrumb-prefix">' . esc_html($prefix) . '</span> ';
1581 }
1582
1583 $html .= '<ol class="breadcrumb-list">';
1584
1585 $total_items = count($items);
1586
1587 foreach ($items as $index => $item) {
1588 $is_last = ($index === $total_items - 1);
1589 $is_current = !empty($item['current']);
1590
1591 $html .= '<li class="breadcrumb-item' . ($is_current ? ' current' : '') . '">';
1592
1593 if (!$is_current && !empty($item['url'])) {
1594 $html .= '<a href="' . esc_url($item['url']) . '">' . esc_html($item['title']) . '</a>';
1595 } else {
1596 $html .= '<span>' . esc_html($item['title']) . '</span>';
1597 }
1598
1599 if (!$is_last) {
1600 $html .= ' <span class="breadcrumb-separator">' . esc_html($separator) . '</span> ';
1601 }
1602
1603 $html .= '</li>';
1604 }
1605
1606 $html .= '</ol>';
1607 $html .= '</nav>';
1608
1609 return $html;
1610 }
1611
1612 /**
1613 * Generate breadcrumb schema markup
1614 *
1615 * @param array $items Breadcrumb items
1616 * @return array Schema data
1617 */
1618 private function generate_breadcrumb_schema(array $items): array {
1619 if (empty($items)) {
1620 return [];
1621 }
1622
1623 $schema_items = [];
1624
1625 foreach ($items as $item) {
1626 $schema_items[] = [
1627 '@type' => 'ListItem',
1628 'position' => $item['position'],
1629 'name' => $item['title'],
1630 'item' => $item['url']
1631 ];
1632 }
1633
1634 return [
1635 '@context' => 'https://schema.org',
1636 '@type' => 'BreadcrumbList',
1637 'itemListElement' => $schema_items
1638 ];
1639 }
1640
1641 /**
1642 * Debug method to verify priority system (remove in production)
1643 *
1644 * @return array Debug information about current page SEO data
1645 */
1646 public function debug_seo_priority(): array {
1647 if (!defined('WP_DEBUG') || !WP_DEBUG) {
1648 return [];
1649 }
1650
1651 $debug = [
1652 'context' => $this->current_context,
1653 'is_singular' => is_singular(),
1654 'post_id' => $this->current_post_id,
1655 'has_post_metadata' => $this->has_thinkrank_metadata(),
1656 'post_metadata' => $this->current_metadata,
1657 'site_identity_enabled' => $this->site_identity_data && $this->site_identity_data['enabled'],
1658 'final_title' => '',
1659 'final_description' => '',
1660 'title_source' => '',
1661 'description_source' => ''
1662 ];
1663
1664 // Determine title source
1665 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
1666 $debug['final_title'] = $this->current_metadata['title'];
1667 $debug['title_source'] = 'post_metadata';
1668 } else {
1669 $generated_title = $this->generate_context_title();
1670 if ($generated_title) {
1671 $debug['final_title'] = $generated_title;
1672 $debug['title_source'] = 'site_identity_template';
1673 } else {
1674 $debug['final_title'] = is_singular() ? get_the_title() : get_bloginfo('name');
1675 $debug['title_source'] = 'wordpress_default';
1676 }
1677 }
1678
1679 // Determine description source
1680 $description = $this->get_meta_description();
1681 if ($description) {
1682 $debug['final_description'] = $description;
1683
1684 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['description'])) {
1685 $debug['description_source'] = 'post_metadata';
1686 } elseif ($this->site_identity_data && $this->site_identity_data['enabled']) {
1687 $settings = $this->site_identity_manager->get_settings('site');
1688 if (!empty($settings['default_meta_description'])) {
1689 $debug['description_source'] = 'site_identity_default';
1690 } else {
1691 $debug['description_source'] = 'auto_generated';
1692 }
1693 } else {
1694 $debug['description_source'] = 'auto_generated_or_site_description';
1695 }
1696 } else {
1697 $debug['description_source'] = 'none';
1698 }
1699
1700 return $debug;
1701 }
1702 }
1703