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

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