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

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