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

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