PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.3.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.3.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / frontend / class-seo-manager.php

class-seo-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.3.0, at includes/frontend/class-seo-manager.php

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