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

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