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