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

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