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