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

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