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

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