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

1,766 lines 62.4 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 Twitter image with proper fallback priority
815 $twitter_image_url = $this->get_twitter_image_with_fallback();
816 if ($twitter_image_url) {
817 echo "<meta name=\"twitter:image\" content=\"" . esc_url($twitter_image_url) . "\" />\n";
818
819 // Add image alt text for accessibility (if it's a featured image)
820 if (is_singular() && $this->current_post_id && has_post_thumbnail($this->current_post_id)) {
821 $featured_image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
822 if ($twitter_image_url === $featured_image_url) {
823 $image_id = get_post_thumbnail_id($this->current_post_id);
824 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', true);
825 if ($image_alt) {
826 echo "<meta name=\"twitter:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n";
827 }
828 }
829 }
830 }
831
832 // Add site Twitter handle if configured
833 if ($this->site_identity_data && !empty($this->site_identity_data['social']['twitter_username'])) {
834 $twitter_handle = $this->site_identity_data['social']['twitter_username'];
835 // Ensure handle starts with @
836 if (strpos($twitter_handle, '@') !== 0) {
837 $twitter_handle = '@' . $twitter_handle;
838 }
839 echo "<meta name=\"twitter:site\" content=\"" . esc_attr($twitter_handle) . "\" />\n";
840 }
841 echo "<!-- /ThinkRank SEO Twitter Card Meta Tags -->\n";
842 }
843
844 /**
845 * Output canonical URL
846 *
847 * @return void
848 */
849 public function output_canonical_url(): void {
850 if (!is_singular()) {
851 return;
852 }
853
854 $canonical_url = $this->current_post_id ? get_permalink($this->current_post_id) : get_permalink();
855 echo "<!-- ThinkRank SEO Canonical URL -->\n";
856 echo "<link rel=\"canonical\" href=\"" . esc_url($canonical_url) . "\" />\n";
857 echo "<!-- /ThinkRank SEO Canonical URL -->\n";
858 }
859
860
861
862 /**
863 * Check if ThinkRank has metadata for current post
864 *
865 * @return bool True if has ThinkRank metadata
866 */
867 private function has_thinkrank_metadata(): bool {
868 if (!is_singular()) {
869 return false;
870 }
871
872 return !empty($this->current_metadata['title']) || !empty($this->current_metadata['description']);
873 }
874
875 /**
876 * Check if current page has SEO data (public method for template functions)
877 *
878 * @return bool True if has SEO data
879 */
880 public function has_seo_data(): bool {
881 // Check if Site Identity is enabled and active
882 if ($this->site_identity_data && $this->site_identity_data['enabled']) {
883 return true;
884 }
885
886 // Check if post has ThinkRank metadata
887 return $this->has_thinkrank_metadata();
888 }
889
890 /**
891 * Get current breadcrumbs data (public method for template functions)
892 *
893 * @return array|null Breadcrumb data or null if not available
894 */
895 public function get_current_breadcrumbs(): ?array {
896 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
897 return null;
898 }
899
900 $settings = $this->site_identity_manager->get_settings('site');
901
902 if (empty($settings['breadcrumbs_enabled'])) {
903 return null;
904 }
905
906 return $this->generate_breadcrumbs($settings);
907 }
908
909 /**
910 * Get current SEO metadata
911 *
912 * @return array Current metadata
913 */
914 public function get_current_metadata(): array {
915 return $this->current_metadata;
916 }
917
918 /**
919 * Generate title based on current context using Site Identity templates
920 *
921 * @return string|null Generated title or null if no template available
922 */
923 private function generate_context_title(): ?string {
924 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
925 return null;
926 }
927
928 $template = $this->get_title_template_for_context();
929 if (!$template) {
930 return null;
931 }
932
933 $placeholders = $this->get_title_placeholders();
934 return $this->process_title_template($template, $placeholders);
935 }
936
937 /**
938 * Get title template for current context
939 *
940 * @return string|null Template string or null if not found
941 */
942 private function get_title_template_for_context(): ?string {
943 $settings = $this->site_identity_manager->get_settings('site');
944
945 switch ($this->current_context) {
946 case 'homepage':
947 return $settings['homepage_title'] ?? null;
948 case 'post':
949 return $settings['post_title'] ?? null;
950 case 'page':
951 return $settings['page_title'] ?? null;
952 case 'category':
953 return $settings['category_title'] ?? null;
954 case 'tag':
955 return $settings['tag_title'] ?? null;
956 case 'author':
957 return $settings['author_title'] ?? null;
958 case 'search':
959 return $settings['search_title'] ?? null;
960 case 'archive':
961 return $settings['archive_title'] ?? null;
962 default:
963 return null;
964 }
965 }
966
967 /**
968 * Get title placeholders for current context
969 *
970 * @return array Placeholder values
971 */
972 private function get_title_placeholders(): array {
973 global $post, $wp_query;
974
975 $settings = $this->site_identity_manager->get_settings('site');
976 $separator = $this->get_title_separator($settings['title_separator'] ?? 'pipe');
977
978 $placeholders = [
979 '%site_title%' => $settings['site_name'] ?? get_bloginfo('name'),
980 '%site_name%' => $settings['site_name'] ?? get_bloginfo('name'),
981 '%site_description%' => $settings['site_description'] ?? get_bloginfo('description'),
982 '%tagline%' => $settings['tagline'] ?? get_bloginfo('description'),
983 '%separator%' => ' ' . $separator . ' ',
984 '%date%' => gmdate('F Y'),
985 ];
986
987 // Context-specific placeholders
988 switch ($this->current_context) {
989 case 'post':
990 case 'page':
991 if ($this->current_post_id) {
992 $placeholders['%post_title%'] = get_the_title($this->current_post_id);
993 $placeholders['%page_title%'] = get_the_title($this->current_post_id);
994 $post_author = get_post_field('post_author', $this->current_post_id);
995 $placeholders['%author%'] = get_the_author_meta('display_name', $post_author);
996 $placeholders['%author_name%'] = get_the_author_meta('display_name', $post_author);
997
998 // Get categories for posts
999 $post_type = get_post_type($this->current_post_id);
1000 if ($post_type === 'post') {
1001 $categories = get_the_category($this->current_post_id);
1002 $placeholders['%category%'] = !empty($categories) ? $categories[0]->name : '';
1003 }
1004 }
1005 break;
1006
1007 case 'category':
1008 $category = get_queried_object();
1009 if ($category) {
1010 $placeholders['%category_title%'] = $category->name;
1011 $placeholders['%category%'] = $category->name;
1012 }
1013 break;
1014
1015 case 'tag':
1016 $tag = get_queried_object();
1017 if ($tag) {
1018 $placeholders['%tag_title%'] = $tag->name;
1019 $placeholders['%tag%'] = $tag->name;
1020 }
1021 break;
1022
1023 case 'author':
1024 $author = get_queried_object();
1025 if ($author) {
1026 $placeholders['%author_name%'] = $author->display_name;
1027 $placeholders['%author%'] = $author->display_name;
1028 }
1029 break;
1030
1031 case 'search':
1032 $placeholders['%search_term%'] = get_search_query();
1033 break;
1034
1035 case 'archive':
1036 $placeholders['%archive_title%'] = get_the_archive_title();
1037 break;
1038 }
1039
1040 return $placeholders;
1041 }
1042
1043 /**
1044 * Process title template with placeholders
1045 *
1046 * @param string $template Template string
1047 * @param array $placeholders Placeholder values
1048 * @return string Processed title
1049 */
1050 private function process_title_template(string $template, array $placeholders): string {
1051 $title = str_replace(array_keys($placeholders), array_values($placeholders), $template);
1052
1053 // Clean up multiple separators and extra spaces
1054 $separator = $placeholders['%separator%'] ?? ' | ';
1055 $title = preg_replace('/\s*' . preg_quote(trim($separator), '/') . '\s*' . preg_quote(trim($separator), '/') . '\s*/', $separator, $title);
1056 $title = preg_replace('/\s+/', ' ', $title);
1057 $title = trim($title);
1058
1059 // Remove trailing separator
1060 $separator_trimmed = trim($separator);
1061 if (substr($title, -strlen($separator_trimmed)) === $separator_trimmed) {
1062 $title = trim(substr($title, 0, -strlen($separator_trimmed)));
1063 }
1064
1065 return $title;
1066 }
1067
1068 /**
1069 * Get title separator symbol
1070 *
1071 * @param string $separator_type Separator type
1072 * @return string Separator symbol
1073 */
1074 private function get_title_separator(string $separator_type): string {
1075 $separators = [
1076 'pipe' => '|',
1077 'dash' => '-',
1078 'ndash' => '',
1079 'mdash' => '',
1080 'middot' => '·',
1081 'bull' => '',
1082 'star' => '*',
1083 'smstar' => '',
1084 'gt' => '>',
1085 'raquo' => '»',
1086 ];
1087
1088 return $separators[$separator_type] ?? '|';
1089 }
1090
1091 /**
1092 * Get meta description with fallback system
1093 *
1094 * @return string|null Meta description or null if none available
1095 */
1096 private function get_meta_description(): ?string {
1097 // First priority: Post-specific ThinkRank metadata
1098 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['description'])) {
1099 return $this->current_metadata['description'];
1100 }
1101
1102 // Second priority: Site Identity default meta description
1103 if ($this->site_identity_data && $this->site_identity_data['enabled']) {
1104 $settings = $this->site_identity_manager->get_settings('site');
1105 $default_description = $settings['default_meta_description'] ?? '';
1106
1107 if (!empty($default_description)) {
1108 return $default_description;
1109 }
1110 }
1111
1112 // Third priority: Generate from content for posts/pages
1113 if (is_singular() && $this->current_post_id) {
1114 $post_content = get_post_field('post_content', $this->current_post_id);
1115 if ($post_content) {
1116 $excerpt = wp_trim_words(wp_strip_all_tags($post_content), 25, '...');
1117 if (!empty($excerpt)) {
1118 return $excerpt;
1119 }
1120 }
1121 }
1122
1123 // Fourth priority: Site description for homepage
1124 if (is_home() || is_front_page()) {
1125 $site_description = get_bloginfo('description');
1126 if (!empty($site_description)) {
1127 return $site_description;
1128 }
1129 }
1130
1131 return null;
1132 }
1133
1134 /**
1135 * Output site-wide schema markup with priority system
1136 *
1137 * Priority: Schema Manager > Site Identity (like Twitter Cards approach)
1138 *
1139 * @return void
1140 */
1141 public function output_site_schema_markup(): void {
1142 $has_schema_manager_output = false;
1143
1144 // PRIORITY 1: Always output site-wide schemas (Organization, Website, LocalBusiness, Person)
1145 if ($this->schema_manager) {
1146 $site_wide_schemas = $this->schema_manager->get_deployed_schemas('site', null);
1147
1148 if (!empty($site_wide_schemas)) {
1149 foreach ($site_wide_schemas as $schema_type => $schema_info) {
1150 $this->output_schema_markup($schema_info['data'], $schema_type, 'Schema Manager');
1151 }
1152 $has_schema_manager_output = true;
1153 }
1154 }
1155
1156 // PRIORITY 2: Also output page-specific schemas (Article, HowTo, FAQ, etc.) on individual posts/pages
1157 if ($this->schema_manager && (is_single() || is_page())) {
1158 $context_type = is_page() ? 'page' : 'post';
1159 $context_id = get_the_ID();
1160
1161 $page_specific_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
1162
1163 if (!empty($page_specific_schemas)) {
1164 foreach ($page_specific_schemas as $schema_type => $schema_info) {
1165 $this->output_schema_markup($schema_info['data'], $schema_type, 'Schema Manager');
1166 }
1167 $has_schema_manager_output = true;
1168 }
1169 }
1170
1171 // Skip Site Identity fallback if any Schema Manager schemas were output
1172 if ($has_schema_manager_output) {
1173 return;
1174 }
1175
1176 // PRIORITY 2: Fall back to Site Identity schemas (like basic Twitter Cards)
1177 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
1178 return;
1179 }
1180
1181 $settings = $this->site_identity_manager->get_settings('site');
1182
1183 // Only output on homepage or if organization schema is enabled
1184 if (!is_home() && !is_front_page() && empty($settings['organization_schema'])) {
1185 return;
1186 }
1187
1188 $schema = $this->generate_organization_schema($settings);
1189
1190 if ($schema) {
1191 $this->output_schema_markup($schema, 'Organization', 'Site Identity');
1192 }
1193 }
1194
1195 /**
1196 * Output schema markup with consistent formatting
1197 *
1198 * @param array $schema_data Schema data
1199 * @param string $schema_type Schema type name
1200 * @param string $source Source of schema (Schema Manager, Site Identity, etc.)
1201 * @return void
1202 */
1203 private function output_schema_markup(array $schema_data, string $schema_type, string $source): void {
1204 if (empty($schema_data)) {
1205 return;
1206 }
1207
1208 echo '<!-- ThinkRank ' . esc_html($source) . ': ' . esc_html($schema_type) . ' Schema -->' . "\n";
1209 echo '<script type="application/ld+json">' . "\n";
1210 echo wp_json_encode($schema_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
1211 echo '</script>' . "\n";
1212 echo '<!-- /ThinkRank ' . esc_html($source) . ': ' . esc_html($schema_type) . ' Schema -->' . "\n";
1213 }
1214
1215 /**
1216 * Generate organization schema markup
1217 *
1218 * @param array $settings Site identity settings
1219 * @return array|null Schema data or null if insufficient data
1220 */
1221 private function generate_organization_schema(array $settings): ?array {
1222 $site_name = $settings['site_name'] ?? get_bloginfo('name');
1223 $site_description = $settings['site_description'] ?? get_bloginfo('description');
1224
1225 if (empty($site_name)) {
1226 return null;
1227 }
1228
1229 $schema = [
1230 '@context' => 'https://schema.org',
1231 '@type' => 'Organization',
1232 '@id' => home_url() . '#organization',
1233 'name' => $site_name,
1234 'url' => home_url(),
1235 ];
1236
1237 // Add description if available
1238 if (!empty($site_description)) {
1239 $schema['description'] = $site_description;
1240 }
1241
1242 // Add logo if available with proper ImageObject structure
1243 if (!empty($settings['logo_url'])) {
1244 $schema['logo'] = [
1245 '@type' => 'ImageObject',
1246 '@id' => home_url() . '#logo',
1247 'url' => $settings['logo_url'],
1248 'contentUrl' => $settings['logo_url'],
1249 'caption' => $site_name . ' Logo'
1250 ];
1251
1252 // Also add as image property
1253 $schema['image'] = $schema['logo'];
1254 }
1255
1256 // Add social media accounts if available
1257 $social_urls = [];
1258 if (!empty($this->site_identity_data['social'])) {
1259 $social_data = $this->site_identity_data['social'];
1260
1261 if (!empty($social_data['facebook_url'])) {
1262 $social_urls[] = $social_data['facebook_url'];
1263 }
1264 if (!empty($social_data['twitter_username'])) {
1265 $twitter_url = 'https://twitter.com/' . ltrim($social_data['twitter_username'], '@');
1266 $social_urls[] = $twitter_url;
1267 }
1268 if (!empty($social_data['linkedin_url'])) {
1269 $social_urls[] = $social_data['linkedin_url'];
1270 }
1271 if (!empty($social_data['instagram_url'])) {
1272 $social_urls[] = $social_data['instagram_url'];
1273 }
1274 if (!empty($social_data['youtube_url'])) {
1275 $social_urls[] = $social_data['youtube_url'];
1276 }
1277 }
1278
1279 if (!empty($social_urls)) {
1280 $schema['sameAs'] = $social_urls;
1281 }
1282
1283 // Add contact information if available
1284 if (!empty($settings['contact_email'])) {
1285 $schema['email'] = $settings['contact_email'];
1286 }
1287
1288 return $schema;
1289 }
1290
1291 /**
1292 * Output breadcrumb schema markup
1293 *
1294 * @return void
1295 */
1296 public function output_breadcrumb_schema(): void {
1297 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
1298 return;
1299 }
1300
1301 $settings = $this->site_identity_manager->get_settings('site');
1302
1303 // Only output if breadcrumbs are enabled
1304 if (empty($settings['breadcrumbs_enabled'])) {
1305 return;
1306 }
1307
1308 $breadcrumbs = $this->generate_breadcrumbs($settings);
1309
1310 if (!empty($breadcrumbs['schema'])) {
1311 echo "<!-- ThinkRank SEO Breadcrumb Schema Markup -->\n";
1312 echo '<script type="application/ld+json">' . "\n";
1313 echo wp_json_encode($breadcrumbs['schema'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
1314 echo '</script>' . "\n";
1315 echo "<!-- /ThinkRank SEO Breadcrumb Schema Markup -->\n";
1316 }
1317 }
1318
1319 /**
1320 * Output closing comment for ThinkRank SEO
1321 *
1322 * @return void
1323 */
1324 public function output_closing_comment(): void {
1325 // Only output if we've output any SEO content
1326 static $header_output = false;
1327 if ($header_output || $this->has_seo_output()) {
1328 echo "<!-- /ThinkRank SEO -->\n";
1329 }
1330 }
1331
1332 /**
1333 * Check if any SEO content has been output
1334 *
1335 * @return bool True if SEO content was output
1336 */
1337 private function has_seo_output(): bool {
1338 // Check if we have meta description or any other SEO data
1339 return !empty($this->get_meta_description()) ||
1340 $this->has_thinkrank_metadata() ||
1341 ($this->site_identity_data && $this->site_identity_data['enabled']);
1342 }
1343
1344 /**
1345 * Display breadcrumbs HTML
1346 *
1347 * @return void
1348 */
1349 public function display_breadcrumbs(): void {
1350 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
1351 return;
1352 }
1353
1354 $settings = $this->site_identity_manager->get_settings('site');
1355
1356 // Only display if breadcrumbs are enabled
1357 if (empty($settings['breadcrumbs_enabled'])) {
1358 return;
1359 }
1360
1361 $breadcrumbs = $this->generate_breadcrumbs($settings);
1362
1363 if (!empty($breadcrumbs['html'])) {
1364 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML is properly escaped in generate_breadcrumb_html method
1365 echo $breadcrumbs['html'];
1366 }
1367 }
1368
1369 /**
1370 * Generate breadcrumbs data
1371 *
1372 * @param array $settings Breadcrumb settings
1373 * @return array Breadcrumb data with HTML and schema
1374 */
1375 private function generate_breadcrumbs(array $settings): array {
1376 $breadcrumbs = [
1377 'items' => [],
1378 'html' => '',
1379 'schema' => null
1380 ];
1381
1382 // Get breadcrumb items
1383 $items = $this->get_breadcrumb_items($settings);
1384
1385 if (empty($items)) {
1386 return $breadcrumbs;
1387 }
1388
1389 $breadcrumbs['items'] = $items;
1390
1391 // Generate HTML
1392 $breadcrumbs['html'] = $this->generate_breadcrumb_html($items, $settings);
1393
1394 // Generate schema
1395 $breadcrumbs['schema'] = $this->generate_breadcrumb_schema($items);
1396
1397 return $breadcrumbs;
1398 }
1399
1400 /**
1401 * Filter WordPress robots.txt output
1402 *
1403 * @param string $output The default robots.txt output
1404 * @param string $public Whether the site is public
1405 * @return string Modified robots.txt content
1406 */
1407 public function filter_robots_txt(string $output, string $public): string {
1408 // Only override if Site Identity is enabled and robots.txt management is enabled
1409 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
1410 return $output;
1411 }
1412
1413 $settings = $this->site_identity_manager->get_settings('site');
1414 if (empty($settings['robots_txt_enabled'])) {
1415 return $output;
1416 }
1417
1418 // Generate ThinkRank robots.txt content
1419 try {
1420 $robots_data = $this->site_identity_manager->generate_robots_txt();
1421
1422 if (!empty($robots_data['content'])) {
1423 return $robots_data['content'];
1424 }
1425 } catch (\Exception $e) {
1426 // Robots.txt generation failed - fallback to default output
1427 }
1428
1429 // Fallback to default output if generation fails
1430 return $output;
1431 }
1432
1433 /**
1434 * Get breadcrumb items for current page
1435 *
1436 * @param array $settings Breadcrumb settings
1437 * @return array Breadcrumb items
1438 */
1439 private function get_breadcrumb_items(array $settings): array {
1440 $items = [];
1441
1442 // Always start with home
1443 $home_text = $settings['breadcrumb_home_text'] ?? 'Home';
1444 $items[] = [
1445 'title' => $home_text,
1446 'url' => home_url(),
1447 'position' => 1
1448 ];
1449
1450 $position = 2;
1451
1452 if (is_single()) {
1453 $current_post_id = get_the_ID();
1454
1455 if ($current_post_id) {
1456 // Add categories for posts
1457 $post_type = get_post_type($current_post_id);
1458 if ($post_type === 'post') {
1459 $categories = get_the_category($current_post_id);
1460 if (!empty($categories)) {
1461 $category = $categories[0];
1462 $items[] = [
1463 'title' => $category->name,
1464 'url' => get_category_link($category->term_id),
1465 'position' => $position++
1466 ];
1467 }
1468 }
1469
1470 // Add current post
1471 if (empty($settings['show_current_page']) || $settings['show_current_page']) {
1472 $items[] = [
1473 'title' => get_the_title($current_post_id),
1474 'url' => get_permalink($current_post_id),
1475 'position' => $position,
1476 'current' => true
1477 ];
1478 }
1479 }
1480
1481 } elseif (is_page()) {
1482 $current_post_id = get_the_ID();
1483
1484 if ($current_post_id) {
1485 // Add parent pages
1486 $parents = [];
1487 $parent_id = wp_get_post_parent_id($current_post_id);
1488
1489 while ($parent_id) {
1490 $parent = get_post($parent_id);
1491 if ($parent) {
1492 $parents[] = [
1493 'title' => get_the_title($parent->ID),
1494 'url' => get_permalink($parent->ID),
1495 'position' => 0 // Will be set later
1496 ];
1497 $parent_id = $parent->post_parent;
1498 } else {
1499 break;
1500 }
1501 }
1502
1503 // Reverse to get correct order
1504 $parents = array_reverse($parents);
1505
1506 // Add parents with correct positions
1507 foreach ($parents as $parent) {
1508 $parent['position'] = $position++;
1509 $items[] = $parent;
1510 }
1511
1512 // Add current page
1513 if (empty($settings['show_current_page']) || $settings['show_current_page']) {
1514 $items[] = [
1515 'title' => get_the_title($current_post_id),
1516 'url' => get_permalink($current_post_id),
1517 'position' => $position,
1518 'current' => true
1519 ];
1520 }
1521 }
1522
1523 } elseif (is_category()) {
1524 $category = get_queried_object();
1525
1526 // Add parent categories
1527 $parents = [];
1528 $parent_id = $category->parent;
1529
1530 while ($parent_id) {
1531 $parent = get_category($parent_id);
1532 if ($parent && !is_wp_error($parent)) {
1533 $parents[] = [
1534 'title' => $parent->name,
1535 'url' => get_category_link($parent->term_id),
1536 'position' => 0 // Will be set later
1537 ];
1538 $parent_id = $parent->parent;
1539 } else {
1540 break;
1541 }
1542 }
1543
1544 // Reverse to get correct order
1545 $parents = array_reverse($parents);
1546
1547 // Add parents with correct positions
1548 foreach ($parents as $parent) {
1549 $parent['position'] = $position++;
1550 $items[] = $parent;
1551 }
1552
1553 // Add current category
1554 if (empty($settings['show_current_page']) || $settings['show_current_page']) {
1555 $items[] = [
1556 'title' => $category->name,
1557 'url' => get_category_link($category->term_id),
1558 'position' => $position,
1559 'current' => true
1560 ];
1561 }
1562 }
1563
1564 return $items;
1565 }
1566
1567 /**
1568 * Generate breadcrumb HTML
1569 *
1570 * @param array $items Breadcrumb items
1571 * @param array $settings Breadcrumb settings
1572 * @return string HTML output
1573 */
1574 private function generate_breadcrumb_html(array $items, array $settings): string {
1575 if (empty($items)) {
1576 return '';
1577 }
1578
1579 $separator = $settings['breadcrumb_separator'] ?? '>';
1580 $prefix = $settings['breadcrumb_prefix'] ?? '';
1581
1582 $html = '<nav class="thinkrank-breadcrumbs" aria-label="Breadcrumb">';
1583
1584 if (!empty($prefix)) {
1585 $html .= '<span class="breadcrumb-prefix">' . esc_html($prefix) . '</span> ';
1586 }
1587
1588 $html .= '<ol class="breadcrumb-list">';
1589
1590 $total_items = count($items);
1591
1592 foreach ($items as $index => $item) {
1593 $is_last = ($index === $total_items - 1);
1594 $is_current = !empty($item['current']);
1595
1596 $html .= '<li class="breadcrumb-item' . ($is_current ? ' current' : '') . '">';
1597
1598 if (!$is_current && !empty($item['url'])) {
1599 $html .= '<a href="' . esc_url($item['url']) . '">' . esc_html($item['title']) . '</a>';
1600 } else {
1601 $html .= '<span>' . esc_html($item['title']) . '</span>';
1602 }
1603
1604 if (!$is_last) {
1605 $html .= ' <span class="breadcrumb-separator">' . esc_html($separator) . '</span> ';
1606 }
1607
1608 $html .= '</li>';
1609 }
1610
1611 $html .= '</ol>';
1612 $html .= '</nav>';
1613
1614 return $html;
1615 }
1616
1617 /**
1618 * Generate breadcrumb schema markup
1619 *
1620 * @param array $items Breadcrumb items
1621 * @return array Schema data
1622 */
1623 private function generate_breadcrumb_schema(array $items): array {
1624 if (empty($items)) {
1625 return [];
1626 }
1627
1628 $schema_items = [];
1629
1630 foreach ($items as $item) {
1631 $schema_items[] = [
1632 '@type' => 'ListItem',
1633 'position' => $item['position'],
1634 'name' => $item['title'],
1635 'item' => $item['url']
1636 ];
1637 }
1638
1639 return [
1640 '@context' => 'https://schema.org',
1641 '@type' => 'BreadcrumbList',
1642 'itemListElement' => $schema_items
1643 ];
1644 }
1645
1646 /**
1647 * Debug method to verify priority system (remove in production)
1648 *
1649 * @return array Debug information about current page SEO data
1650 */
1651 public function debug_seo_priority(): array {
1652 if (!defined('WP_DEBUG') || !WP_DEBUG) {
1653 return [];
1654 }
1655
1656 $debug = [
1657 'context' => $this->current_context,
1658 'is_singular' => is_singular(),
1659 'post_id' => $this->current_post_id,
1660 'has_post_metadata' => $this->has_thinkrank_metadata(),
1661 'post_metadata' => $this->current_metadata,
1662 'site_identity_enabled' => $this->site_identity_data && $this->site_identity_data['enabled'],
1663 'final_title' => '',
1664 'final_description' => '',
1665 'title_source' => '',
1666 'description_source' => ''
1667 ];
1668
1669 // Determine title source
1670 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
1671 $debug['final_title'] = $this->current_metadata['title'];
1672 $debug['title_source'] = 'post_metadata';
1673 } else {
1674 $generated_title = $this->generate_context_title();
1675 if ($generated_title) {
1676 $debug['final_title'] = $generated_title;
1677 $debug['title_source'] = 'site_identity_template';
1678 } else {
1679 $debug['final_title'] = is_singular() ? get_the_title() : get_bloginfo('name');
1680 $debug['title_source'] = 'wordpress_default';
1681 }
1682 }
1683
1684 // Determine description source
1685 $description = $this->get_meta_description();
1686 if ($description) {
1687 $debug['final_description'] = $description;
1688
1689 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['description'])) {
1690 $debug['description_source'] = 'post_metadata';
1691 } elseif ($this->site_identity_data && $this->site_identity_data['enabled']) {
1692 $settings = $this->site_identity_manager->get_settings('site');
1693 if (!empty($settings['default_meta_description'])) {
1694 $debug['description_source'] = 'site_identity_default';
1695 } else {
1696 $debug['description_source'] = 'auto_generated';
1697 }
1698 } else {
1699 $debug['description_source'] = 'auto_generated_or_site_description';
1700 }
1701 } else {
1702 $debug['description_source'] = 'none';
1703 }
1704
1705 return $debug;
1706 }
1707
1708 /**
1709 * Get Twitter image with proper fallback priority
1710 *
1711 * @since 1.0.0
1712 *
1713 * @return string|null Twitter image URL or null if none available
1714 */
1715 private function get_twitter_image_with_fallback(): ?string {
1716 // For posts, check for post-specific Twitter image meta first (future enhancement)
1717 if (is_singular() && $this->current_post_id) {
1718 $post_twitter_image = get_post_meta($this->current_post_id, '_thinkrank_twitter_image', true);
1719 if (!empty($post_twitter_image)) {
1720 return $post_twitter_image;
1721 }
1722
1723 // Check featured image as fallback for posts
1724 if (has_post_thumbnail($this->current_post_id)) {
1725 $featured_image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
1726 if ($featured_image_url) {
1727 return $featured_image_url;
1728 }
1729 }
1730 }
1731
1732 // Check Social Meta Manager settings for Twitter-specific default image
1733 if ($this->social_manager) {
1734 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
1735 $social_settings = $this->social_manager->get_settings($social_context, $this->current_post_id);
1736
1737 // Prioritize Twitter-specific default image
1738 if (!empty($social_settings['default_twitter_image'])) {
1739 return $social_settings['default_twitter_image'];
1740 }
1741
1742 // Fallback to Open Graph default image
1743 if (!empty($social_settings['default_og_image'])) {
1744 return $social_settings['default_og_image'];
1745 }
1746
1747 // Final fallback to generic default image
1748 if (!empty($social_settings['default_image'])) {
1749 return $social_settings['default_image'];
1750 }
1751 }
1752
1753 // Check Site Identity data for social images
1754 if ($this->site_identity_data && !empty($this->site_identity_data['social'])) {
1755 $social_data = $this->site_identity_data['social'];
1756
1757 // Check for any configured social image
1758 if (!empty($social_data['default_image'])) {
1759 return $social_data['default_image'];
1760 }
1761 }
1762
1763 return null;
1764 }
1765 }
1766