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

2,876 lines 109.3 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 // Remove WordPress core's robots output so ours isn't duplicated.
128 // Core registers wp_robots() on wp_head at priority 1; without this the
129 // page would emit two <meta name="robots"> tags (core's + ThinkRank's).
130 // The priority MUST match core's (1) or remove_action is a no-op.
131 //
132 // Exception: when "Discourage search engines" is enabled (blog_public=0),
133 // leave core's wp_robots in place so it emits the native noindex directive,
134 // and ThinkRank suppresses its own robots tag (see output_seo_meta_tags).
135 if (get_option('blog_public')) {
136 remove_action('wp_head', 'wp_robots', 1);
137 }
138
139 // Output meta tags with HIGH priority
140 add_action('wp_head', [$this, 'output_meta_description'], 1);
141 add_action('wp_head', [$this, 'output_seo_meta_tags'], 2);
142 add_action('wp_head', [$this, 'output_open_graph_tags'], 3);
143 add_action('wp_head', [$this, 'output_twitter_card_tags'], 4);
144 add_action('wp_head', [$this, 'output_platform_meta_tags'], 5);
145
146 // Remove WordPress core's canonical output so ours isn't duplicated.
147 // Core registers rel_canonical() on wp_head at priority 10; without this
148 // the page would emit two <link rel="canonical"> tags on singular views.
149 remove_action('wp_head', 'rel_canonical');
150 add_action('wp_head', [$this, 'output_canonical_url'], 6);
151
152 // Add Site Identity specific outputs
153 add_action('wp_head', [$this, 'output_site_schema_markup'], 7);
154 add_action('wp_head', [$this, 'output_breadcrumb_schema'], 8);
155
156 // Add closing comment (runs last)
157 add_action('wp_head', [$this, 'output_closing_comment'], 99);
158
159 // Add breadcrumb display hook
160 add_action('thinkrank_breadcrumbs', [$this, 'display_breadcrumbs']);
161
162 // Breadcrumb shortcode for use inside post/page content
163 add_shortcode('thinkrank_breadcrumbs', [$this, 'breadcrumbs_shortcode']);
164
165 // Hero section (Site Identity → Hero & Branding): theme action hook +
166 // shortcode so the configured hero title/subtitle/CTA/background render.
167 add_action('thinkrank_hero', [$this, 'display_hero']);
168 add_shortcode('thinkrank_hero', [$this, 'hero_shortcode']);
169
170 // Add robots.txt filter hook
171 add_filter('robots_txt', [$this, 'filter_robots_txt'], 10, 2);
172
173 // Keep an existing physical robots.txt in step with WordPress's
174 // "Discourage search engines" toggle (blog_public). A physical file
175 // bypasses core's robots_txt filter, so flipping blog_public after the
176 // file was written would otherwise leave the previous crawl policy served
177 // until an unrelated robots save. Covers both transitions.
178 add_action('update_option_blog_public', [$this, 'on_blog_public_changed'], 10, 0);
179
180 // Serve the Site Identity favicon through core's site-icon pipeline so
181 // wp_site_icon() outputs it on the front-end (and previews pick it up)
182 add_filter('get_site_icon_url', [$this, 'filter_site_icon_url'], 10, 2);
183
184 // Process image SEO in content
185 add_filter('the_content', [$this, 'filter_content_images'], 99999);
186 add_filter('post_thumbnail_html', [$this, 'filter_content_images'], 11, 2);
187 add_filter('woocommerce_single_product_image_thumbnail_html', [$this, 'filter_content_images'], 11);
188
189 // Persist alt text to the Media Library for newly uploaded images (opt-in).
190 add_action('add_attachment', [$this, 'maybe_fill_attachment_alt']);
191 }
192
193 /**
194 * Initialize Site Identity Manager
195 *
196 * @return void
197 */
198 private function initialize_site_identity_manager(): void {
199 if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) {
200 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-site-identity-manager.php';
201 }
202
203 $this->site_identity_manager = new \ThinkRank\SEO\Site_Identity_Manager();
204 }
205
206 /**
207 * Initialize Social Meta Manager
208 *
209 * @return void
210 */
211 private function initialize_social_meta_manager(): void {
212 if (!class_exists('ThinkRank\\SEO\\Social_Meta_Manager')) {
213 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-social-meta-manager.php';
214 }
215
216 $this->social_manager = new \ThinkRank\SEO\Social_Meta_Manager();
217 }
218
219 /**
220 * Initialize Schema Manager for enhanced schema output
221 *
222 * @return void
223 */
224 private function initialize_schema_manager(): void {
225 if (!class_exists('ThinkRank\\SEO\\Schema_Management_System')) {
226 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-management-system.php';
227 }
228
229 // Initialize Schema Manager and store reference for integration
230 $this->schema_manager = new \ThinkRank\SEO\Schema_Management_System();
231 }
232
233 /**
234 * Initialize Global SEO Schema Output
235 *
236 * @return void
237 */
238 private function initialize_global_seo_schema(): void {
239 if (!class_exists('ThinkRank\\Frontend\\Global_SEO_Schema_Output')) {
240 require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-global-seo-schema-output.php';
241 }
242
243 // Initialize Global SEO Schema Output and store reference
244 $this->global_seo_schema = new Global_SEO_Schema_Output();
245 $this->global_seo_schema->init();
246 }
247
248 /**
249 * Initialize Google Analytics Tracking Manager
250 *
251 * @return void
252 */
253 private function initialize_google_analytics_tracking(): void {
254 if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
255 require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-google-analytics-tracking-manager.php';
256 }
257
258 // Initialize Google Analytics Tracking Manager
259 new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
260 }
261
262 /**
263 * Initialize Image SEO Manager
264 *
265 * @return void
266 */
267 private function initialize_image_seo_manager(): void {
268 if (!class_exists('ThinkRank\\SEO\\Image_SEO_Manager')) {
269 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-image-seo-manager.php';
270 }
271
272 $this->image_seo_manager = new \ThinkRank\SEO\Image_SEO_Manager();
273 }
274
275 /**
276 * Filter content to inject image SEO attributes
277 *
278 * @since 1.0.0
279 * @param string $content Content to filter
280 * @return string Filtered content
281 */
282 public function filter_content_images(string $content, $post_id = null): string {
283 if (!$this->image_seo_manager) {
284 return $content;
285 }
286
287 // Ensure post_id is an integer if provided
288 if ($post_id !== null && !is_numeric($post_id)) {
289 $post_id = null;
290 }
291
292 return $this->image_seo_manager->process_content($content, $post_id ? (int) $post_id : null);
293 }
294
295 /**
296 * Persist generated alt text to a freshly uploaded image (opt-in).
297 *
298 * Delegates to the Image SEO Manager, which no-ops unless the
299 * "save alt to media" + "fill on upload" settings are enabled.
300 *
301 * @since 1.19.1
302 * @param int $attachment_id The newly created attachment ID.
303 * @return void
304 */
305 public function maybe_fill_attachment_alt($attachment_id): void {
306 if ($this->image_seo_manager && is_numeric($attachment_id)) {
307 $this->image_seo_manager->maybe_auto_fill_on_upload((int) $attachment_id);
308 }
309 }
310
311 /**
312 * Initialize current context and post data
313 *
314 * @return void
315 */
316 public function initialize_current_context(): void {
317 // Determine current context
318 $this->current_context = $this->detect_current_context();
319
320 // Initialize post data if singular
321 if (is_singular()) {
322 $post_id = get_the_ID();
323 if ($post_id) {
324 $this->current_post_id = $post_id;
325 $this->current_metadata = $this->get_post_seo_metadata($post_id);
326 }
327 }
328
329 // Load site identity data
330 $this->load_site_identity_data();
331 }
332
333 /**
334 * Detect current page context
335 *
336 * @return string Current context type
337 */
338 private function detect_current_context(): string {
339 if (is_home() || is_front_page()) {
340 return 'homepage';
341 } elseif (is_single()) {
342 return 'post';
343 } elseif (is_page()) {
344 return 'page';
345 } elseif (is_category()) {
346 return 'category';
347 } elseif (is_tag()) {
348 return 'tag';
349 } elseif (is_author()) {
350 return 'author';
351 } elseif (is_search()) {
352 return 'search';
353 } elseif (is_archive()) {
354 return 'archive';
355 }
356
357 return 'site';
358 }
359
360 /**
361 * Load site identity data
362 *
363 * @return void
364 */
365 private function load_site_identity_data(): void {
366 if ($this->site_identity_manager && $this->site_identity_data === null) {
367 $this->site_identity_data = $this->site_identity_manager->get_output_data('site', null);
368 }
369 }
370
371 /**
372 * Get SEO metadata for a post
373 *
374 * @param int $post_id Post ID
375 * @return array SEO metadata
376 */
377 private function get_post_seo_metadata(int $post_id): array {
378 $focus_keywords = \ThinkRank\SEO\Focus_Keywords::get($post_id);
379
380 $title = get_post_meta($post_id, '_thinkrank_seo_title', true);
381 $description = get_post_meta($post_id, '_thinkrank_meta_description', true);
382
383 return [
384 // Per-post values may contain variable tags (e.g. "%title% %sep%
385 // %sitename%") entered in the metabox, so resolve them. Literal
386 // values without tags pass through unchanged.
387 'title' => $title ? \ThinkRank\SEO\Pattern_Resolver::resolve_value($title, $post_id) : $title,
388 'description' => $description ? \ThinkRank\SEO\Pattern_Resolver::resolve_value($description, $post_id) : $description,
389 'focus_keyword' => $focus_keywords[0] ?? '',
390 'focus_keywords' => $focus_keywords,
391 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true),
392 ];
393 }
394
395 /**
396 * Resolve the effective SEO title for the current request.
397 *
398 * Same priority chain as override_document_title() — post-specific
399 * ThinkRank metadata (resolved _thinkrank_seo_title) > Global SEO
400 * template > Site Identity template — without the raw WordPress-title
401 * fallback. Returns null when no ThinkRank-managed title applies, letting
402 * callers (e.g. the social manager OG fallback) drop to their own default.
403 *
404 * @return string|null Effective SEO title, or null if none applies.
405 */
406 private function get_effective_seo_title(): ?string {
407 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
408 return $this->current_metadata['title'];
409 }
410
411 return $this->generate_context_title();
412 }
413
414 /**
415 * Override WordPress document title (HIGH PRIORITY)
416 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates
417 *
418 * @param string $title Original title
419 * @return string Modified title
420 */
421 public function override_document_title($title): string {
422 // First priority: Post-specific ThinkRank metadata
423 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
424 return $this->current_metadata['title'];
425 }
426
427 // Second priority: Global SEO templates, Third priority: Site Identity templates
428 $generated_title = $this->generate_context_title();
429 if ($generated_title) {
430 return $generated_title;
431 }
432
433 return $title;
434 }
435
436 /**
437 * Override WordPress wp_title (HIGH PRIORITY)
438 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates
439 *
440 * @param string $title Original title
441 * @param string $sep Title separator
442 * @return string Modified title
443 */
444 public function override_wp_title(string $title, string $sep = ''): string {
445 // First priority: Post-specific ThinkRank metadata
446 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
447 $site_name = get_bloginfo('name');
448 return $this->current_metadata['title'] . ($sep ? " $sep " : ' | ') . $site_name;
449 }
450
451 // Second priority: Global SEO templates, Third priority: Site Identity templates
452 $generated_title = $this->generate_context_title();
453 if ($generated_title) {
454 return $generated_title;
455 }
456
457 return $title;
458 }
459
460 /**
461 * Output meta description (HIGH PRIORITY)
462 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates > WordPress defaults
463 *
464 * @return void
465 */
466 public function output_meta_description(): void {
467 $description = $this->get_meta_description();
468
469 if ($description) {
470 // Output main ThinkRank SEO header comment (only once)
471 static $header_output = false;
472 if (!$header_output) {
473 echo "<!-- Search Engine Optimization by ThinkRank - https://thinkrank.ai/ -->\n";
474 $header_output = true;
475 }
476
477 // Ensure description is within optimal length (150-160 characters)
478 if (strlen($description) > 160) {
479 $description = wp_trim_words($description, 25, '...');
480 }
481
482 echo "<!-- ThinkRank SEO Meta Description -->\n";
483 echo '<meta name="description" content="' . esc_attr($description) . '" />' . "\n";
484 echo "<!-- /ThinkRank SEO Meta Description -->\n";
485 }
486 }
487
488 /**
489 * Output SEO meta tags
490 *
491 * @return void
492 */
493 public function output_seo_meta_tags(): void {
494 echo "<!-- ThinkRank SEO Meta Tags -->\n";
495
496 // Output robots meta tag with proper directives.
497 // When "Discourage search engines" (blog_public=0) is enabled, defer to
498 // WordPress core's native noindex output and skip ThinkRank's tag so we
499 // don't emit a conflicting/duplicate directive.
500 if (get_option('blog_public')) {
501 $robots_content = $this->get_robots_meta_content();
502 echo '<meta name="robots" content="' . esc_attr($robots_content) . '" />' . "\n";
503 }
504
505 // Output focus keywords as meta keywords (all keywords, comma-separated)
506 $focus_keywords = $this->current_metadata['focus_keywords'] ?? [];
507 if (empty($focus_keywords) && !empty($this->current_metadata['focus_keyword'])) {
508 $focus_keywords = [$this->current_metadata['focus_keyword']];
509 }
510 if (!empty($focus_keywords)) {
511 $keywords = implode(', ', array_filter(array_map('trim', (array) $focus_keywords), 'strlen'));
512 if (!empty($keywords)) {
513 echo '<meta name="keywords" content="' . esc_attr($keywords) . '" />' . "\n";
514 }
515 }
516
517 // Output local SEO meta tags if business info is available
518 $this->output_local_seo_meta_tags();
519
520 // Output generator meta tag
521 echo '<meta name="generator" content="ThinkRank ' . esc_attr(THINKRANK_VERSION) . '" />' . "\n";
522
523 // Output viewport meta tag if not already present
524 if (!has_action('wp_head', 'wp_site_icon') || !wp_is_mobile()) {
525 echo '<meta name="viewport" content="width=device-width, initial-scale=1.0" />' . "\n";
526 }
527 echo "<!-- /ThinkRank SEO Meta Tags -->\n";
528 }
529
530 /**
531 * Get robots meta content based on context and settings
532 *
533 * @return string Robots meta content
534 */
535 private function get_robots_meta_content(): string {
536 $robots = [];
537
538 // 404 and search results must never be indexed, regardless of the
539 // configured global/post-type directives. Links are still followed so
540 // crawlers can discover the rest of the site.
541 if (is_404() || is_search()) {
542 $robots = apply_filters('thinkrank_robots_meta', ['noindex', 'follow']);
543 return implode(', ', array_unique($robots));
544 }
545
546 // 1. Get global robot meta settings (Base)
547 $global_settings = get_option('thinkrank_global_robot_meta_settings', []);
548
549 // Initialize current settings with global defaults
550 $current_settings = wp_parse_args($global_settings, [
551 'index' => true,
552 'noindex' => false,
553 'nofollow' => false,
554 'noarchive' => false,
555 'noimageindex' => false,
556 'nosnippet' => false,
557 ]);
558
559 // 2. Apply Post Type based option (if singular)
560 if (is_singular()) {
561 $post_type = get_post_type();
562 $global_seo_settings = get_option('thinkrank_global_seo_settings', []);
563
564 // Check if post type settings are enabled
565 $robots_enabled = isset($global_seo_settings[$post_type]['robots_meta_enabled']) && $global_seo_settings[$post_type]['robots_meta_enabled'];
566
567 if ($robots_enabled && isset($global_seo_settings[$post_type]['robots_meta']) && is_array($global_seo_settings[$post_type]['robots_meta'])) {
568 // Merge post type settings over global settings
569 $current_settings = array_merge($current_settings, $global_seo_settings[$post_type]['robots_meta']);
570 }
571 }
572
573 // Determine Index/Noindex based on merged settings
574 // Priority: if noindex is true, it overrides index
575 if (!empty($current_settings['noindex'])) {
576 $robots[] = 'noindex';
577 } else {
578 // Default to index if noindex is not set
579 $robots[] = 'index';
580 }
581
582 // Determine Follow/Nofollow based on merged settings
583 if (!empty($current_settings['nofollow'])) {
584 $robots[] = 'nofollow';
585 } else {
586 $robots[] = 'follow';
587 }
588
589 // Other directives
590 if (!empty($current_settings['noarchive'])) {
591 $robots[] = 'noarchive';
592 }
593 if (!empty($current_settings['noimageindex'])) {
594 $robots[] = 'noimageindex';
595 }
596 if (!empty($current_settings['nosnippet'])) {
597 $robots[] = 'nosnippet';
598 }
599
600 // Add advanced directives for better SEO
601 // Get advanced settings
602 $advanced_settings = [
603 'snippet_enabled' => true,
604 'max_snippet' => -1,
605 'video_preview_enabled' => true,
606 'max_video_preview' => -1,
607 'image_preview_enabled' => true,
608 'max_image_preview' => 'large'
609 ];
610
611 // Apply post type specific advanced settings if enabled
612 if (is_singular() && isset($robots_enabled) && $robots_enabled && isset($global_seo_settings[$post_type]['advanced_robots_meta'])) {
613 $advanced_settings = array_merge($advanced_settings, $global_seo_settings[$post_type]['advanced_robots_meta']);
614 }
615
616 // Generate advanced directives
617 if (empty($current_settings['nosnippet'])) {
618 if ($advanced_settings['snippet_enabled']) {
619 $robots[] = 'max-snippet:' . (int)$advanced_settings['max_snippet'];
620 }
621
622 if ($advanced_settings['video_preview_enabled']) {
623 $robots[] = 'max-video-preview:' . (int)$advanced_settings['max_video_preview'];
624 }
625 }
626
627 // Only add max-image-preview if we are allowing image indexing
628 if (empty($current_settings['noimageindex']) && $advanced_settings['image_preview_enabled']) {
629 $robots[] = 'max-image-preview:' . esc_attr($advanced_settings['max_image_preview']);
630 }
631
632 // 3. Check for single post meta based option (Overrides everything)
633 if (is_singular()) {
634 $robots = $this->apply_post_robots_override(get_the_ID(), $robots, $current_settings);
635 }
636
637 // Check for archive pages (search is handled by the early return above)
638 if (is_archive()) {
639 // Allow indexing of category/tag archives but be more conservative
640 if (is_paged()) {
641 $robots = ['noindex', 'follow'];
642 }
643
644 // Honor the global date-archive noindex toggle (written by the
645 // Rank Math/Yoast settings importer). Author archives are handled
646 // by Author_Archives_Manager via the thinkrank_robots_meta filter.
647 if (is_date() && !empty($current_settings['noindex_date_archives'])) {
648 $robots = ['noindex', 'follow'];
649 }
650 }
651
652 // Apply filters for customization
653 $robots = apply_filters('thinkrank_robots_meta', $robots);
654
655 // Remove duplicates and implode
656 return implode(', ', array_unique($robots));
657 }
658
659 /**
660 * Apply per-post robots overrides on top of the cascaded directives.
661 *
662 * Reads `_thinkrank_robots_meta` (JSON) when `_thinkrank_robots_meta_enabled`
663 * is truthy. When the override is off, the cascaded directives pass through
664 * unchanged.
665 *
666 * @param int $post_id Post being rendered
667 * @param array $robots Directives accumulated so far
668 * @param array $current_settings Effective robots flags (global + post type)
669 * @return array Updated robots directive list
670 */
671 private function apply_post_robots_override(int $post_id, array $robots, array $current_settings): array {
672 if (!(bool) get_post_meta($post_id, '_thinkrank_robots_meta_enabled', true)) {
673 return $robots;
674 }
675
676 $raw_robots = get_post_meta($post_id, '_thinkrank_robots_meta', true);
677 $post_robots = is_string($raw_robots) && $raw_robots !== '' ? json_decode($raw_robots, true) : null;
678 if (!is_array($post_robots)) {
679 return $robots;
680 }
681
682 $raw_advanced = get_post_meta($post_id, '_thinkrank_advanced_robots_meta', true);
683 $post_advanced = is_string($raw_advanced) && $raw_advanced !== '' ? json_decode($raw_advanced, true) : null;
684
685 $effective = array_merge($current_settings, array_intersect_key($post_robots, array_flip([
686 'index', 'noindex', 'nofollow', 'noarchive', 'noimageindex', 'nosnippet',
687 ])));
688
689 $rebuilt = [];
690 $rebuilt[] = !empty($effective['noindex']) ? 'noindex' : 'index';
691 $rebuilt[] = !empty($effective['nofollow']) ? 'nofollow' : 'follow';
692
693 if (!empty($effective['noarchive'])) {
694 $rebuilt[] = 'noarchive';
695 }
696 if (!empty($effective['noimageindex'])) {
697 $rebuilt[] = 'noimageindex';
698 }
699 if (!empty($effective['nosnippet'])) {
700 $rebuilt[] = 'nosnippet';
701 }
702
703 if (is_array($post_advanced)) {
704 $advanced = array_merge([
705 'snippet_enabled' => true,
706 'max_snippet' => -1,
707 'video_preview_enabled' => true,
708 'max_video_preview' => -1,
709 'image_preview_enabled' => true,
710 'max_image_preview' => 'large',
711 ], $post_advanced);
712
713 if (empty($effective['nosnippet'])) {
714 if (!empty($advanced['snippet_enabled'])) {
715 $rebuilt[] = 'max-snippet:' . (int) $advanced['max_snippet'];
716 }
717 if (!empty($advanced['video_preview_enabled'])) {
718 $rebuilt[] = 'max-video-preview:' . (int) $advanced['max_video_preview'];
719 }
720 }
721
722 if (empty($effective['noimageindex']) && !empty($advanced['image_preview_enabled'])) {
723 $rebuilt[] = 'max-image-preview:' . sanitize_text_field((string) $advanced['max_image_preview']);
724 }
725 }
726
727 return $rebuilt;
728 }
729
730 /**
731 * Output local SEO meta tags for business information
732 *
733 * @return void
734 */
735 private function output_local_seo_meta_tags(): void {
736 if (!$this->site_identity_manager) {
737 return;
738 }
739
740 $settings = $this->site_identity_manager->get_settings('site');
741
742 // Only output if local SEO is enabled and business info is available
743 if (empty($settings['local_seo_enabled']) || empty($settings['business_name'])) {
744 return;
745 }
746
747 echo "<!-- ThinkRank Local SEO Meta Tags -->\n";
748
749 // NAP (Name, Address, Phone) Consistency Meta Tags
750 if (!empty($settings['business_name'])) {
751 echo '<meta name="business:name" content="' . esc_attr($settings['business_name']) . '" />' . "\n";
752 }
753
754 // Business address components
755 if (!empty($settings['business_address'])) {
756 echo '<meta name="business:contact_data:street_address" content="' . esc_attr($settings['business_address']) . '" />' . "\n";
757 }
758
759 if (!empty($settings['business_city'])) {
760 echo '<meta name="business:contact_data:locality" content="' . esc_attr($settings['business_city']) . '" />' . "\n";
761 echo '<meta name="geo.placename" content="' . esc_attr($settings['business_city']) . '" />' . "\n";
762 }
763
764 if (!empty($settings['business_state'])) {
765 echo '<meta name="business:contact_data:region" content="' . esc_attr($settings['business_state']) . '" />' . "\n";
766 }
767
768 if (!empty($settings['business_postal_code'])) {
769 echo '<meta name="business:contact_data:postal_code" content="' . esc_attr($settings['business_postal_code']) . '" />' . "\n";
770 }
771
772 if (!empty($settings['business_country'])) {
773 echo '<meta name="business:contact_data:country_name" content="' . esc_attr($settings['business_country']) . '" />' . "\n";
774 }
775
776 // Phone number
777 if (!empty($settings['business_phone'])) {
778 echo '<meta name="business:contact_data:phone_number" content="' . esc_attr($settings['business_phone']) . '" />' . "\n";
779 }
780
781 // Email address
782 if (!empty($settings['business_email'])) {
783 echo '<meta name="business:contact_data:email" content="' . esc_attr($settings['business_email']) . '" />' . "\n";
784 }
785
786 // Geo-location meta tags (if coordinates are available)
787 if (!empty($settings['business_latitude']) && !empty($settings['business_longitude'])) {
788 $coordinates = $settings['business_latitude'] . ';' . $settings['business_longitude'];
789 echo '<meta name="geo.position" content="' . esc_attr($coordinates) . '" />' . "\n";
790 echo '<meta name="ICBM" content="' . esc_attr($settings['business_latitude'] . ', ' . $settings['business_longitude']) . '" />' . "\n";
791 }
792
793 // Regional meta tag (state/country combination)
794 if (!empty($settings['business_state']) && !empty($settings['business_country'])) {
795 $region = strtoupper($settings['business_country']) . '-' . strtoupper($settings['business_state']);
796 echo '<meta name="geo.region" content="' . esc_attr($region) . '" />' . "\n";
797 }
798
799 // Business hours in structured format
800 if (!empty($settings['business_hours']) && is_array($settings['business_hours'])) {
801 $formatted_hours = $this->format_business_hours_for_meta($settings['business_hours']);
802 if (!empty($formatted_hours)) {
803 echo '<meta name="business:hours" content="' . esc_attr($formatted_hours) . '" />' . "\n";
804 }
805 }
806
807 // Business type
808 if (!empty($settings['business_type'])) {
809 echo '<meta name="business:type" content="' . esc_attr($settings['business_type']) . '" />' . "\n";
810 }
811
812 echo "<!-- /ThinkRank Local SEO Meta Tags -->\n";
813 }
814
815 /**
816 * Format business hours for meta tag output
817 *
818 * @param array $business_hours Business hours array
819 * @return string Formatted hours string
820 */
821 private function format_business_hours_for_meta(array $business_hours): string {
822 $formatted_days = [];
823
824 $day_abbreviations = [
825 'monday' => 'Mo',
826 'tuesday' => 'Tu',
827 'wednesday' => 'We',
828 'thursday' => 'Th',
829 'friday' => 'Fr',
830 'saturday' => 'Sa',
831 'sunday' => 'Su'
832 ];
833
834 foreach ($day_abbreviations as $day => $abbrev) {
835 if (isset($business_hours[$day]) && !empty($business_hours[$day])) {
836 $day_data = $business_hours[$day];
837
838 if (!empty($day_data['closed']) || empty($day_data['open']) || empty($day_data['close'])) {
839 continue; // Skip closed days
840 }
841
842 $formatted_days[] = $abbrev . ' ' . $day_data['open'] . '-' . $day_data['close'];
843 }
844 }
845
846 return implode(', ', $formatted_days);
847 }
848
849 /**
850 * Output social media Open Graph tags from Social Meta Manager
851 *
852 * @param array $og_tags Open Graph tags array
853 * @return void
854 */
855 private function output_social_og_tags(array $og_tags): void {
856 // Honor the thinkrank_og_type filter here too — this "Enhanced" path is
857 // the active OG emitter, so add-ons (e.g. Pro's WooCommerce module which
858 // sets 'product' on product pages) must be applied to it, not only to
859 // output_open_graph_tags().
860 if (isset($og_tags['og:type'])) {
861 $og_tags['og:type'] = apply_filters('thinkrank_og_type', $og_tags['og:type']);
862 }
863
864 echo "<!-- ThinkRank SEO Open Graph Tags (Enhanced) -->\n";
865
866 // Define optimal order for Open Graph tags
867 $og_order = [
868 'og:title',
869 'og:description',
870 'og:type',
871 'og:url',
872 'og:site_name',
873 'og:locale',
874 'og:image',
875 'og:image:width',
876 'og:image:height',
877 'og:image:type',
878 'og:image:alt',
879 'article:published_time',
880 'article:modified_time',
881 'article:author',
882 'article:section'
883 ];
884
885 // Output tags in optimal order
886 foreach ($og_order as $property) {
887 if (!empty($og_tags[$property])) {
888 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_meta_value() applies esc_url()/esc_attr(); the sniff cannot follow a method call.
889 echo '<meta property="' . esc_attr($property) . '" content="' . $this->esc_meta_value($property, $og_tags[$property]) . '" />' . "\n";
890 }
891 }
892
893 // Output any remaining tags not in the order list
894 foreach ($og_tags as $property => $content) {
895 if (!empty($content) && !in_array($property, $og_order, true)) {
896 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_meta_value() applies esc_url()/esc_attr(); the sniff cannot follow a method call.
897 echo '<meta property="' . esc_attr($property) . '" content="' . $this->esc_meta_value($property, $content) . '" />' . "\n";
898 }
899 }
900
901 echo "<!-- /ThinkRank SEO Open Graph Tags -->\n";
902 }
903
904 /**
905 * Output social media Twitter Card tags from Social Meta Manager
906 *
907 * @param array $twitter_tags Twitter Card tags array
908 * @return void
909 */
910 private function output_social_twitter_tags(array $twitter_tags): void {
911 echo "<!-- ThinkRank SEO Twitter Card Tags (Enhanced) -->\n";
912
913 // Define optimal order for Twitter Card tags
914 $twitter_order = [
915 'twitter:card',
916 'twitter:title',
917 'twitter:description',
918 'twitter:site',
919 'twitter:creator',
920 'twitter:image',
921 'twitter:image:alt'
922 ];
923
924 // Output tags in optimal order
925 foreach ($twitter_order as $name) {
926 if (!empty($twitter_tags[$name])) {
927 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_meta_value() applies esc_url()/esc_attr(); the sniff cannot follow a method call.
928 echo '<meta name="' . esc_attr($name) . '" content="' . $this->esc_meta_value($name, $twitter_tags[$name]) . '" />' . "\n";
929 }
930 }
931
932 // Output any remaining tags not in the order list
933 foreach ($twitter_tags as $name => $content) {
934 if (!empty($content) && !in_array($name, $twitter_order, true)) {
935 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_meta_value() applies esc_url()/esc_attr(); the sniff cannot follow a method call.
936 echo '<meta name="' . esc_attr($name) . '" content="' . $this->esc_meta_value($name, $content) . '" />' . "\n";
937 }
938 }
939
940 echo "<!-- /ThinkRank SEO Twitter Card Tags -->\n";
941 }
942
943 /**
944 * Escape a social meta tag value, using esc_url() for URL-valued keys so a
945 * javascript:/data: scheme is stripped and output stays spec-compliant, and
946 * esc_attr() for everything else.
947 *
948 * @param string $key The OG/Twitter property or name.
949 * @param string|int $value The tag value. Image dimension keys
950 * (og:image:width/height) arrive as integers, so
951 * accept any scalar and normalise to string here —
952 * the file is under strict_types, which would
953 * otherwise throw a TypeError on the int.
954 * @return string Escaped value.
955 */
956 private function esc_meta_value(string $key, $value): string {
957 $value = (string) $value;
958 $url_keys = [
959 'og:image', 'og:image:url', 'og:image:secure_url', 'og:url',
960 'twitter:image', 'twitter:player',
961 ];
962 return in_array($key, $url_keys, true) ? esc_url($value) : esc_attr($value);
963 }
964
965 /**
966 * Output platform-specific meta tags
967 *
968 * @return void
969 */
970 public function output_platform_meta_tags(): void {
971 // Try Social Meta Manager for platform tags
972 if ($this->social_manager) {
973 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
974 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
975
976 // Pass the same effective title/description as the OG and Twitter
977 // callbacks so all three share one memoized get_output_data() result
978 // (platform tags don't depend on them, so output is unchanged).
979 $social_data = $this->social_manager->get_output_data(
980 $social_context,
981 $this->current_post_id,
982 $this->get_effective_seo_title(),
983 $this->get_meta_description()
984 );
985
986 if ($social_data['enabled'] && !empty($social_data['platform_tags'])) {
987 $this->output_social_platform_tags($social_data['platform_tags']);
988 }
989 }
990 }
991
992 /**
993 * Output social media platform tags from Social Meta Manager
994 *
995 * @param array $platform_tags Platform tags array
996 * @return void
997 */
998 private function output_social_platform_tags(array $platform_tags): void {
999 echo "<!-- ThinkRank SEO Platform Meta Tags -->\n";
1000
1001 foreach ($platform_tags as $name => $content) {
1002 if (!empty($content)) {
1003 // Determine if it should be property or name attribute
1004 if (strpos($name, 'fb:') === 0) {
1005 // Facebook tags use property attribute
1006 echo '<meta property="' . esc_attr($name) . '" content="' . esc_attr($content) . '" />' . "\n";
1007 } else {
1008 // Other platform tags use name attribute
1009 echo '<meta name="' . esc_attr($name) . '" content="' . esc_attr($content) . '" />' . "\n";
1010 }
1011 }
1012 }
1013
1014 echo "<!-- /ThinkRank SEO Platform Meta Tags -->\n";
1015 }
1016
1017 /**
1018 * Output Open Graph meta tags (HIGH PRIORITY)
1019 * Uses Social Meta Manager with fallback to Site Identity templates
1020 *
1021 * @return void
1022 */
1023 public function output_open_graph_tags(): void {
1024 // Priority 1: Try Social Meta Manager (Social Media tab settings)
1025 if ($this->social_manager) {
1026 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1027 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
1028
1029 // Effective SEO title/description for this request (resolved
1030 // per-post value > Global SEO template > Site Identity), identical
1031 // to what is output as the document <title>/meta description and
1032 // mirrored by the Social metabox preview. Passed as fallbacks so a
1033 // cleared Open Graph Title/Description renders the same inherited
1034 // value the preview shows.
1035 $social_data = $this->social_manager->get_output_data(
1036 $social_context,
1037 $this->current_post_id,
1038 $this->get_effective_seo_title(),
1039 $this->get_meta_description()
1040 );
1041
1042 // The Social Meta Manager ran, so it owns Open Graph output. If OG is
1043 // toggled off, emit nothing — do NOT fall through to the basic
1044 // emitter (which would re-add a full OG block despite the toggle).
1045 if (!empty($social_data['og_enabled'])) {
1046 $this->output_social_og_tags($social_data['og_tags']);
1047 }
1048 return;
1049 }
1050
1051 // Priority 2: Fallback only when the Social Meta Manager is unavailable.
1052 $this->output_basic_og_tags();
1053 }
1054
1055 /**
1056 * Output basic Open Graph tags (fallback implementation)
1057 *
1058 * @return void
1059 */
1060 private function output_basic_og_tags(): void {
1061 // Check for per-post OG overrides first
1062 $og_title_override = '';
1063 $og_description_override = '';
1064 $og_image_override = '';
1065 if (is_singular() && $this->current_post_id) {
1066 // Social fields may hold variable tags entered in the metabox.
1067 $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value(
1068 (string) get_post_meta($this->current_post_id, '_thinkrank_og_title', true),
1069 $this->current_post_id
1070 );
1071 $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value(
1072 (string) get_post_meta($this->current_post_id, '_thinkrank_og_description', true),
1073 $this->current_post_id
1074 );
1075 $og_image_override = get_post_meta($this->current_post_id, '_thinkrank_og_image', true);
1076 }
1077
1078 // Get title using priority system: OG override > post-specific > Global SEO > Site Identity > default
1079 $title = '';
1080 if (!empty($og_title_override)) {
1081 $title = $og_title_override;
1082 } elseif ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
1083 $title = $this->current_metadata['title'];
1084 } else {
1085 $title = $this->generate_context_title();
1086 }
1087 if (!$title) {
1088 $title = is_singular() ? get_the_title() : get_bloginfo('name');
1089 }
1090
1091 // Get description with OG override priority
1092 $description = '';
1093 if (!empty($og_description_override)) {
1094 $description = $og_description_override;
1095 } else {
1096 $description = $this->get_meta_description();
1097 }
1098 if (!$description) {
1099 $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1100 }
1101
1102 $url = is_singular() ? get_permalink() : home_url();
1103 $site_name = $this->site_identity_data && !empty($this->site_identity_data['identity']['site_name'])
1104 ? $this->site_identity_data['identity']['site_name']
1105 : get_bloginfo('name');
1106
1107 // Determine proper og:type based on context
1108 $og_type = 'website';
1109 if (is_singular('post')) {
1110 $og_type = 'article';
1111 } elseif (is_singular('page')) {
1112 $og_type = 'website';
1113 } elseif (is_home() || is_front_page()) {
1114 $og_type = 'website';
1115 }
1116
1117 /**
1118 * Filter the Open Graph og:type. Add-ons (e.g. ThinkRank Pro's
1119 * WooCommerce module) use this to set 'product' on product pages.
1120 *
1121 * @since 1.14.0
1122 *
1123 * @param string $og_type Determined og:type.
1124 */
1125 $og_type = apply_filters('thinkrank_og_type', $og_type);
1126
1127 echo "<!-- ThinkRank SEO Open Graph Meta Tags -->\n";
1128 echo "<meta property=\"og:type\" content=\"" . esc_attr($og_type) . "\" />\n";
1129 echo "<meta property=\"og:title\" content=\"" . esc_attr($title) . "\" />\n";
1130 echo "<meta property=\"og:description\" content=\"" . esc_attr($description) . "\" />\n";
1131 echo "<meta property=\"og:url\" content=\"" . esc_url($url) . "\" />\n";
1132 echo "<meta property=\"og:site_name\" content=\"" . esc_attr($site_name) . "\" />\n";
1133 /**
1134 * Filter the og:locale value.
1135 *
1136 * Defaults to get_locale(), which is only language-correct while the
1137 * active language's translation files are installed — on a multilingual
1138 * site without them WordPress keeps reporting the default locale even
1139 * on translated URLs. The multilingual integration overrides this with
1140 * the locale its provider reports for the current language.
1141 *
1142 * @since 1.23.0
1143 *
1144 * @param string $locale Locale for the current request.
1145 */
1146 $og_locale = (string) apply_filters('thinkrank_og_locale', get_locale());
1147 echo "<meta property=\"og:locale\" content=\"" . esc_attr($og_locale) . "\" />\n";
1148
1149 // Add OG image — per-post override > featured image
1150 if (is_singular() && $this->current_post_id) {
1151 if (!empty($og_image_override)) {
1152 echo "<meta property=\"og:image\" content=\"" . esc_url($og_image_override) . "\" />\n";
1153 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($og_image_override) . "\" />\n";
1154 } elseif (has_post_thumbnail($this->current_post_id)) {
1155 $image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
1156 echo "<meta property=\"og:image\" content=\"" . esc_url($image_url) . "\" />\n";
1157 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($image_url) . "\" />\n";
1158
1159 // Get image dimensions and alt text
1160 $image_id = get_post_thumbnail_id($this->current_post_id);
1161 $image_meta = wp_get_attachment_metadata($image_id);
1162 if ($image_meta) {
1163 // SVGs (and other vector uploads) report 0x0 — emitting
1164 // those as og:image dimensions is invalid, so skip them.
1165 $og_width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0;
1166 $og_height = isset($image_meta['height']) ? (int) $image_meta['height'] : 0;
1167 if ($og_width > 0 && $og_height > 0) {
1168 echo "<meta property=\"og:image:width\" content=\"" . esc_attr($og_width) . "\" />\n";
1169 echo "<meta property=\"og:image:height\" content=\"" . esc_attr($og_height) . "\" />\n";
1170 }
1171 // Derive the real mime type instead of hardcoding image/jpeg,
1172 // which mislabels PNG/WebP featured images.
1173 $image_mime = get_post_mime_type($image_id);
1174 if ($image_mime) {
1175 echo "<meta property=\"og:image:type\" content=\"" . esc_attr($image_mime) . "\" />\n";
1176 }
1177 }
1178
1179 // Add image alt text
1180 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', true);
1181 if ($image_alt) {
1182 echo "<meta property=\"og:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n";
1183 }
1184 }
1185
1186 // Add article specific tags for posts only
1187 if ($og_type === 'article') {
1188 echo '<meta property="article:published_time" content="' . esc_attr(get_the_date('c', $this->current_post_id)) . '" />' . "\n";
1189 echo '<meta property="article:modified_time" content="' . esc_attr(get_the_modified_date('c', $this->current_post_id)) . '" />' . "\n";
1190
1191 // Add author
1192 $author_id = get_post_field('post_author', $this->current_post_id);
1193 $author_name = get_the_author_meta('display_name', $author_id);
1194 echo "<meta property=\"article:author\" content=\"" . esc_attr($author_name) . "\" />\n";
1195
1196 // Add categories as article:section
1197 if (is_single()) {
1198 $categories = get_the_category($this->current_post_id);
1199 if (!empty($categories)) {
1200 echo "<meta property=\"article:section\" content=\"" . esc_attr($categories[0]->name) . "\" />\n";
1201 }
1202 }
1203 }
1204 }
1205 echo "<!-- /ThinkRank SEO Open Graph Meta Tags -->\n";
1206 }
1207
1208 /**
1209 * Output Twitter Card meta tags (HIGH PRIORITY)
1210 * Uses Social Meta Manager with fallback to Site Identity templates
1211 *
1212 * @return void
1213 */
1214 public function output_twitter_card_tags(): void {
1215 // Priority 1: Try Social Meta Manager (Social Media tab settings)
1216 if ($this->social_manager) {
1217 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1218 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
1219
1220 // Twitter title/description derive from the same content data, so
1221 // pass the effective SEO title and meta description as fallbacks to
1222 // keep a cleared override in step with the document <title>/meta
1223 // description and the metabox preview.
1224 $social_data = $this->social_manager->get_output_data(
1225 $social_context,
1226 $this->current_post_id,
1227 $this->get_effective_seo_title(),
1228 $this->get_meta_description()
1229 );
1230
1231
1232 // The Social Meta Manager ran, so it owns Twitter output. If Twitter
1233 // Cards are toggled off, emit nothing — do NOT fall through to the
1234 // basic emitter (which would re-add twitter:* tags despite the toggle).
1235 if (!empty($social_data['twitter_enabled'])) {
1236 $this->output_social_twitter_tags($social_data['twitter_tags']);
1237 }
1238 return;
1239 }
1240
1241 // Priority 2: Fallback only when the Social Meta Manager is unavailable.
1242 $this->output_basic_twitter_tags();
1243 }
1244
1245 /**
1246 * Output basic Twitter Card tags (fallback implementation)
1247 *
1248 * @return void
1249 */
1250 private function output_basic_twitter_tags(): void {
1251 // Check for per-post Twitter overrides first, then fall through to OG overrides.
1252 $twitter_title_override = '';
1253 $twitter_description_override = '';
1254 $og_title_override = '';
1255 $og_description_override = '';
1256 if (is_singular() && $this->current_post_id) {
1257 // Social fields may hold variable tags entered in the metabox.
1258 $pid = $this->current_post_id;
1259 $twitter_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_twitter_title', true), $pid);
1260 $twitter_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_twitter_description', true), $pid);
1261 $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_og_title', true), $pid);
1262 $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_og_description', true), $pid);
1263 }
1264
1265 // Title cascade: Twitter override > OG override > Global SEO > Site Identity > default
1266 $title = '';
1267 if (!empty($twitter_title_override)) {
1268 $title = $twitter_title_override;
1269 } elseif (!empty($og_title_override)) {
1270 $title = $og_title_override;
1271 } elseif ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
1272 $title = $this->current_metadata['title'];
1273 } else {
1274 $title = $this->generate_context_title();
1275 }
1276 if (!$title) {
1277 $title = is_singular() ? get_the_title() : get_bloginfo('name');
1278 }
1279
1280 // Description cascade: Twitter override > OG override > meta description > excerpt
1281 $description = '';
1282 if (!empty($twitter_description_override)) {
1283 $description = $twitter_description_override;
1284 } elseif (!empty($og_description_override)) {
1285 $description = $og_description_override;
1286 } else {
1287 $description = $this->get_meta_description();
1288 }
1289 if (!$description) {
1290 $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1291 }
1292
1293 // Determine card type based on image availability
1294 $card_type = 'summary';
1295 if (is_singular() && $this->current_post_id && has_post_thumbnail($this->current_post_id)) {
1296 $card_type = 'summary_large_image';
1297 }
1298
1299 echo "<!-- ThinkRank SEO Twitter Card Meta Tags -->\n";
1300 echo '<meta name="twitter:card" content="' . esc_attr($card_type) . '" />' . "\n";
1301 echo "<meta name=\"twitter:title\" content=\"" . esc_attr($title) . "\" />\n";
1302 echo "<meta name=\"twitter:description\" content=\"" . esc_attr($description) . "\" />\n";
1303
1304 // Add Twitter image with proper fallback priority
1305 $twitter_image_url = $this->get_twitter_image_with_fallback();
1306 if ($twitter_image_url) {
1307 echo "<meta name=\"twitter:image\" content=\"" . esc_url($twitter_image_url) . "\" />\n";
1308
1309 // Add image alt text for accessibility (if it's a featured image)
1310 if (is_singular() && $this->current_post_id && has_post_thumbnail($this->current_post_id)) {
1311 $featured_image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
1312 if ($twitter_image_url === $featured_image_url) {
1313 $image_id = get_post_thumbnail_id($this->current_post_id);
1314 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', true);
1315 if ($image_alt) {
1316 echo "<meta name=\"twitter:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n";
1317 }
1318 }
1319 }
1320 }
1321
1322 // Add site Twitter handle if configured
1323 if ($this->site_identity_data && !empty($this->site_identity_data['social']['twitter_username'])) {
1324 $twitter_handle = $this->site_identity_data['social']['twitter_username'];
1325 // Ensure handle starts with @
1326 if (strpos($twitter_handle, '@') !== 0) {
1327 $twitter_handle = '@' . $twitter_handle;
1328 }
1329 echo "<meta name=\"twitter:site\" content=\"" . esc_attr($twitter_handle) . "\" />\n";
1330 }
1331 echo "<!-- /ThinkRank SEO Twitter Card Meta Tags -->\n";
1332 }
1333
1334 /**
1335 * Output canonical URL
1336 *
1337 * @return void
1338 */
1339 public function output_canonical_url(): void {
1340 $canonical_url = '';
1341
1342 if (is_singular()) {
1343 // Check for custom canonical URL override
1344 if ($this->current_post_id) {
1345 $custom_canonical = get_post_meta($this->current_post_id, '_thinkrank_canonical_url', true);
1346 if (!empty($custom_canonical)) {
1347 $canonical_url = $custom_canonical;
1348 }
1349 }
1350
1351 if (empty($canonical_url)) {
1352 $canonical_url = $this->current_post_id ? get_permalink($this->current_post_id) : get_permalink();
1353 }
1354 } else {
1355 $canonical_url = $this->get_non_singular_canonical_url();
1356 }
1357
1358 /**
1359 * Filter the canonical URL before output.
1360 *
1361 * @since 1.16.0
1362 *
1363 * @param string $canonical_url Canonical URL ('' suppresses the tag).
1364 */
1365 $canonical_url = apply_filters('thinkrank_canonical_url', $canonical_url);
1366
1367 if (empty($canonical_url)) {
1368 return;
1369 }
1370
1371 echo "<!-- ThinkRank SEO Canonical URL -->\n";
1372 echo "<link rel=\"canonical\" href=\"" . esc_url($canonical_url) . "\" />\n";
1373 echo "<!-- /ThinkRank SEO Canonical URL -->\n";
1374 }
1375
1376 /**
1377 * Build the canonical URL for non-singular contexts.
1378 *
1379 * Covers the blog home, post type / taxonomy / author / date archives.
1380 * Search results and 404 pages get no canonical (they are noindexed).
1381 * Paginated archives canonicalize to their own page URL so page 2+ is
1382 * self-referential rather than pointing at page 1.
1383 *
1384 * @return string Canonical URL or '' when none applies
1385 */
1386 private function get_non_singular_canonical_url(): string {
1387 if (is_404() || is_search()) {
1388 return '';
1389 }
1390
1391 $canonical_url = '';
1392
1393 if (is_front_page() || is_home()) {
1394 $canonical_url = is_home() && !is_front_page()
1395 ? (string) get_permalink((int) get_option('page_for_posts'))
1396 : home_url('/');
1397 } elseif (is_post_type_archive()) {
1398 $canonical_url = (string) get_post_type_archive_link((string) get_query_var('post_type'));
1399 } elseif (is_category() || is_tag() || is_tax()) {
1400 $term_link = get_term_link(get_queried_object());
1401 $canonical_url = is_wp_error($term_link) ? '' : $term_link;
1402 } elseif (is_author()) {
1403 $canonical_url = get_author_posts_url((int) get_queried_object_id());
1404 } elseif (is_date()) {
1405 if (is_day()) {
1406 $canonical_url = get_day_link((int) get_query_var('year'), (int) get_query_var('monthnum'), (int) get_query_var('day'));
1407 } elseif (is_month()) {
1408 $canonical_url = get_month_link((int) get_query_var('year'), (int) get_query_var('monthnum'));
1409 } elseif (is_year()) {
1410 $canonical_url = get_year_link((int) get_query_var('year'));
1411 }
1412 }
1413
1414 if (empty($canonical_url)) {
1415 return '';
1416 }
1417
1418 // Point paginated archives at their own page, not page 1.
1419 $paged = (int) get_query_var('paged');
1420 if ($paged > 1) {
1421 global $wp_rewrite;
1422 $canonical_url = $wp_rewrite->using_permalinks()
1423 ? trailingslashit($canonical_url) . user_trailingslashit($wp_rewrite->pagination_base . '/' . $paged, 'paged')
1424 : add_query_arg('paged', $paged, $canonical_url);
1425 }
1426
1427 return $canonical_url;
1428 }
1429
1430
1431 /**
1432 * Check if ThinkRank has metadata for current post
1433 *
1434 * @return bool True if has ThinkRank metadata
1435 */
1436 private function has_thinkrank_metadata(): bool {
1437 if (!is_singular()) {
1438 return false;
1439 }
1440
1441 return !empty($this->current_metadata['title']) || !empty($this->current_metadata['description']);
1442 }
1443
1444 /**
1445 * Check if current page has SEO data (public method for template functions)
1446 *
1447 * @return bool True if has SEO data
1448 */
1449 public function has_seo_data(): bool {
1450 // Check if Site Identity is enabled and active
1451 if ($this->site_identity_data && $this->site_identity_data['enabled']) {
1452 return true;
1453 }
1454
1455 // Check if post has ThinkRank metadata
1456 return $this->has_thinkrank_metadata();
1457 }
1458
1459 /**
1460 * Get current breadcrumbs data (public method for template functions)
1461 *
1462 * @return array|null Breadcrumb data or null if not available
1463 */
1464 public function get_current_breadcrumbs(): ?array {
1465 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
1466 return null;
1467 }
1468
1469 $settings = $this->site_identity_manager->get_settings('site');
1470
1471 if (empty($settings['breadcrumbs_enabled'])) {
1472 return null;
1473 }
1474
1475 return $this->generate_breadcrumbs($settings);
1476 }
1477
1478 /**
1479 * Get current SEO metadata
1480 *
1481 * @return array Current metadata
1482 */
1483 public function get_current_metadata(): array {
1484 return $this->current_metadata;
1485 }
1486
1487 /**
1488 * Generate title based on current context using Site Identity templates
1489 *
1490 * @return string|null Generated title or null if no template available
1491 */
1492 private function generate_context_title(): ?string {
1493 // Priority 1: Try Global SEO settings for current post type
1494 $global_seo_title = $this->get_global_seo_title();
1495 if ($global_seo_title) {
1496 return $global_seo_title;
1497 }
1498
1499 // Priority 2: Fall back to Site Identity templates
1500 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
1501 return null;
1502 }
1503
1504 $template = $this->get_title_template_for_context();
1505 if (!$template) {
1506 return null;
1507 }
1508
1509 $placeholders = $this->get_title_placeholders();
1510 return $this->process_title_template($template, $placeholders);
1511 }
1512
1513 /**
1514 * Get title from Global SEO settings for current post type
1515 *
1516 * @return string|null Generated title or null if no Global SEO template available
1517 */
1518 private function get_global_seo_title(): ?string {
1519 // Only apply Global SEO to singular posts/pages
1520 if (!is_singular()) {
1521 return null;
1522 }
1523
1524 $post_type = get_post_type();
1525 if (!$post_type) {
1526 return null;
1527 }
1528
1529 // Get Global SEO settings for this post type
1530 $global_seo_settings = $this->get_global_seo_settings($post_type);
1531 if (empty($global_seo_settings['title'])) {
1532 return null;
1533 }
1534
1535 $template = $global_seo_settings['title'];
1536 $placeholders = $this->get_global_seo_placeholders();
1537
1538 return $this->process_global_seo_template($template, $placeholders);
1539 }
1540
1541 /**
1542 * Get Global SEO settings for a post type
1543 *
1544 * @param string $post_type Post type slug
1545 * @return array Global SEO settings or empty array
1546 */
1547 private function get_global_seo_settings(string $post_type): array {
1548 $all_settings = get_option('thinkrank_global_seo_settings', []);
1549 return $all_settings[$post_type] ?? [];
1550 }
1551
1552 /**
1553 * Get placeholders for Global SEO template processing
1554 *
1555 * @return array Placeholder values
1556 */
1557 private function get_global_seo_placeholders(): array {
1558 $placeholders = [
1559 '%title%' => '',
1560 '%sitename%' => get_bloginfo('name'),
1561 '%sep%' => $this->get_global_seo_separator(),
1562 '%excerpt%' => '',
1563 '%date%' => get_the_date(),
1564 '%modified%' => get_the_modified_date(),
1565 '%author%' => '',
1566 '%category%' => '',
1567 ];
1568
1569 // Get current post data if available
1570 if ($this->current_post_id) {
1571 $placeholders['%title%'] = get_the_title($this->current_post_id);
1572
1573 // Get excerpt
1574 $post = get_post($this->current_post_id);
1575 if ($post) {
1576 $excerpt = !empty($post->post_excerpt)
1577 ? $post->post_excerpt
1578 : wp_trim_words(wp_strip_all_tags($post->post_content), 25, '...');
1579 $placeholders['%excerpt%'] = $excerpt;
1580 }
1581
1582 // Get author
1583 $author_id = get_post_field('post_author', $this->current_post_id);
1584 $placeholders['%author%'] = get_the_author_meta('display_name', $author_id);
1585
1586 // Get category (for posts)
1587 if (get_post_type($this->current_post_id) === 'post') {
1588 $categories = get_the_category($this->current_post_id);
1589 $placeholders['%category%'] = !empty($categories) ? $categories[0]->name : '';
1590 }
1591 }
1592
1593 return $placeholders;
1594 }
1595
1596 /**
1597 * Get separator for Global SEO title
1598 *
1599 * @return string Separator symbol
1600 */
1601 public function get_global_seo_separator(): string {
1602 return \ThinkRank\SEO\Site_Identity_Manager::get_active_separator_symbol();
1603 }
1604
1605 /**
1606 * Process Global SEO template with placeholders
1607 *
1608 * @param string $template Template string with variables
1609 * @param array $placeholders Placeholder values
1610 * @return string Processed title
1611 */
1612 private function process_global_seo_template(string $template, array $placeholders): string {
1613 // Replace all placeholders
1614 $title = str_replace(array_keys($placeholders), array_values($placeholders), $template);
1615
1616 // Clean up multiple spaces
1617 $title = preg_replace('/\s+/', ' ', $title);
1618 $title = trim($title);
1619
1620 // Clean up multiple separators (e.g., "| |" becomes "|")
1621 $separator = $placeholders['%sep%'] ?? '|';
1622 $separator_pattern = preg_quote($separator, '/');
1623 $title = preg_replace('/\s*' . $separator_pattern . '\s*' . $separator_pattern . '\s*/', ' ' . $separator . ' ', $title);
1624
1625 // Remove leading/trailing separators
1626 $title = trim($title, " \t\n\r\0\x0B" . $separator);
1627
1628 return $title;
1629 }
1630
1631 /**
1632 * Get title template for current context
1633 *
1634 * @return string|null Template string or null if not found
1635 */
1636 private function get_title_template_for_context(): ?string {
1637 $settings = $this->site_identity_manager->get_settings('site');
1638
1639 switch ($this->current_context) {
1640 case 'homepage':
1641 return $settings['homepage_title'] ?? null;
1642 case 'post':
1643 return $settings['post_title'] ?? null;
1644 case 'page':
1645 return $settings['page_title'] ?? null;
1646 case 'category':
1647 return $settings['category_title'] ?? null;
1648 case 'tag':
1649 return $settings['tag_title'] ?? null;
1650 case 'author':
1651 return $settings['author_title'] ?? null;
1652 case 'search':
1653 return $settings['search_title'] ?? null;
1654 case 'archive':
1655 return $settings['archive_title'] ?? null;
1656 default:
1657 return null;
1658 }
1659 }
1660
1661 /**
1662 * Get title placeholders for current context
1663 *
1664 * @return array Placeholder values
1665 */
1666 private function get_title_placeholders(): array {
1667 global $post, $wp_query;
1668
1669 $settings = $this->site_identity_manager->get_settings('site');
1670 $separator = $this->get_title_separator($settings['title_separator'] ?? 'pipe');
1671
1672 $placeholders = [
1673 '%site_title%' => $settings['site_name'] ?? get_bloginfo('name'),
1674 '%site_name%' => $settings['site_name'] ?? get_bloginfo('name'),
1675 '%site_description%' => $settings['site_description'] ?? get_bloginfo('description'),
1676 '%tagline%' => $settings['tagline'] ?? get_bloginfo('description'),
1677 '%separator%' => ' ' . $separator . ' ',
1678 '%sep%' => ' ' . $separator . ' ',
1679 '%date%' => gmdate('F Y'),
1680 ];
1681
1682 // Context-specific placeholders
1683 switch ($this->current_context) {
1684 case 'post':
1685 case 'page':
1686 if ($this->current_post_id) {
1687 $placeholders['%post_title%'] = get_the_title($this->current_post_id);
1688 $placeholders['%page_title%'] = get_the_title($this->current_post_id);
1689 $post_author = get_post_field('post_author', $this->current_post_id);
1690 $placeholders['%author%'] = get_the_author_meta('display_name', $post_author);
1691 $placeholders['%author_name%'] = get_the_author_meta('display_name', $post_author);
1692
1693 // Get categories for posts
1694 $post_type = get_post_type($this->current_post_id);
1695 if ($post_type === 'post') {
1696 $categories = get_the_category($this->current_post_id);
1697 $placeholders['%category%'] = !empty($categories) ? $categories[0]->name : '';
1698 }
1699 }
1700 break;
1701
1702 case 'category':
1703 $category = get_queried_object();
1704 if ($category) {
1705 $placeholders['%category_title%'] = $category->name;
1706 $placeholders['%category%'] = $category->name;
1707 }
1708 break;
1709
1710 case 'tag':
1711 $tag = get_queried_object();
1712 if ($tag) {
1713 $placeholders['%tag_title%'] = $tag->name;
1714 $placeholders['%tag%'] = $tag->name;
1715 }
1716 break;
1717
1718 case 'author':
1719 $author = get_queried_object();
1720 if ($author) {
1721 $placeholders['%author_name%'] = $author->display_name;
1722 $placeholders['%author%'] = $author->display_name;
1723 }
1724 break;
1725
1726 case 'search':
1727 $placeholders['%search_term%'] = get_search_query();
1728 break;
1729
1730 case 'archive':
1731 $placeholders['%archive_title%'] = get_the_archive_title();
1732 break;
1733 }
1734
1735 return $placeholders;
1736 }
1737
1738 /**
1739 * Process title template with placeholders
1740 *
1741 * @param string $template Template string
1742 * @param array $placeholders Placeholder values
1743 * @return string Processed title
1744 */
1745 private function process_title_template(string $template, array $placeholders): string {
1746 $title = str_replace(array_keys($placeholders), array_values($placeholders), $template);
1747
1748 // Clean up multiple separators and extra spaces
1749 $separator = $placeholders['%separator%'] ?? ' | ';
1750
1751 // Legacy templates stored a literal pipe as separator — apply the active separator to them
1752 $title = preg_replace('/\s*\|\s*/', $separator, $title);
1753 $title = preg_replace('/\s*' . preg_quote(trim($separator), '/') . '\s*' . preg_quote(trim($separator), '/') . '\s*/', $separator, $title);
1754 $title = preg_replace('/\s+/', ' ', $title);
1755 $title = trim($title);
1756
1757 // Remove trailing separator
1758 $separator_trimmed = trim($separator);
1759 if (substr($title, -strlen($separator_trimmed)) === $separator_trimmed) {
1760 $title = trim(substr($title, 0, -strlen($separator_trimmed)));
1761 }
1762
1763 return $title;
1764 }
1765
1766 /**
1767 * Get title separator symbol
1768 *
1769 * @param string $separator_type Separator type
1770 * @return string Separator symbol
1771 */
1772 private function get_title_separator(string $separator_type): string {
1773 return \ThinkRank\SEO\Site_Identity_Manager::$title_separators[$separator_type]['symbol'] ?? \ThinkRank\SEO\Site_Identity_Manager::$title_separators['pipe']['symbol'];
1774 }
1775
1776 /**
1777 * Get meta description with fallback system
1778 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates > WordPress defaults
1779 *
1780 * @return string|null Meta description or null if none available
1781 */
1782 private function get_meta_description(): ?string {
1783 // First priority: Post-specific ThinkRank metadata
1784 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['description'])) {
1785 return $this->current_metadata['description'];
1786 }
1787
1788 // Second priority: Global SEO description template
1789 $global_seo_description = $this->get_global_seo_description();
1790 if ($global_seo_description) {
1791 return $global_seo_description;
1792 }
1793
1794 // Archive contexts: derive the description from the archive itself
1795 // (term description, post type description, author bio)
1796 $archive_description = $this->get_archive_meta_description();
1797 if ($archive_description) {
1798 return $archive_description;
1799 }
1800
1801 // Third priority: Site Identity default meta description
1802 if ($this->site_identity_data && $this->site_identity_data['enabled']) {
1803 $settings = $this->site_identity_manager->get_settings('site');
1804 $default_description = $settings['default_meta_description'] ?? '';
1805
1806 if (!empty($default_description)) {
1807 return $default_description;
1808 }
1809 }
1810
1811 // Fourth priority: Generate from content for posts/pages
1812 if (is_singular() && $this->current_post_id) {
1813 $post_content = get_post_field('post_content', $this->current_post_id);
1814 if ($post_content) {
1815 $excerpt = wp_trim_words(wp_strip_all_tags($post_content), 25, '...');
1816 if (!empty($excerpt)) {
1817 return $excerpt;
1818 }
1819 }
1820 }
1821
1822 // Fifth priority: Site description for homepage
1823 if (is_home() || is_front_page()) {
1824 $site_description = get_bloginfo('description');
1825 if (!empty($site_description)) {
1826 return $site_description;
1827 }
1828 }
1829
1830 return null;
1831 }
1832
1833 /**
1834 * Get a meta description for archive contexts.
1835 *
1836 * Post type archives use the post type's description, taxonomy archives
1837 * the term description, author archives the author bio. Returns null for
1838 * non-archive contexts so the regular fallback chain continues.
1839 *
1840 * @return string|null Archive description or null when not applicable
1841 */
1842 private function get_archive_meta_description(): ?string {
1843 $description = '';
1844
1845 // Author archives are intentionally excluded — Author_Archives_Manager
1846 // outputs its own template-based meta description on wp_head.
1847 if (is_post_type_archive()) {
1848 $post_type_object = get_queried_object();
1849 if ($post_type_object instanceof \WP_Post_Type && !empty($post_type_object->description)) {
1850 $description = $post_type_object->description;
1851 }
1852 } elseif (is_category() || is_tag() || is_tax()) {
1853 $description = term_description() ?: '';
1854 }
1855
1856 $description = trim(wp_strip_all_tags((string) $description));
1857 if ($description === '') {
1858 return null;
1859 }
1860
1861 if (strlen($description) > 160) {
1862 $description = wp_trim_words($description, 25, '...');
1863 }
1864
1865 return $description;
1866 }
1867
1868 /**
1869 * Get description from Global SEO settings for current post type
1870 *
1871 * @return string|null Generated description or null if no Global SEO template available
1872 */
1873 private function get_global_seo_description(): ?string {
1874 // Only apply Global SEO to singular posts/pages
1875 if (!is_singular()) {
1876 return null;
1877 }
1878
1879 $post_type = get_post_type();
1880 if (!$post_type) {
1881 return null;
1882 }
1883
1884 // Get Global SEO settings for this post type
1885 $global_seo_settings = $this->get_global_seo_settings($post_type);
1886 if (empty($global_seo_settings['description'])) {
1887 return null;
1888 }
1889
1890 $template = $global_seo_settings['description'];
1891 $placeholders = $this->get_global_seo_placeholders();
1892
1893 return $this->process_global_seo_description_template($template, $placeholders);
1894 }
1895
1896 /**
1897 * Process Global SEO description template with placeholders
1898 *
1899 * @param string $template Template string with variables
1900 * @param array $placeholders Placeholder values
1901 * @return string Processed description
1902 */
1903 private function process_global_seo_description_template(string $template, array $placeholders): string {
1904 // Replace all placeholders
1905 $description = str_replace(array_keys($placeholders), array_values($placeholders), $template);
1906
1907 // Clean up multiple spaces
1908 $description = preg_replace('/\s+/', ' ', $description);
1909 $description = trim($description);
1910
1911 // Ensure description doesn't exceed recommended length (160 characters)
1912 if (strlen($description) > 160) {
1913 $description = wp_trim_words($description, 25, '...');
1914 }
1915
1916 return $description;
1917 }
1918
1919 /**
1920 * Output site-wide schema markup with priority system
1921 *
1922 * Priority: Schema Manager > Site Identity (like Twitter Cards approach)
1923 *
1924 * @return void
1925 */
1926 public function output_site_schema_markup(): void {
1927 $has_schema_manager_output = false;
1928 $has_website_schema = false;
1929
1930 // PRIORITY 1: Always output site-wide schemas (Organization, Website, LocalBusiness, Person)
1931 if ($this->schema_manager) {
1932 $site_wide_schemas = $this->schema_manager->get_deployed_schemas('site', null);
1933
1934 if (!empty($site_wide_schemas)) {
1935 foreach ($site_wide_schemas as $schema_type => $schema_info) {
1936 $this->output_schema_markup($schema_info['data'], $schema_type, 'Schema Manager');
1937 }
1938 $has_schema_manager_output = true;
1939 $has_website_schema = isset($site_wide_schemas['WebSite']);
1940 }
1941 }
1942
1943 // The homepage always gets a WebSite schema (with a SearchAction) so
1944 // search engines can associate the site name and sitelinks searchbox —
1945 // unless the Schema Manager already deployed one.
1946 if ((is_front_page() || is_home()) && !$has_website_schema) {
1947 $website_schema = $this->generate_website_schema();
1948
1949 /**
1950 * Filter the default homepage WebSite schema before output.
1951 *
1952 * @since 1.16.0
1953 *
1954 * @param array $website_schema WebSite schema array ([] suppresses output).
1955 */
1956 $website_schema = apply_filters('thinkrank_website_schema', $website_schema);
1957
1958 if (!empty($website_schema)) {
1959 $this->output_schema_markup($website_schema, 'WebSite', 'Site Identity');
1960 }
1961 }
1962
1963 // PRIORITY 2: Also output page-specific schemas (Article, HowTo, FAQ, etc.) on individual posts/pages
1964 if ($this->schema_manager && (is_single() || is_page())) {
1965 $context_id = get_the_ID();
1966 $context_type = get_post_type( $context_id );
1967 $context_type = in_array( $context_type, [ 'site', 'post', 'page', 'product' ] , true) ? $context_type : 'post';
1968
1969 $page_specific_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
1970
1971 if (!empty($page_specific_schemas)) {
1972 // Apply filter for Pro to allow multiple schemas
1973 // In free version, it's limited to 1 schema if not filtered
1974 $page_specific_schemas = apply_filters(
1975 'thinkrank_page_schemas_to_render',
1976 $page_specific_schemas,
1977 $context_type,
1978 $context_id
1979 );
1980
1981 // If still multiple schemas and not Pro, limit to 1 (enforcing free limit)
1982 $is_pro = \ThinkRank\Core\Plan_Config::is_pro();
1983 if (!$is_pro && count($page_specific_schemas) > 2) {
1984 $page_specific_schemas = array_slice($page_specific_schemas, 0, 2, true);
1985 }
1986
1987 foreach ($page_specific_schemas as $schema_type => $schema_info) {
1988 $this->output_schema_markup($schema_info['data'], $schema_type, 'Schema Manager');
1989 }
1990 $has_schema_manager_output = true;
1991 }
1992 }
1993
1994 // Skip Site Identity fallback if any Schema Manager schemas were output
1995 if ($has_schema_manager_output) {
1996 return;
1997 }
1998
1999 // PRIORITY 2: Fall back to Site Identity schemas (like basic Twitter Cards)
2000 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
2001 return;
2002 }
2003
2004 $settings = $this->site_identity_manager->get_settings('site');
2005
2006 // Only output on homepage or if organization schema is enabled
2007 if (!is_home() && !is_front_page() && empty($settings['organization_schema'])) {
2008 return;
2009 }
2010
2011 $schema = $this->generate_organization_schema($settings);
2012
2013 if ($schema) {
2014 $this->output_schema_markup($schema, 'Organization', 'Site Identity');
2015 }
2016 }
2017
2018 /**
2019 * Generate the default WebSite schema for the homepage.
2020 *
2021 * Includes a SearchAction potentialAction so search engines can surface a
2022 * sitelinks searchbox, mirroring what Rank Math/Yoast output by default.
2023 *
2024 * @return array WebSite schema
2025 */
2026 private function generate_website_schema(): array {
2027 $settings = $this->site_identity_manager ? $this->site_identity_manager->get_settings('site') : [];
2028
2029 $schema = [
2030 '@context' => 'https://schema.org',
2031 '@type' => 'WebSite',
2032 '@id' => home_url('/#website'),
2033 'name' => !empty($settings['site_name']) ? $settings['site_name'] : get_bloginfo('name'),
2034 'url' => home_url('/'),
2035 ];
2036
2037 $description = !empty($settings['site_description']) ? $settings['site_description'] : get_bloginfo('description');
2038 if (!empty($description)) {
2039 $schema['description'] = $description;
2040 }
2041
2042 $schema['potentialAction'] = [
2043 '@type' => 'SearchAction',
2044 'target' => [
2045 '@type' => 'EntryPoint',
2046 'urlTemplate' => home_url('/?s={search_term_string}'),
2047 ],
2048 'query-input' => 'required name=search_term_string',
2049 ];
2050
2051 return $schema;
2052 }
2053
2054 /**
2055 * Output schema markup with consistent formatting
2056 *
2057 * @param array $schema_data Schema data
2058 * @param string $schema_type Schema type name
2059 * @param string $source Source of schema (Schema Manager, Site Identity, etc.)
2060 * @return void
2061 */
2062 private function output_schema_markup(array $schema_data, string $schema_type, string $source): void {
2063 if (empty($schema_data)) {
2064 return;
2065 }
2066
2067 echo '<!-- ThinkRank ' . esc_html($source) . ': ' . esc_html($schema_type) . ' Schema -->' . "\n";
2068 echo '<script type="application/ld+json">' . "\n";
2069 echo wp_json_encode($schema_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . "\n";
2070 echo '</script>' . "\n";
2071 echo '<!-- /ThinkRank ' . esc_html($source) . ': ' . esc_html($schema_type) . ' Schema -->' . "\n";
2072 }
2073
2074 /**
2075 * Generate organization schema markup
2076 *
2077 * Priority: Schema Manager organization settings > Site Identity settings
2078 *
2079 * @param array $settings Site identity settings (used as fallback)
2080 * @return array|null Schema data or null if insufficient data
2081 */
2082 private function generate_organization_schema(array $settings): ?array {
2083 // PRIORITY 1: Get Schema Manager organization settings
2084 $schema_settings = [];
2085 if ($this->schema_manager) {
2086 $schema_settings = $this->schema_manager->get_settings('site', null);
2087 }
2088
2089 // Determine organization values (Schema Manager > Site Identity > WordPress default).
2090 // Use first_non_empty() rather than ??: these settings keys are always present
2091 // and default to an empty string, so a ?? chain would stop dead on '' and never
2092 // reach the WordPress fallback.
2093 $org_name = $this->first_non_empty(
2094 $schema_settings['organization_name'] ?? null,
2095 $settings['site_name'] ?? null,
2096 get_bloginfo('name')
2097 );
2098
2099 $org_url = $this->first_non_empty(
2100 $schema_settings['organization_url'] ?? null,
2101 $settings['site_url'] ?? null,
2102 home_url()
2103 );
2104
2105 $org_description = $this->first_non_empty(
2106 $schema_settings['organization_description'] ?? null,
2107 $settings['site_description'] ?? null,
2108 get_bloginfo('description')
2109 );
2110
2111 if (empty($org_name)) {
2112 return null;
2113 }
2114
2115 // Determine organization type (Schema Manager setting or default)
2116 $org_type = $schema_settings['organization_type'] ?? 'Organization';
2117
2118 $schema = [
2119 '@context' => 'https://schema.org',
2120 '@type' => $org_type,
2121 '@id' => home_url() . '#organization',
2122 'name' => $org_name,
2123 'url' => $org_url,
2124 ];
2125
2126 // Add description if available
2127 if (!empty($org_description)) {
2128 $schema['description'] = $org_description;
2129 }
2130
2131 // Add logo if available with proper ImageObject structure
2132 // Priority: Schema Manager logo > Site Identity logo
2133 $logo_url = $schema_settings['organization_logo'] ?? $settings['logo_url'] ?? '';
2134
2135 if (!empty($logo_url)) {
2136 $schema['logo'] = [
2137 '@type' => 'ImageObject',
2138 '@id' => home_url() . '#logo',
2139 'url' => $logo_url,
2140 'contentUrl' => $logo_url,
2141 'caption' => $org_name . ' Logo'
2142 ];
2143
2144 // Also add as image property
2145 $schema['image'] = $schema['logo'];
2146 }
2147
2148 // Add social media accounts if available
2149 // Priority: Schema Manager social profiles > Site Identity social profiles
2150 $social_urls = [];
2151
2152 // Check Schema Manager organization social profiles first
2153 if (!empty($schema_settings['organization_social_facebook'])) {
2154 $social_urls[] = $schema_settings['organization_social_facebook'];
2155 }
2156 if (!empty($schema_settings['organization_social_twitter'])) {
2157 $twitter_url = $schema_settings['organization_social_twitter'];
2158 // Ensure it's a full URL
2159 if (strpos($twitter_url, 'http') !== 0) {
2160 $twitter_url = 'https://twitter.com/' . ltrim($twitter_url, '@');
2161 }
2162 $social_urls[] = $twitter_url;
2163 }
2164 if (!empty($schema_settings['organization_social_linkedin'])) {
2165 $social_urls[] = $schema_settings['organization_social_linkedin'];
2166 }
2167 if (!empty($schema_settings['organization_social_instagram'])) {
2168 $social_urls[] = $schema_settings['organization_social_instagram'];
2169 }
2170 if (!empty($schema_settings['organization_social_youtube'])) {
2171 $social_urls[] = $schema_settings['organization_social_youtube'];
2172 }
2173 if (!empty($schema_settings['organization_social_pinterest'])) {
2174 $social_urls[] = $schema_settings['organization_social_pinterest'];
2175 }
2176 if (!empty($schema_settings['organization_social_whatsapp'])) {
2177 $social_urls[] = $schema_settings['organization_social_whatsapp'];
2178 }
2179 if (!empty($schema_settings['organization_social_telegram'])) {
2180 $social_urls[] = $schema_settings['organization_social_telegram'];
2181 }
2182
2183 // Fallback to Site Identity social profiles if no Schema Manager profiles
2184 if (empty($social_urls) && !empty($this->site_identity_data['social'])) {
2185 $social_data = $this->site_identity_data['social'];
2186
2187 if (!empty($social_data['facebook_url'])) {
2188 $social_urls[] = $social_data['facebook_url'];
2189 }
2190 if (!empty($social_data['twitter_username'])) {
2191 $twitter_url = 'https://twitter.com/' . ltrim($social_data['twitter_username'], '@');
2192 $social_urls[] = $twitter_url;
2193 }
2194 if (!empty($social_data['linkedin_url'])) {
2195 $social_urls[] = $social_data['linkedin_url'];
2196 }
2197 if (!empty($social_data['instagram_url'])) {
2198 $social_urls[] = $social_data['instagram_url'];
2199 }
2200 if (!empty($social_data['youtube_url'])) {
2201 $social_urls[] = $social_data['youtube_url'];
2202 }
2203 }
2204
2205 if (!empty($social_urls)) {
2206 $schema['sameAs'] = $social_urls;
2207 }
2208
2209 // Add contact information if available
2210 // Priority: Schema Manager contact info > Site Identity contact info
2211 if (!empty($schema_settings['organization_contact_phone']) || !empty($schema_settings['organization_contact_email'])) {
2212 $contact_point = [
2213 '@type' => 'ContactPoint',
2214 'contactType' => $schema_settings['organization_contact_type'] ?? 'customer service'
2215 ];
2216
2217 if (!empty($schema_settings['organization_contact_phone'])) {
2218 $contact_point['telephone'] = $schema_settings['organization_contact_phone'];
2219 }
2220
2221 if (!empty($schema_settings['organization_contact_email'])) {
2222 $contact_point['email'] = $schema_settings['organization_contact_email'];
2223 }
2224
2225 if (!empty($schema_settings['organization_contact_hours'])) {
2226 $contact_point['hoursAvailable'] = $schema_settings['organization_contact_hours'];
2227 }
2228
2229 $schema['contactPoint'] = $contact_point;
2230 } elseif (!empty($settings['contact_email'])) {
2231 // Fallback to Site Identity contact email
2232 $schema['email'] = $settings['contact_email'];
2233 }
2234
2235 return $schema;
2236 }
2237
2238 /**
2239 * Return the first value that is a non-empty (after trim) string.
2240 *
2241 * Settings keys such as organization_url are always present and default to
2242 * an empty string, so the null-coalescing operator (??) cannot be used to
2243 * build a fallback chain: '' is not null and would short-circuit the chain.
2244 * This helper skips empty strings and returns the first real value, falling
2245 * back to '' when none qualify.
2246 *
2247 * @param string|null ...$values Candidate values in priority order.
2248 * @return string First non-empty value, or '' if none.
2249 */
2250 private function first_non_empty(...$values): string {
2251 foreach ($values as $value) {
2252 if (is_string($value) && trim($value) !== '') {
2253 return $value;
2254 }
2255 }
2256 return '';
2257 }
2258
2259 /**
2260 * Output breadcrumb schema markup
2261 *
2262 * @return void
2263 */
2264 public function output_breadcrumb_schema(): void {
2265 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
2266 return;
2267 }
2268
2269 $settings = $this->site_identity_manager->get_settings('site');
2270
2271 // Only output if breadcrumbs are enabled
2272 if (empty($settings['breadcrumbs_enabled'])) {
2273 return;
2274 }
2275
2276 $breadcrumbs = $this->generate_breadcrumbs($settings);
2277
2278 if (!empty($breadcrumbs['schema'])) {
2279 echo "<!-- ThinkRank SEO Breadcrumb Schema Markup -->\n";
2280 echo '<script type="application/ld+json">' . "\n";
2281 echo wp_json_encode($breadcrumbs['schema'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . "\n";
2282 echo '</script>' . "\n";
2283 echo "<!-- /ThinkRank SEO Breadcrumb Schema Markup -->\n";
2284 }
2285 }
2286
2287 /**
2288 * Output closing comment for ThinkRank SEO
2289 *
2290 * @return void
2291 */
2292 public function output_closing_comment(): void {
2293 // Only output if we've output any SEO content
2294 static $header_output = false;
2295 if ($header_output || $this->has_seo_output()) {
2296 echo "<!-- /ThinkRank SEO -->\n";
2297 }
2298 }
2299
2300 /**
2301 * Check if any SEO content has been output
2302 *
2303 * @return bool True if SEO content was output
2304 */
2305 private function has_seo_output(): bool {
2306 // Check if we have meta description or any other SEO data
2307 return !empty($this->get_meta_description()) ||
2308 $this->has_thinkrank_metadata() ||
2309 ($this->site_identity_data && $this->site_identity_data['enabled']);
2310 }
2311
2312 /**
2313 * Display breadcrumbs HTML
2314 *
2315 * @return void
2316 */
2317 public function display_breadcrumbs(): void {
2318 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
2319 return;
2320 }
2321
2322 $settings = $this->site_identity_manager->get_settings('site');
2323
2324 // Only display if breadcrumbs are enabled
2325 if (empty($settings['breadcrumbs_enabled'])) {
2326 return;
2327 }
2328
2329 $breadcrumbs = $this->generate_breadcrumbs($settings);
2330
2331 if (!empty($breadcrumbs['html'])) {
2332 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML is properly escaped in generate_breadcrumb_html method
2333 echo $breadcrumbs['html'];
2334 }
2335 }
2336
2337 /**
2338 * Render breadcrumbs for the [thinkrank_breadcrumbs] shortcode
2339 *
2340 * Respects the same site-identity / breadcrumbs_enabled gates as
2341 * display_breadcrumbs().
2342 *
2343 * @return string Breadcrumb HTML (empty string when disabled)
2344 */
2345 public function breadcrumbs_shortcode(): string {
2346 ob_start();
2347 $this->display_breadcrumbs();
2348 return (string) ob_get_clean();
2349 }
2350
2351 /**
2352 * Display the hero section for the `thinkrank_hero` action hook /
2353 * `thinkrank_hero()` template tag.
2354 *
2355 * Gated on the Site Identity master toggle. Emits nothing when no hero
2356 * content (title/subtitle/CTA) is configured, so an empty hero never
2357 * appears on the front end.
2358 *
2359 * @return void
2360 */
2361 public function display_hero(): void {
2362 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
2363 return;
2364 }
2365
2366 $settings = $this->site_identity_manager->get_settings('site');
2367 $html = $this->generate_hero_html($settings);
2368
2369 if ($html !== '') {
2370 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML is escaped field-by-field in generate_hero_html().
2371 echo $html;
2372 }
2373 }
2374
2375 /**
2376 * Render the hero section for the [thinkrank_hero] shortcode.
2377 *
2378 * Respects the same gates as display_hero().
2379 *
2380 * @return string Hero HTML (empty string when disabled or unconfigured)
2381 */
2382 public function hero_shortcode(): string {
2383 ob_start();
2384 $this->display_hero();
2385 return (string) ob_get_clean();
2386 }
2387
2388 /**
2389 * Get the current hero section data without displaying it.
2390 *
2391 * @return array|null Hero data (title, subtitle, cta_text, cta_url,
2392 * background_image, html) or null when unavailable.
2393 */
2394 public function get_current_hero(): ?array {
2395 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
2396 return null;
2397 }
2398
2399 $settings = $this->site_identity_manager->get_settings('site');
2400
2401 $hero = [
2402 'title' => (string) ($settings['hero_title'] ?? ''),
2403 'subtitle' => (string) ($settings['hero_subtitle'] ?? ''),
2404 'cta_text' => (string) ($settings['hero_cta_text'] ?? ''),
2405 'cta_url' => (string) ($settings['hero_cta_url'] ?? ''),
2406 'background_image' => (string) ($settings['hero_background_image'] ?? ''),
2407 ];
2408
2409 // generate_hero_html() is the single source of truth for the
2410 // "is anything renderable?" gate (title, subtitle, or a complete CTA),
2411 // so defer to it rather than duplicate the check — and never expose an
2412 // empty hero.
2413 $hero['html'] = $this->generate_hero_html($settings);
2414 if ($hero['html'] === '') {
2415 return null;
2416 }
2417
2418 return $hero;
2419 }
2420
2421 /**
2422 * Build the hero section HTML from Site Identity settings.
2423 *
2424 * Every dynamic value is escaped at the point of output. Returns an empty
2425 * string when there is no title, subtitle, or complete CTA (text + URL).
2426 *
2427 * @param array $settings Site Identity settings
2428 * @return string Hero HTML, or '' when there is nothing to render
2429 */
2430 private function generate_hero_html(array $settings): string {
2431 $title = trim((string) ($settings['hero_title'] ?? ''));
2432 $subtitle = trim((string) ($settings['hero_subtitle'] ?? ''));
2433 $cta_text = trim((string) ($settings['hero_cta_text'] ?? ''));
2434 $cta_url = trim((string) ($settings['hero_cta_url'] ?? ''));
2435 $bg_image = trim((string) ($settings['hero_background_image'] ?? ''));
2436
2437 // A CTA is only meaningful with both a label and a destination.
2438 $has_cta = ($cta_text !== '' && $cta_url !== '');
2439
2440 // Don't emit an empty hero when nothing renderable is configured. A
2441 // background image alone — or CTA text without a URL — is not enough.
2442 if ($title === '' && $subtitle === '' && !$has_cta) {
2443 return '';
2444 }
2445
2446 $classes = ['thinkrank-hero'];
2447 $style = '';
2448 if ($bg_image !== '') {
2449 $classes[] = 'thinkrank-hero--has-image';
2450 $style = ' style="background-image:url(' . esc_url($bg_image) . ');"';
2451 }
2452
2453 $html = '<section class="' . esc_attr(implode(' ', $classes)) . '"' . $style . '>';
2454 $html .= '<div class="thinkrank-hero__inner">';
2455
2456 if ($title !== '') {
2457 $html .= '<h2 class="thinkrank-hero__title">' . esc_html($title) . '</h2>';
2458 }
2459
2460 if ($subtitle !== '') {
2461 $html .= '<p class="thinkrank-hero__subtitle">' . esc_html($subtitle) . '</p>';
2462 }
2463
2464 if ($has_cta) {
2465 $html .= '<a class="thinkrank-hero__cta" href="' . esc_url($cta_url) . '">' . esc_html($cta_text) . '</a>';
2466 }
2467
2468 $html .= '</div></section>';
2469
2470 return $html;
2471 }
2472
2473 /**
2474 * Generate breadcrumbs data
2475 *
2476 * @param array $settings Breadcrumb settings
2477 * @return array Breadcrumb data with HTML and schema
2478 */
2479 private function generate_breadcrumbs(array $settings): array {
2480 $breadcrumbs = [
2481 'items' => [],
2482 'html' => '',
2483 'schema' => null
2484 ];
2485
2486 // Get breadcrumb items
2487 $items = $this->get_breadcrumb_items($settings);
2488
2489 if (empty($items)) {
2490 return $breadcrumbs;
2491 }
2492
2493 $breadcrumbs['items'] = $items;
2494
2495 // Generate HTML
2496 $breadcrumbs['html'] = $this->generate_breadcrumb_html($items, $settings);
2497
2498 // Generate schema
2499 $breadcrumbs['schema'] = $this->generate_breadcrumb_schema($items);
2500
2501 return $breadcrumbs;
2502 }
2503
2504 /**
2505 * Filter WordPress robots.txt output
2506 *
2507 * @param string $output The default robots.txt output
2508 * @param string $is_public Whether the site is public
2509 * @return string Modified robots.txt content
2510 */
2511 public function filter_robots_txt(string $output, string $is_public): string {
2512 // Only override if Site Identity is enabled and robots.txt management is enabled
2513 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
2514 return $output;
2515 }
2516
2517 $settings = $this->site_identity_manager->get_settings('site');
2518 if (empty($settings['robots_txt_enabled'])) {
2519 return $output;
2520 }
2521
2522 // Serve the effective content (manual textarea edit if present, else
2523 // auto-generated) so the live /robots.txt matches what the admin sees.
2524 try {
2525 $content = $this->site_identity_manager->render_robots_txt();
2526
2527 if (!empty($content)) {
2528 return $content;
2529 }
2530 } catch (\Exception $e) {
2531 // Rendering failed - fall back to default output
2532 }
2533
2534 // Fallback to default output if rendering fails
2535 return $output;
2536 }
2537
2538 /**
2539 * Re-sync the physical robots.txt when WordPress's "Discourage search
2540 * engines" setting (blog_public) changes.
2541 *
2542 * Only acts when ThinkRank robots management is enabled AND a physical
2543 * robots.txt already exists — a stale physical file is the failure being
2544 * fixed. When no file exists the virtual robots_txt filter already reflects
2545 * blog_public live (render_robots_txt() enforces the full block), so there is
2546 * nothing to re-sync and no reason to create a file the user never generated.
2547 *
2548 * @return void
2549 */
2550 public function on_blog_public_changed(): void {
2551 if (!$this->site_identity_manager) {
2552 return;
2553 }
2554
2555 // Gate on robots management (robots_txt_enabled), not the Site Identity
2556 // master toggle: the physical file's lifecycle is governed by that
2557 // setting alone (same as sync_robots_txt_file() and the save endpoint),
2558 // and a stale physical file is served by the web server regardless of the
2559 // master toggle.
2560 $settings = $this->site_identity_manager->get_settings('site');
2561 if (empty($settings['robots_txt_enabled'])) {
2562 return;
2563 }
2564
2565 if (file_exists(ABSPATH . 'robots.txt')) {
2566 $this->site_identity_manager->sync_robots_txt_file();
2567 }
2568 }
2569
2570 /**
2571 * Use the Site Identity favicon as the site icon URL
2572 *
2573 * When a favicon is uploaded in ThinkRank Site Identity it takes
2574 * precedence over the core site icon; with no core icon set this also
2575 * makes has_site_icon() truthy so wp_site_icon() prints the icon tags.
2576 * Reads settings directly (not site_identity_data) because this filter
2577 * also runs in admin, before initialize_current_context().
2578 *
2579 * @param string $url Site icon URL from core
2580 * @param int $size Requested icon size
2581 * @return string Icon URL
2582 */
2583 public function filter_site_icon_url($url, $size = 512): string {
2584 $settings = $this->site_identity_manager->get_settings('site');
2585
2586 if (empty($settings['enabled'])) {
2587 return (string) $url;
2588 }
2589
2590 // Apple touch icon has its own dedicated setting
2591 if ((int) $size === 180 && !empty($settings['apple_touch_icon_url'])) {
2592 return esc_url($settings['apple_touch_icon_url']);
2593 }
2594
2595 if (!empty($settings['favicon_url'])) {
2596 return esc_url($settings['favicon_url']);
2597 }
2598
2599 return (string) $url;
2600 }
2601
2602 /**
2603 * Get breadcrumb items for current page
2604 *
2605 * @param array $settings Breadcrumb settings
2606 * @return array Breadcrumb items
2607 */
2608 private function get_breadcrumb_items(array $settings): array {
2609 $items = [];
2610
2611 // Always start with home
2612 $home_text = $settings['breadcrumb_home_text'] ?? 'Home';
2613 $items[] = [
2614 'title' => $home_text,
2615 'url' => home_url(),
2616 'position' => 1
2617 ];
2618
2619 $position = 2;
2620
2621 if (is_single()) {
2622 $current_post_id = get_the_ID();
2623
2624 if ($current_post_id) {
2625 // Add categories for posts
2626 $post_type = get_post_type($current_post_id);
2627 if ($post_type === 'post') {
2628 $categories = get_the_category($current_post_id);
2629 if (!empty($categories)) {
2630 $category = $categories[0];
2631 $items[] = [
2632 'title' => $category->name,
2633 'url' => get_category_link($category->term_id),
2634 'position' => $position++
2635 ];
2636 }
2637 }
2638
2639 // Add current post
2640 if (empty($settings['show_current_page']) || $settings['show_current_page']) {
2641 $items[] = [
2642 'title' => get_the_title($current_post_id),
2643 'url' => get_permalink($current_post_id),
2644 'position' => $position,
2645 'current' => true
2646 ];
2647 }
2648 }
2649 } elseif (is_page()) {
2650 $current_post_id = get_the_ID();
2651
2652 if ($current_post_id) {
2653 // Add parent pages
2654 $parents = [];
2655 $parent_id = wp_get_post_parent_id($current_post_id);
2656
2657 while ($parent_id) {
2658 $parent = get_post($parent_id);
2659 if ($parent) {
2660 $parents[] = [
2661 'title' => get_the_title($parent->ID),
2662 'url' => get_permalink($parent->ID),
2663 'position' => 0 // Will be set later
2664 ];
2665 $parent_id = $parent->post_parent;
2666 } else {
2667 break;
2668 }
2669 }
2670
2671 // Reverse to get correct order
2672 $parents = array_reverse($parents);
2673
2674 // Add parents with correct positions
2675 foreach ($parents as $parent) {
2676 $parent['position'] = $position++;
2677 $items[] = $parent;
2678 }
2679
2680 // Add current page
2681 if (empty($settings['show_current_page']) || $settings['show_current_page']) {
2682 $items[] = [
2683 'title' => get_the_title($current_post_id),
2684 'url' => get_permalink($current_post_id),
2685 'position' => $position,
2686 'current' => true
2687 ];
2688 }
2689 }
2690 } elseif (is_category()) {
2691 $category = get_queried_object();
2692
2693 // Add parent categories
2694 $parents = [];
2695 $parent_id = $category->parent;
2696
2697 while ($parent_id) {
2698 $parent = get_category($parent_id);
2699 if ($parent && !is_wp_error($parent)) {
2700 $parents[] = [
2701 'title' => $parent->name,
2702 'url' => get_category_link($parent->term_id),
2703 'position' => 0 // Will be set later
2704 ];
2705 $parent_id = $parent->parent;
2706 } else {
2707 break;
2708 }
2709 }
2710
2711 // Reverse to get correct order
2712 $parents = array_reverse($parents);
2713
2714 // Add parents with correct positions
2715 foreach ($parents as $parent) {
2716 $parent['position'] = $position++;
2717 $items[] = $parent;
2718 }
2719
2720 // Add current category
2721 if (empty($settings['show_current_page']) || $settings['show_current_page']) {
2722 $items[] = [
2723 'title' => $category->name,
2724 'url' => get_category_link($category->term_id),
2725 'position' => $position,
2726 'current' => true
2727 ];
2728 }
2729 }
2730
2731 return $items;
2732 }
2733
2734 /**
2735 * Generate breadcrumb HTML
2736 *
2737 * @param array $items Breadcrumb items
2738 * @param array $settings Breadcrumb settings
2739 * @return string HTML output
2740 */
2741 private function generate_breadcrumb_html(array $items, array $settings): string {
2742 if (empty($items)) {
2743 return '';
2744 }
2745
2746 $separator = $settings['breadcrumb_separator'] ?? '>';
2747 $prefix = $settings['breadcrumb_prefix'] ?? '';
2748
2749 $html = '<nav class="thinkrank-breadcrumbs" aria-label="Breadcrumb">';
2750
2751 if (!empty($prefix)) {
2752 $html .= '<span class="breadcrumb-prefix">' . esc_html($prefix) . '</span> ';
2753 }
2754
2755 $html .= '<ol class="breadcrumb-list">';
2756
2757 $total_items = count($items);
2758
2759 foreach ($items as $index => $item) {
2760 $is_last = ($index === $total_items - 1);
2761 $is_current = !empty($item['current']);
2762
2763 $html .= '<li class="breadcrumb-item' . ($is_current ? ' current' : '') . '">';
2764
2765 if (!$is_current && !empty($item['url'])) {
2766 $html .= '<a href="' . esc_url($item['url']) . '">' . esc_html($item['title']) . '</a>';
2767 } else {
2768 $html .= '<span>' . esc_html($item['title']) . '</span>';
2769 }
2770
2771 if (!$is_last) {
2772 $html .= ' <span class="breadcrumb-separator">' . esc_html($separator) . '</span> ';
2773 }
2774
2775 $html .= '</li>';
2776 }
2777
2778 $html .= '</ol>';
2779 $html .= '</nav>';
2780
2781 return $html;
2782 }
2783
2784 /**
2785 * Generate breadcrumb schema markup
2786 *
2787 * @param array $items Breadcrumb items
2788 * @return array Schema data
2789 */
2790 private function generate_breadcrumb_schema(array $items): array {
2791 if (empty($items)) {
2792 return [];
2793 }
2794
2795 $schema_items = [];
2796
2797 foreach ($items as $item) {
2798 $schema_items[] = [
2799 '@type' => 'ListItem',
2800 'position' => $item['position'],
2801 'name' => $item['title'],
2802 'item' => $item['url']
2803 ];
2804 }
2805
2806 return [
2807 '@context' => 'https://schema.org',
2808 '@type' => 'BreadcrumbList',
2809 'itemListElement' => $schema_items
2810 ];
2811 }
2812
2813 /**
2814 * Get Twitter image with proper fallback priority
2815 *
2816 * @since 1.0.0
2817 *
2818 * @return string|null Twitter image URL or null if none available
2819 */
2820 private function get_twitter_image_with_fallback(): ?string {
2821 // Cascade: post-specific Twitter image > post-specific OG image > featured image.
2822 if (is_singular() && $this->current_post_id) {
2823 $post_twitter_image = get_post_meta($this->current_post_id, '_thinkrank_twitter_image', true);
2824 if (!empty($post_twitter_image)) {
2825 return $post_twitter_image;
2826 }
2827
2828 $post_og_image = get_post_meta($this->current_post_id, '_thinkrank_og_image', true);
2829 if (!empty($post_og_image)) {
2830 return $post_og_image;
2831 }
2832
2833 // Check featured image as fallback for posts
2834 if (has_post_thumbnail($this->current_post_id)) {
2835 $featured_image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
2836 if ($featured_image_url) {
2837 return $featured_image_url;
2838 }
2839 }
2840 }
2841
2842 // Check Social Meta Manager settings for Twitter-specific default image
2843 if ($this->social_manager) {
2844 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
2845 $social_settings = $this->social_manager->get_settings($social_context, $this->current_post_id);
2846
2847 // Prioritize Twitter-specific default image
2848 if (!empty($social_settings['default_twitter_image'])) {
2849 return $social_settings['default_twitter_image'];
2850 }
2851
2852 // Fallback to Open Graph default image
2853 if (!empty($social_settings['default_og_image'])) {
2854 return $social_settings['default_og_image'];
2855 }
2856
2857 // Final fallback to generic default image
2858 if (!empty($social_settings['default_image'])) {
2859 return $social_settings['default_image'];
2860 }
2861 }
2862
2863 // Check Site Identity data for social images
2864 if ($this->site_identity_data && !empty($this->site_identity_data['social'])) {
2865 $social_data = $this->site_identity_data['social'];
2866
2867 // Check for any configured social image
2868 if (!empty($social_data['default_image'])) {
2869 return $social_data['default_image'];
2870 }
2871 }
2872
2873 return null;
2874 }
2875 }
2876