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

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