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

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