PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.8.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.8.0
2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 All 49 releases
← All changes | includes/frontend/class-seo-manager.php +1567 -149 1.28.02.8.0 View file →
@@ -41,8 +41,16 @@
41 41 */
42 42 private array $current_metadata = [];
43 43
44 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 + /**
45 53 * Site Identity Manager instance
46 54 *
47 55 * @var \ThinkRank\SEO\Site_Identity_Manager|null
48 56 */
@@ -48,8 +56,29 @@
48 56 */
49 57 private ?\ThinkRank\SEO\Site_Identity_Manager $site_identity_manager = null;
50 58
51 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 + /**
52 81 * Social Meta Manager instance
53 82 *
54 83 * @var \ThinkRank\SEO\Social_Meta_Manager|null
55 84 */
@@ -83,8 +112,16 @@
83 112 */
84 113 private ?\ThinkRank\SEO\Image_SEO_Manager $image_seo_manager = null;
85 114
86 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 + /**
87 124 * Current page context
88 125 *
89 126 * @var string
90 127 */
@@ -90,8 +127,39 @@
90 127 */
91 128 private string $current_context = 'site';
92 129
93 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 + /**
94 162 * Initialize SEO manager
95 163 *
96 164 * @return void
97 165 */
@@ -107,17 +175,22 @@
107 175
108 176 // Initialize Global SEO Schema Output
109 177 $this->initialize_global_seo_schema();
110 178
111 - // Initialize Google Analytics Tracking Manager
112 - $this->initialize_google_analytics_tracking();
113 -
114 179 // Initialize Image SEO Manager
115 180 $this->initialize_image_seo_manager();
116 181
182 + // Initialize External Links Manager (rel=nofollow / target=_blank)
183 + $this->initialize_external_links_manager();
184 +
117 185 // Initialize current post and context data first
118 186 add_action('wp', [$this, 'initialize_current_context']);
119 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 +
120 193 // Use HIGH PRIORITY hooks to override other SEO plugins
121 194 // Priority 1-5 ensures ThinkRank runs before other SEO plugins
122 195
123 196 // Override WordPress title with HIGH priority
@@ -148,11 +221,27 @@
148 221 // the page would emit two <link rel="canonical"> tags on singular views.
149 222 remove_action('wp_head', 'rel_canonical');
150 223 add_action('wp_head', [$this, 'output_canonical_url'], 6);
151 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 +
152 235 // Add Site Identity specific outputs
153 236 add_action('wp_head', [$this, 'output_site_schema_markup'], 7);
154 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();
155 244
156 245 // Add closing comment (runs last)
157 246 add_action('wp_head', [$this, 'output_closing_comment'], 99);
158 247
@@ -169,8 +258,38 @@
169 258
170 259 // Add robots.txt filter hook
171 260 add_filter('robots_txt', [$this, 'filter_robots_txt'], 10, 2);
172 261
262 + // Serve /llms.txt from PHP when the request reaches WordPress. A
263 + // published llms.txt is a physical file, so the web server normally
264 + // answers it — with `text/plain` and no charset, which renders UTF-8
265 + // content as mojibake. This route (plus the .htaccess block written by
266 + // LLMs_Txt_Manager for the static file) guarantees an explicit UTF-8
267 + // charset. Priority 8 keeps it ahead of redirect_canonical().
268 + add_action('template_redirect', [$this, 'maybe_serve_llms_txt'], 8);
269 +
270 + // Take WordPress core's own sitemap offline while ThinkRank's is active.
271 + // Two sitemap indexes on one site is a crawl conflict: core keeps
272 + // /wp-sitemap.xml served and injects its own "Sitemap:" line into
273 + // robots.txt (WP_Sitemaps::add_robots, priority 0). Until now that line
274 + // only disappeared as a side effect of filter_robots_txt() replacing the
275 + // whole filter output, which does not happen when robots.txt management
276 + // is off, when Site Identity is disabled, or when another SEO plugin
277 + // claims the filter first — and it never took /wp-sitemap.xml itself
278 + // offline, so crawlers could still find and follow the duplicate index.
279 + add_filter('wp_sitemaps_enabled', [$this, 'filter_wp_sitemaps_enabled']);
280 +
281 + // …and point the URLs core owned at our sitemap, rather than letting
282 + // them dead-end. Disabling core's sitemap does not unhook the two core
283 + // paths that route /sitemap.xml: WP_Rewrite::rewrite_rules() adds the
284 + // `sitemap\.xml` rule unconditionally, and redirect_canonical() 301s any
285 + // request carrying the `sitemap` query var to /wp-sitemap.xml without
286 + // consulting wp_sitemaps_enabled — which then 404s. Runs before both
287 + // redirect_canonical() and WP_Sitemaps::render_sitemaps() (priority 10),
288 + // and after a Pro redirect rule (priority 1) so a user-defined redirect
289 + // for these URLs still wins.
290 + add_action('template_redirect', [$this, 'redirect_core_sitemap_requests'], 9);
291 +
173 292 // Keep an existing physical robots.txt in step with WordPress's
174 293 // "Discourage search engines" toggle (blog_public). A physical file
175 294 // bypasses core's robots_txt filter, so flipping blog_public after the
176 295 // file was written would otherwise leave the previous crawl policy served
@@ -180,8 +299,17 @@
180 299 // Serve the Site Identity favicon through core's site-icon pipeline so
181 300 // wp_site_icon() outputs it on the front-end (and previews pick it up)
182 301 add_filter('get_site_icon_url', [$this, 'filter_site_icon_url'], 10, 2);
183 302
303 + // Rewrite outbound anchors (rel=nofollow / target=_blank). Runs at
304 + // the very end of the_content, after core's formatting AND after the
305 + // image filter above, so it sees the markup the visitor will get. The
306 + // stored post_content is never touched — turning the settings off
307 + // restores the author's markup exactly.
308 + add_filter('the_content', [$this, 'filter_external_links'], 100000);
309 + add_filter('the_excerpt', [$this, 'filter_external_links'], 100000);
310 + add_filter('widget_text_content', [$this, 'filter_external_links'], 100000);
311 +
184 312 // Process image SEO in content
185 313 add_filter('the_content', [$this, 'filter_content_images'], 99999);
186 314 add_filter('post_thumbnail_html', [$this, 'filter_content_images'], 11, 2);
187 315 add_filter('woocommerce_single_product_image_thumbnail_html', [$this, 'filter_content_images'], 11);
@@ -245,35 +373,61 @@
245 373 $this->global_seo_schema->init();
246 374 }
247 375
248 376 /**
249 - * Initialize Google Analytics Tracking Manager
377 + * Initialize Image SEO Manager
250 378 *
251 379 * @return void
252 380 */
253 - private function initialize_google_analytics_tracking(): void {
254 - if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
255 - require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-google-analytics-tracking-manager.php';
381 + private function initialize_image_seo_manager(): void {
382 + if (!class_exists('ThinkRank\\SEO\\Image_SEO_Manager')) {
383 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-image-seo-manager.php';
256 384 }
257 385
258 - // Initialize Google Analytics Tracking Manager
259 - new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
386 + $this->image_seo_manager = new \ThinkRank\SEO\Image_SEO_Manager();
260 387 }
261 388
262 389 /**
263 - * Initialize Image SEO Manager
390 + * Initialize External Links Manager
264 391 *
392 + * @since 2.5.0
265 393 * @return void
266 394 */
267 - private function initialize_image_seo_manager(): void {
268 - if (!class_exists('ThinkRank\\SEO\\Image_SEO_Manager')) {
269 - require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-image-seo-manager.php';
395 + private function initialize_external_links_manager(): void {
396 + if (!class_exists('ThinkRank\\SEO\\External_Links_Manager')) {
397 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-external-links-manager.php';
270 398 }
271 399
272 - $this->image_seo_manager = new \ThinkRank\SEO\Image_SEO_Manager();
400 + $this->external_links_manager = new \ThinkRank\SEO\External_Links_Manager();
273 401 }
274 402
275 403 /**
404 + * Filter rendered content to annotate external links
405 + *
406 + * @since 2.5.0
407 + * @param mixed $content Content to filter; passed through untouched when
408 + * it is not a string.
409 + * @return mixed Filtered content.
410 + */
411 + public function filter_external_links($content) {
412 + // No return type: a filter value another plugin hands through as null
413 + // or an object belongs to whoever set it, and coercing it to '' would
414 + // silently drop their content on the floor.
415 + if (!is_string($content) || $content === '' || !$this->external_links_manager) {
416 + return $content;
417 + }
418 +
419 + // Feeds carry the same markup to a reader we do not control; leave
420 + // them as authored rather than annotating for a context that has no
421 + // browser tab to open.
422 + if (is_feed()) {
423 + return $content;
424 + }
425 +
426 + return $this->external_links_manager->process_content($content);
427 + }
428 +
429 + /**
276 430 * Filter content to inject image SEO attributes
277 431 *
278 432 * @since 1.0.0
279 433 * @param string $content Content to filter
@@ -325,18 +479,74 @@
325 479 $this->current_metadata = $this->get_post_seo_metadata($post_id);
326 480 }
327 481 }
328 482
483 + // Term archives. Category, tag and custom-taxonomy pages store their SEO
484 + // title and description as term meta — written by the term UI, by the
485 + // abilities API and by the Yoast/RankMath/AIOSEO/SEOPress importer — but
486 + // nothing here ever read them, so the whole title/description cascade
487 + // fell through to the theme default and no description tag was printed
488 + // at all. Term robots was fixed for the same reason in 1.31.0 (#290);
489 + // this is the title and description half (#386).
490 + if (is_category() || is_tag() || is_tax()) {
491 + $queried = get_queried_object();
492 + if ($queried instanceof \WP_Term) {
493 + $this->current_term_id = $queried->term_id;
494 + $this->current_metadata = $this->get_term_seo_metadata($queried->term_id);
495 + }
496 + }
497 +
329 498 // Load site identity data
330 499 $this->load_site_identity_data();
331 500 }
332 501
333 502 /**
503 + * Drop the request's post identity once it has become a 404.
504 + *
505 + * `initialize_current_context()` runs on `wp`, but a request can be turned
506 + * into a 404 after that: `set_404()` on `template_redirect` is the ordinary
507 + * way to refuse a URL that did resolve to a real post, and both core and
508 + * plugins do it — ThinkRank Pro's Markdown for AI refuses an ineligible
509 + * `.md` URL that way. The snapshot still said `post`/`page` and still held
510 + * the post id and its metadata, so the error page shipped that post's meta
511 + * description, focus keywords and — where the social emitters got that far
512 + * — its og:description and twitter:description, all of which a request that
513 + * was a 404 from the start never prints (#655).
514 + *
515 + * Clearing the snapshot rather than special-casing each emitter is what
516 + * makes every consumer agree, including the ones that read
517 + * `$current_metadata` without ever asking what the context is.
518 + *
519 + * @since 2.3.1
520 + *
521 + * @return void
522 + */
523 + public function recheck_404_context(): void {
524 + if (!is_404() || '404' === $this->current_context) {
525 + return;
526 + }
527 +
528 + $this->current_context = '404';
529 + $this->current_post_id = null;
530 + $this->current_term_id = null;
531 + $this->current_metadata = [];
532 + }
533 +
534 + /**
334 535 * Detect current page context
335 536 *
336 537 * @return string Current context type
337 538 */
338 539 private function detect_current_context(): string {
540 + // 404 first: a not-found request matches none of the branches below and
541 + // used to fall through to 'site', which handed crawlers the homepage's
542 + // social identity for an error page. It gets its own context so the
543 + // social layer can skip it, matching get_non_singular_canonical_url(),
544 + // which already suppresses the canonical for 404 and search.
545 + if (is_404()) {
546 + return '404';
547 + }
548 +
339 549 if (is_home() || is_front_page()) {
340 550 return 'homepage';
341 551 } elseif (is_single()) {
342 552 return 'post';
@@ -392,8 +602,37 @@
392 602 ];
393 603 }
394 604
395 605 /**
606 + * Get SEO metadata for a term.
607 + *
608 + * Mirrors get_post_seo_metadata(): the stored values may carry variable
609 + * tags, so they are resolved against the term's own values. Focus keyword
610 + * and score have no term equivalent on the frontend and stay empty.
611 + *
612 + * @since 2.0.1
613 + *
614 + * @param int $term_id Term ID.
615 + * @return array SEO metadata.
616 + */
617 + private function get_term_seo_metadata(int $term_id): array {
618 + $title = get_term_meta($term_id, '_thinkrank_seo_title', true);
619 + $description = get_term_meta($term_id, '_thinkrank_meta_description', true);
620 +
621 + return [
622 + 'title' => $title
623 + ? \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) $title, $term_id)
624 + : '',
625 + 'description' => $description
626 + ? \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) $description, $term_id)
627 + : '',
628 + 'focus_keyword' => '',
629 + 'focus_keywords' => [],
630 + 'seo_score' => '',
631 + ];
632 + }
633 +
634 + /**
396 635 * Resolve the effective SEO title for the current request.
397 636 *
398 637 * Same priority chain as override_document_title() — post-specific
399 638 * ThinkRank metadata (resolved _thinkrank_seo_title) > Global SEO
@@ -418,17 +657,23 @@
418 657 * @param string $title Original title
419 658 * @return string Modified title
420 659 */
421 660 public function override_document_title($title): string {
661 + // A content type with metas switched off keeps whatever title the theme
662 + // and WordPress produce (#660).
663 + if (!$this->metas_enabled()) {
664 + return $title;
665 + }
666 +
422 667 // First priority: Post-specific ThinkRank metadata
423 668 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
424 - return $this->current_metadata['title'];
669 + return self::with_page_suffix($this->current_metadata['title']);
425 670 }
426 671
427 672 // Second priority: Global SEO templates, Third priority: Site Identity templates
428 673 $generated_title = $this->generate_context_title();
429 674 if ($generated_title) {
430 - return $generated_title;
675 + return self::with_page_suffix($generated_title);
431 676 }
432 677
433 678 return $title;
434 679 }
@@ -433,8 +678,117 @@
433 678 return $title;
434 679 }
435 680
436 681 /**
682 + * Append a page indicator to a title on page 2 and beyond.
683 + *
684 + * This filter short-circuits pre_get_document_title at priority 1, which
685 + * drops the " – Page 2" core would otherwise add — so every page of an
686 + * archive, and every part of a multi-page post, shared one <title> (#397).
687 + * The templates have no %page% token, so the suffix is added here rather
688 + * than asking every site to edit its title format.
689 + *
690 + * @since 2.0.1
691 + *
692 + * @param string $title Resolved title.
693 + * @return string Title with the page indicator, when there is one.
694 + */
695 + /**
696 + * The archive's subject, without the label WordPress prefixes it with.
697 + *
698 + * `get_the_archive_title()` returns "Month: September 2026", "Archives:
699 + * Recipes", "Category: Uncategorized" — the label is core's, aimed at an
700 + * archive heading on the page, and it reads badly in a browser tab, an
701 + * og:title or a search result. Category, tag and author contexts already
702 + * avoid it by using the raw name; the generic archive context did not, so
703 + * date, custom-post-type and custom-taxonomy archives carried it (#640).
704 + *
705 + * Removed through core's own `get_the_archive_title_prefix` filter rather
706 + * than by matching the prefix text, because that text is translated and
707 + * differs per archive type — a string comparison would work in English and
708 + * silently stop working everywhere else.
709 + *
710 + * A site that wants a prefix can put one in its title template, where it is
711 + * visible and editable, instead of inheriting one it cannot see.
712 + *
713 + * @since 2.7.0
714 + *
715 + * @return string Archive subject, with markup and the core prefix removed.
716 + */
717 + private static function archive_subject(): string {
718 + $drop_prefix = static function (): string {
719 + return '';
720 + };
721 +
722 + add_filter('get_the_archive_title_prefix', $drop_prefix, 99);
723 +
724 + $title = (string) get_the_archive_title();
725 +
726 + remove_filter('get_the_archive_title_prefix', $drop_prefix, 99);
727 +
728 + // The <span> core wraps the subject in survives the prefix filter.
729 + return trim(wp_strip_all_tags($title));
730 + }
731 +
732 + /**
733 + * Remove HTML from a title that is about to be emitted.
734 + *
735 + * A title carrying markup is broken twice over, in two different ways, and
736 + * both were reaching real pages: inside `<title>` the tags render literally,
737 + * because that element is RCDATA and never parses them; inside `og:title`
738 + * and `twitter:title` they are attribute-escaped, so the reader sees
739 + * `&lt;em&gt;` as visible text (#640).
740 + *
741 + * Applied at the point of emission rather than at each source, so it covers
742 + * every branch that can produce a title — post meta, Global SEO templates,
743 + * Site Identity templates — without each having to remember.
744 + *
745 + * Unconditional rather than a setting: there is no title for which markup is
746 + * the correct output. The filter is the escape hatch for anyone who
747 + * disagrees, and lets a site keep entities it deliberately encoded.
748 + *
749 + * @since 2.7.0
750 + *
751 + * @param string $title Title about to be emitted.
752 + * @return string Title with any markup removed.
753 + */
754 + public static function strip_title_tags(string $title): string {
755 + /**
756 + * Filter whether HTML is stripped from generated titles.
757 + *
758 + * @since 2.7.0
759 + *
760 + * @param bool $strip Whether to strip. Default true.
761 + * @param string $title The title being emitted.
762 + */
763 + if (!apply_filters('thinkrank_strip_title_tags', true, $title)) {
764 + return $title;
765 + }
766 +
767 + return trim(wp_strip_all_tags($title));
768 + }
769 +
770 + public static function with_page_suffix(string $title): string {
771 + $title = self::strip_title_tags($title);
772 +
773 + $page = self::current_page_number();
774 +
775 + if ($page <= 1 || '' === $title) {
776 + return $title;
777 + }
778 +
779 + $separator = class_exists('\ThinkRank\SEO\Site_Identity_Manager')
780 + ? \ThinkRank\SEO\Site_Identity_Manager::get_active_separator_symbol()
781 + : '|';
782 +
783 + return $title . ' ' . $separator . ' ' . sprintf(
784 + /* translators: %d: page number. */
785 + __('Page %d', 'thinkrank'),
786 + $page
787 + );
788 + }
789 +
790 + /**
437 791 * Override WordPress wp_title (HIGH PRIORITY)
438 792 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates
439 793 *
440 794 * @param string $title Original title
@@ -441,18 +795,24 @@
441 795 * @param string $sep Title separator
442 796 * @return string Modified title
443 797 */
444 798 public function override_wp_title(string $title, string $sep = ''): string {
799 + if (!$this->metas_enabled()) {
800 + return $title;
801 + }
802 +
445 803 // First priority: Post-specific ThinkRank metadata
446 804 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
447 805 $site_name = get_bloginfo('name');
448 - return $this->current_metadata['title'] . ($sep ? " $sep " : ' | ') . $site_name;
806 + return self::with_page_suffix(
807 + $this->current_metadata['title'] . ($sep ? " $sep " : ' | ') . $site_name
808 + );
449 809 }
450 810
451 811 // Second priority: Global SEO templates, Third priority: Site Identity templates
452 812 $generated_title = $this->generate_context_title();
453 813 if ($generated_title) {
454 - return $generated_title;
814 + return self::with_page_suffix($generated_title);
455 815 }
456 816
457 817 return $title;
458 818 }
@@ -457,28 +817,57 @@
457 817 return $title;
458 818 }
459 819
460 820 /**
821 + * Whether ThinkRank owns the title and meta description for this request.
822 + *
823 + * Metas are on site-wide by default; the per-content-type matrix can switch
824 + * them off for one content type, in which case ThinkRank stops overriding
825 + * the document title and prints no meta description (#660).
826 + *
827 + * @since 2.5.0
828 + * @return bool
829 + */
830 + private function metas_enabled(): bool {
831 + return \ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
832 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_META,
833 + true
834 + );
835 + }
836 +
837 + /**
461 838 * Output meta description (HIGH PRIORITY)
462 839 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates > WordPress defaults
463 840 *
841 + * Author archives are skipped entirely: Author_Archives_Manager owns that
842 + * context and prints its own template-based description on wp_head at
843 + * priority 5. get_archive_meta_description() already declines to build one
844 + * there, but the fallback chain used to continue into the Site Identity
845 + * default, so the page ended up with two <meta name="description"> tags.
846 + *
464 847 * @return void
465 848 */
466 849 public function output_meta_description(): void {
850 + if (is_author()) {
851 + return;
852 + }
853 +
854 + if (!$this->metas_enabled()) {
855 + return;
856 + }
857 +
467 858 $description = $this->get_meta_description();
468 859
469 860 if ($description) {
470 861 // Output main ThinkRank SEO header comment (only once)
471 - static $header_output = false;
472 - if (!$header_output) {
473 - echo "<!-- Search Engine Optimization by ThinkRank - https://thinkrank.ai/ -->\n";
474 - $header_output = true;
475 - }
862 + self::note_opening_comment();
476 863
477 864 // Ensure description is within optimal length (150-160 characters)
478 - if (strlen($description) > 160) {
479 - $description = wp_trim_words($description, 25, '...');
480 - }
865 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
866 + // CJK description tripped this limit at a third of its length, and
867 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
868 + // English, 25 characters in Thai (#687).
869 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
481 870
482 871 echo "<!-- ThinkRank SEO Meta Description -->\n";
483 872 echo '<meta name="description" content="' . esc_attr($description) . '" />' . "\n";
484 873 echo "<!-- /ThinkRank SEO Meta Description -->\n";
@@ -519,13 +908,41 @@
519 908
520 909 // Output generator meta tag
521 910 echo '<meta name="generator" content="ThinkRank ' . esc_attr(THINKRANK_VERSION) . '" />' . "\n";
522 911
523 - // Output viewport meta tag if not already present
524 - if (!has_action('wp_head', 'wp_site_icon') || !wp_is_mobile()) {
525 - echo '<meta name="viewport" content="width=device-width, initial-scale=1.0" />' . "\n";
912 + // No viewport tag here. The viewport is the theme's responsibility and
913 + // every modern theme ships one, so emitting our own only ever produced a
914 + // second <meta name="viewport"> in the document. The old guard could not
915 + // prevent that either: has_action() returns the registered priority
916 + // (truthy), so its first operand was always false, and !wp_is_mobile() is
917 + // true for every desktop request.
918 + echo "<!-- /ThinkRank SEO Meta Tags -->\n";
919 + }
920 +
921 + /**
922 + * Build the basic robots directive list from a set of robots flags.
923 + *
924 + * Shared by the search/404 branch of get_robots_meta_content() so those
925 + * pages resolve their directives through the same rules as everything else
926 + * rather than a hardcoded literal.
927 + *
928 + * @since 2.5.0
929 + * @param array $settings Robots flags (index/noindex/nofollow/...).
930 + * @return string[] Directives.
931 + */
932 + private static function build_robots_directives(array $settings): array {
933 + $robots = [];
934 +
935 + $robots[] = !empty($settings['noindex']) ? 'noindex' : 'index';
936 + $robots[] = !empty($settings['nofollow']) ? 'nofollow' : 'follow';
937 +
938 + foreach (['noarchive', 'noimageindex', 'nosnippet'] as $directive) {
939 + if (!empty($settings[$directive])) {
940 + $robots[] = $directive;
941 + }
526 942 }
527 - echo "<!-- /ThinkRank SEO Meta Tags -->\n";
943 +
944 + return $robots;
528 945 }
529 946
530 947 /**
531 948 * Get robots meta content based on context and settings
@@ -534,13 +951,22 @@
534 951 */
535 952 private function get_robots_meta_content(): string {
536 953 $robots = [];
537 954
538 - // 404 and search results must never be indexed, regardless of the
539 - // configured global/post-type directives. Links are still followed so
540 - // crawlers can discover the rest of the site.
955 + // 404 and search results are noindex/follow by default — the behaviour
956 + // that used to be hardcoded here. It is now settings-driven (#660): the
957 + // Content Type Matrix can give either its own robots directives, and an
958 + // install that never touched them resolves to exactly the old pair.
541 959 if (is_404() || is_search()) {
542 - $robots = apply_filters('thinkrank_robots_meta', ['noindex', 'follow']);
960 + $entity = is_404()
961 + ? \ThinkRank\SEO\Content_Type_Settings::ENTITY_404
962 + : \ThinkRank\SEO\Content_Type_Settings::ENTITY_SEARCH;
963 +
964 + $robots = self::build_robots_directives(
965 + \ThinkRank\SEO\Content_Type_Settings::resolve_robots_meta($entity)
966 + );
967 +
968 + $robots = apply_filters('thinkrank_robots_meta', $robots);
543 969 return implode(', ', array_unique($robots));
544 970 }
545 971
546 972 // 1. Get global robot meta settings (Base)
@@ -569,8 +995,24 @@
569 995 $current_settings = array_merge($current_settings, $global_seo_settings[$post_type]['robots_meta']);
570 996 }
571 997 }
572 998
999 + // 2b. Apply the per-entity directives for the non-singular content
1000 + // types the matrix covers — taxonomy archives plus author and date
1001 + // archives. Terms keep their own per-term override, applied further
1002 + // down so it still wins over the taxonomy-wide value (#660).
1003 + if (!is_singular()) {
1004 + $entity_key = \ThinkRank\SEO\Content_Type_Settings::current_entity_key();
1005 +
1006 + if ($entity_key !== null) {
1007 + $entity_settings = \ThinkRank\SEO\Content_Type_Settings::get_entity_settings($entity_key);
1008 +
1009 + if (!empty($entity_settings['robots_meta_enabled']) && is_array($entity_settings['robots_meta'] ?? null)) {
1010 + $current_settings = array_merge($current_settings, $entity_settings['robots_meta']);
1011 + }
1012 + }
1013 + }
1014 +
573 1015 // Determine Index/Noindex based on merged settings
574 1016 // Priority: if noindex is true, it overrides index
575 1017 if (!empty($current_settings['noindex'])) {
576 1018 $robots[] = 'noindex';
@@ -634,12 +1076,30 @@
634 1076 $robots = $this->apply_post_robots_override(get_the_ID(), $robots, $current_settings);
635 1077 }
636 1078
637 1079 // Check for archive pages (search is handled by the early return above)
638 - if (is_archive()) {
1080 + //
1081 + // is_home() is deliberately included: the blog listing is not an
1082 + // is_archive(), so page 2 of a term archive was noindex while page 2 of
1083 + // the blog listing was index — the same kind of page, treated two
1084 + // different ways, on the same site (#397).
1085 + if (is_archive() || is_home()) {
639 1086 // Allow indexing of category/tag archives but be more conservative
640 1087 if (is_paged()) {
641 - $robots = ['noindex', 'follow'];
1088 + /**
1089 + * Filter whether a paginated archive is set noindex.
1090 + *
1091 + * Rank Math and Yoast now index paginated archives with a
1092 + * self-referential canonical by default, so a site that wants
1093 + * that can have it without patching.
1094 + *
1095 + * @since 2.0.1
1096 + *
1097 + * @param bool $noindex Whether to noindex this paginated page.
1098 + */
1099 + if (apply_filters('thinkrank_noindex_paged_archives', true)) {
1100 + $robots = ['noindex', 'follow'];
1101 + }
642 1102 }
643 1103
644 1104 // Honor the global date-archive noindex toggle (written by the
645 1105 // Rank Math/Yoast settings importer). Author archives are handled
@@ -648,8 +1108,27 @@
648 1108 $robots = ['noindex', 'follow'];
649 1109 }
650 1110 }
651 1111
1112 + // 4. Term meta override for taxonomy archives (Overrides everything).
1113 + //
1114 + // Terms had no branch here at all — not a wrong key or a skipped
1115 + // conditional, the lookup simply did not exist — so a category, tag or
1116 + // custom-taxonomy archive saved with noindex still rendered the global
1117 + // default. The stored value read back correctly through the abilities
1118 + // API, which made the setting look applied when it never reached output.
1119 + //
1120 + // Deliberately placed *after* the archive block so it is a real
1121 + // override, matching how a per-post override is final for singular
1122 + // views. Running it earlier would let is_paged() overwrite a term's
1123 + // explicit directives on page 2 of its own archive.
1124 + if (is_category() || is_tag() || is_tax()) {
1125 + $queried = get_queried_object();
1126 + if ($queried instanceof \WP_Term) {
1127 + $robots = $this->apply_term_robots_override($queried->term_id, $robots, $current_settings);
1128 + }
1129 + }
1130 +
652 1131 // Apply filters for customization
653 1132 $robots = apply_filters('thinkrank_robots_meta', $robots);
654 1133
655 1134 // Remove duplicates and implode
@@ -668,20 +1147,69 @@
668 1147 * @param array $current_settings Effective robots flags (global + post type)
669 1148 * @return array Updated robots directive list
670 1149 */
671 1150 private function apply_post_robots_override(int $post_id, array $robots, array $current_settings): array {
672 - if (!(bool) get_post_meta($post_id, '_thinkrank_robots_meta_enabled', true)) {
1151 + return $this->apply_meta_robots_override(
1152 + (bool) get_post_meta($post_id, '_thinkrank_robots_meta_enabled', true),
1153 + (string) get_post_meta($post_id, '_thinkrank_robots_meta', true),
1154 + (string) get_post_meta($post_id, '_thinkrank_advanced_robots_meta', true),
1155 + $robots,
1156 + $current_settings
1157 + );
1158 + }
1159 +
1160 + /**
1161 + * Apply per-term robots overrides on top of the cascaded directives.
1162 + *
1163 + * The term-meta twin of apply_post_robots_override(). Terms store the same
1164 + * three keys with the same shapes — written by the update-term-seo ability
1165 + * and by the Rank Math / Yoast / AIOSEO / SEOPress importer — so the two
1166 + * paths share one engine rather than a second copy that can drift.
1167 + *
1168 + * @since 1.31.0
1169 + *
1170 + * @param int $term_id Term being rendered
1171 + * @param array $robots Directives accumulated so far
1172 + * @param array $current_settings Effective robots flags (global + post type)
1173 + * @return array Updated robots directive list
1174 + */
1175 + private function apply_term_robots_override(int $term_id, array $robots, array $current_settings): array {
1176 + return $this->apply_meta_robots_override(
1177 + (bool) get_term_meta($term_id, '_thinkrank_robots_meta_enabled', true),
1178 + (string) get_term_meta($term_id, '_thinkrank_robots_meta', true),
1179 + (string) get_term_meta($term_id, '_thinkrank_advanced_robots_meta', true),
1180 + $robots,
1181 + $current_settings
1182 + );
1183 + }
1184 +
1185 + /**
1186 + * Rebuild the robots directives from a stored override, whatever holds it.
1187 + *
1188 + * Kept free of get_post_meta()/get_term_meta() so posts and terms cannot
1189 + * diverge: term support was missing entirely because the only override
1190 + * logic lived behind a post-meta read.
1191 + *
1192 + * @since 1.31.0
1193 + *
1194 + * @param bool $enabled Whether the override is switched on
1195 + * @param string $raw_robots JSON robots flags
1196 + * @param string $raw_advanced JSON advanced directives
1197 + * @param array $robots Directives accumulated so far
1198 + * @param array $current_settings Effective robots flags (global + post type)
1199 + * @return array Updated robots directive list
1200 + */
1201 + private function apply_meta_robots_override(bool $enabled, string $raw_robots, string $raw_advanced, array $robots, array $current_settings): array {
1202 + if (!$enabled) {
673 1203 return $robots;
674 1204 }
675 1205
676 - $raw_robots = get_post_meta($post_id, '_thinkrank_robots_meta', true);
677 - $post_robots = is_string($raw_robots) && $raw_robots !== '' ? json_decode($raw_robots, true) : null;
1206 + $post_robots = $raw_robots !== '' ? json_decode($raw_robots, true) : null;
678 1207 if (!is_array($post_robots)) {
679 1208 return $robots;
680 1209 }
681 1210
682 - $raw_advanced = get_post_meta($post_id, '_thinkrank_advanced_robots_meta', true);
683 - $post_advanced = is_string($raw_advanced) && $raw_advanced !== '' ? json_decode($raw_advanced, true) : null;
1211 + $post_advanced = $raw_advanced !== '' ? json_decode($raw_advanced, true) : null;
684 1212
685 1213 $effective = array_merge($current_settings, array_intersect_key($post_robots, array_flip([
686 1214 'index', 'noindex', 'nofollow', 'noarchive', 'noimageindex', 'nosnippet',
687 1215 ])));
@@ -851,9 +1379,9 @@
851 1379 *
852 1380 * @param array $og_tags Open Graph tags array
853 1381 * @return void
854 1382 */
855 - private function output_social_og_tags(array $og_tags): void {
1383 + private function output_social_og_tags(array $og_tags, array $extra_images = []): void {
856 1384 // Honor the thinkrank_og_type filter here too — this "Enhanced" path is
857 1385 // the active OG emitter, so add-ons (e.g. Pro's WooCommerce module which
858 1386 // sets 'product' on product pages) must be applied to it, not only to
859 1387 // output_open_graph_tags().
@@ -884,8 +1412,9 @@
884 1412
885 1413 // Output tags in optimal order
886 1414 foreach ($og_order as $property) {
887 1415 if (!empty($og_tags[$property])) {
1416 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_meta_value() applies esc_url()/esc_attr(); the sniff cannot follow a method call.
888 1417 echo '<meta property="' . esc_attr($property) . '" content="' . $this->esc_meta_value($property, $og_tags[$property]) . '" />' . "\n";
889 1418 }
890 1419 }
891 1420
@@ -891,16 +1420,67 @@
891 1420
892 1421 // Output any remaining tags not in the order list
893 1422 foreach ($og_tags as $property => $content) {
894 1423 if (!empty($content) && !in_array($property, $og_order, true)) {
1424 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_meta_value() applies esc_url()/esc_attr(); the sniff cannot follow a method call.
895 1425 echo '<meta property="' . esc_attr($property) . '" content="' . $this->esc_meta_value($property, $content) . '" />' . "\n";
896 1426 }
897 1427 }
898 1428
1429 + // Alternatives, after the primary and everything belonging to it.
1430 + // Order is the whole point: a consumer reads og:image tags in document
1431 + // order and treats the first as primary, and a structured property
1432 + // attaches to the most recently declared image — so each alternative's
1433 + // companions have to follow its own URL, not be grouped at the end.
1434 + self::output_extra_og_images($extra_images);
1435 +
899 1436 echo "<!-- /ThinkRank SEO Open Graph Tags -->\n";
900 1437 }
901 1438
902 1439 /**
1440 + * Emit the secondary og:image tags a page offers.
1441 + *
1442 + * Shared by the enhanced and basic emitters so both describe an
1443 + * alternative image the same way (#636).
1444 + *
1445 + * @since 2.7.0
1446 + *
1447 + * @param array $images Each with url, and width/height/type/alt where known.
1448 + * @return void
1449 + */
1450 + private static function output_extra_og_images(array $images): void {
1451 + foreach ($images as $image) {
1452 + $url = isset($image['url']) ? (string) $image['url'] : '';
1453 +
1454 + if ('' === $url) {
1455 + continue;
1456 + }
1457 +
1458 + echo '<meta property="og:image" content="' . esc_url($url) . '" />' . "\n";
1459 +
1460 + if (strpos($url, 'https://') === 0) {
1461 + echo '<meta property="og:image:secure_url" content="' . esc_url($url) . '" />' . "\n";
1462 + }
1463 +
1464 + // Only what is actually known: a dimension guessed for a remote
1465 + // image is a number a consumer lays a card out with before it has
1466 + // fetched the file.
1467 + if (!empty($image['width']) && !empty($image['height'])) {
1468 + echo '<meta property="og:image:width" content="' . esc_attr((string) $image['width']) . '" />' . "\n";
1469 + echo '<meta property="og:image:height" content="' . esc_attr((string) $image['height']) . '" />' . "\n";
1470 + }
1471 +
1472 + if (!empty($image['type'])) {
1473 + echo '<meta property="og:image:type" content="' . esc_attr((string) $image['type']) . '" />' . "\n";
1474 + }
1475 +
1476 + if (!empty($image['alt'])) {
1477 + echo '<meta property="og:image:alt" content="' . esc_attr((string) $image['alt']) . '" />' . "\n";
1478 + }
1479 + }
1480 + }
1481 +
1482 + /**
903 1483 * Output social media Twitter Card tags from Social Meta Manager
904 1484 *
905 1485 * @param array $twitter_tags Twitter Card tags array
906 1486 * @return void
@@ -921,8 +1501,9 @@
921 1501
922 1502 // Output tags in optimal order
923 1503 foreach ($twitter_order as $name) {
924 1504 if (!empty($twitter_tags[$name])) {
1505 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_meta_value() applies esc_url()/esc_attr(); the sniff cannot follow a method call.
925 1506 echo '<meta name="' . esc_attr($name) . '" content="' . $this->esc_meta_value($name, $twitter_tags[$name]) . '" />' . "\n";
926 1507 }
927 1508 }
928 1509
@@ -928,8 +1509,9 @@
928 1509
929 1510 // Output any remaining tags not in the order list
930 1511 foreach ($twitter_tags as $name => $content) {
931 1512 if (!empty($content) && !in_array($name, $twitter_order, true)) {
1513 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_meta_value() applies esc_url()/esc_attr(); the sniff cannot follow a method call.
932 1514 echo '<meta name="' . esc_attr($name) . '" content="' . $this->esc_meta_value($name, $content) . '" />' . "\n";
933 1515 }
934 1516 }
935 1517
@@ -963,8 +1545,16 @@
963 1545 *
964 1546 * @return void
965 1547 */
966 1548 public function output_platform_meta_tags(): void {
1549 + // Same reasoning as the Open Graph and Twitter emitters: an error page
1550 + // has no shareable identity, and passing '404' through as a social
1551 + // context asks the manager for settings that describe a page which does
1552 + // not exist. Guarding all three keeps them from disagreeing.
1553 + if ($this->current_context === '404') {
1554 + return;
1555 + }
1556 +
967 1557 // Try Social Meta Manager for platform tags
968 1558 if ($this->social_manager) {
969 1559 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
970 1560 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
@@ -1016,8 +1606,23 @@
1016 1606 *
1017 1607 * @return void
1018 1608 */
1019 1609 public function output_open_graph_tags(): void {
1610 + // Per-content-type Open Graph switch. 'inherit' (the default) keeps the
1611 + // site-wide Social Media setting, which the emitters below read (#660).
1612 + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
1613 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_OPEN_GRAPH,
1614 + true
1615 + )) {
1616 + return;
1617 + }
1618 +
1619 + // An error page has no shareable identity. Emitting Open Graph here
1620 + // advertised the homepage as the og:url of a URL that does not exist.
1621 + if ($this->current_context === '404') {
1622 + return;
1623 + }
1624 +
1020 1625 // Priority 1: Try Social Meta Manager (Social Media tab settings)
1021 1626 if ($this->social_manager) {
1022 1627 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1023 1628 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
@@ -1038,9 +1643,12 @@
1038 1643 // The Social Meta Manager ran, so it owns Open Graph output. If OG is
1039 1644 // toggled off, emit nothing — do NOT fall through to the basic
1040 1645 // emitter (which would re-add a full OG block despite the toggle).
1041 1646 if (!empty($social_data['og_enabled'])) {
1042 - $this->output_social_og_tags($social_data['og_tags']);
1647 + $this->output_social_og_tags(
1648 + $social_data['og_tags'],
1649 + $social_data['og_extra_images'] ?? []
1650 + );
1043 1651 }
1044 1652 return;
1045 1653 }
1046 1654
@@ -1068,8 +1676,21 @@
1068 1676 (string) get_post_meta($this->current_post_id, '_thinkrank_og_description', true),
1069 1677 $this->current_post_id
1070 1678 );
1071 1679 $og_image_override = get_post_meta($this->current_post_id, '_thinkrank_og_image', true);
1680 + } elseif ($this->current_term_id) {
1681 + // Terms carry the same social override keys — the abilities API
1682 + // writes them — so honour them here rather than letting the term's
1683 + // SEO title stand in for an explicit og:title.
1684 + $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value(
1685 + (string) get_term_meta($this->current_term_id, '_thinkrank_og_title', true),
1686 + $this->current_term_id
1687 + );
1688 + $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value(
1689 + (string) get_term_meta($this->current_term_id, '_thinkrank_og_description', true),
1690 + $this->current_term_id
1691 + );
1692 + $og_image_override = get_term_meta($this->current_term_id, '_thinkrank_og_image', true);
1072 1693 }
1073 1694
1074 1695 // Get title using priority system: OG override > post-specific > Global SEO > Site Identity > default
1075 1696 $title = '';
@@ -1090,10 +1711,14 @@
1090 1711 $description = $og_description_override;
1091 1712 } else {
1092 1713 $description = $this->get_meta_description();
1093 1714 }
1094 - if (!$description) {
1095 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1715 + // Skipped for a protected post: core answers get_the_excerpt() with its
1716 + // "There is no excerpt because this is a protected post." placeholder,
1717 + // so this is not a leak — but publishing that sentence as the social
1718 + // description is worse than publishing none (#363).
1719 + if (!$description && !$this->is_content_password_protected()) {
1720 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1096 1721 }
1097 1722
1098 1723 $url = is_singular() ? get_permalink() : home_url();
1099 1724 $site_name = $this->site_identity_data && !empty($this->site_identity_data['identity']['site_name'])
@@ -1121,11 +1746,11 @@
1121 1746 $og_type = apply_filters('thinkrank_og_type', $og_type);
1122 1747
1123 1748 echo "<!-- ThinkRank SEO Open Graph Meta Tags -->\n";
1124 1749 echo "<meta property=\"og:type\" content=\"" . esc_attr($og_type) . "\" />\n";
1125 - echo "<meta property=\"og:title\" content=\"" . esc_attr($title) . "\" />\n";
1750 + echo "<meta property=\"og:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1126 1751 echo "<meta property=\"og:description\" content=\"" . esc_attr($description) . "\" />\n";
1127 - echo "<meta property=\"og:url\" content=\"" . esc_url($url) . "\" />\n";
1752 + echo "<meta property=\"og:url\" content=\"" . esc_url(\ThinkRank\SEO\Url_Scheme::apply($url)) . "\" />\n";
1128 1753 echo "<meta property=\"og:site_name\" content=\"" . esc_attr($site_name) . "\" />\n";
1129 1754 /**
1130 1755 * Filter the og:locale value.
1131 1756 *
@@ -1142,14 +1767,17 @@
1142 1767 $og_locale = (string) apply_filters('thinkrank_og_locale', get_locale());
1143 1768 echo "<meta property=\"og:locale\" content=\"" . esc_attr($og_locale) . "\" />\n";
1144 1769
1145 1770 // Add OG image — per-post override > featured image
1771 + $primary_og_image = '';
1146 1772 if (is_singular() && $this->current_post_id) {
1147 1773 if (!empty($og_image_override)) {
1774 + $primary_og_image = (string) $og_image_override;
1148 1775 echo "<meta property=\"og:image\" content=\"" . esc_url($og_image_override) . "\" />\n";
1149 1776 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($og_image_override) . "\" />\n";
1150 1777 } elseif (has_post_thumbnail($this->current_post_id)) {
1151 1778 $image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
1779 + $primary_og_image = (string) $image_url;
1152 1780 echo "<meta property=\"og:image\" content=\"" . esc_url($image_url) . "\" />\n";
1153 1781 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($image_url) . "\" />\n";
1154 1782
1155 1783 // Get image dimensions and alt text
@@ -1178,8 +1806,30 @@
1178 1806 echo "<meta property=\"og:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n";
1179 1807 }
1180 1808 }
1181 1809
1810 + // Alternatives, same as the enhanced emitter above. This path only
1811 + // runs when the Social Meta Manager is unavailable, but the issue
1812 + // reported against it (#636) and a site that lands here should not
1813 + // silently lose a feature it switched on.
1814 + if (!empty($primary_og_image)) {
1815 + $social_settings = $this->social_manager
1816 + ? $this->social_manager->get_settings(
1817 + $this->current_context === 'homepage' ? 'site' : $this->current_context,
1818 + $this->current_post_id
1819 + )
1820 + : [];
1821 +
1822 + if (!empty($social_settings['og_multiple_images'])) {
1823 + self::output_extra_og_images(
1824 + \ThinkRank\SEO\Social_Images::additional(
1825 + (int) $this->current_post_id,
1826 + $primary_og_image
1827 + )
1828 + );
1829 + }
1830 + }
1831 +
1182 1832 // Add article specific tags for posts only
1183 1833 if ($og_type === 'article') {
1184 1834 echo '<meta property="article:published_time" content="' . esc_attr(get_the_date('c', $this->current_post_id)) . '" />' . "\n";
1185 1835 echo '<meta property="article:modified_time" content="' . esc_attr(get_the_modified_date('c', $this->current_post_id)) . '" />' . "\n";
@@ -1207,8 +1857,21 @@
1207 1857 *
1208 1858 * @return void
1209 1859 */
1210 1860 public function output_twitter_card_tags(): void {
1861 + // Per-content-type Twitter card switch; see output_open_graph_tags().
1862 + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
1863 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_TWITTER,
1864 + true
1865 + )) {
1866 + return;
1867 + }
1868 +
1869 + // Same reasoning as the Open Graph block: nothing on a 404 is shareable.
1870 + if ($this->current_context === '404') {
1871 + return;
1872 + }
1873 +
1211 1874 // Priority 1: Try Social Meta Manager (Social Media tab settings)
1212 1875 if ($this->social_manager) {
1213 1876 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1214 1877 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
@@ -1255,8 +1918,14 @@
1255 1918 $twitter_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_twitter_title', true), $pid);
1256 1919 $twitter_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_twitter_description', true), $pid);
1257 1920 $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_og_title', true), $pid);
1258 1921 $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_og_description', true), $pid);
1922 + } elseif ($this->current_term_id) {
1923 + $tid = $this->current_term_id;
1924 + $twitter_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_twitter_title', true), $tid);
1925 + $twitter_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_twitter_description', true), $tid);
1926 + $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_og_title', true), $tid);
1927 + $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_og_description', true), $tid);
1259 1928 }
1260 1929
1261 1930 // Title cascade: Twitter override > OG override > Global SEO > Site Identity > default
1262 1931 $title = '';
@@ -1281,10 +1950,14 @@
1281 1950 $description = $og_description_override;
1282 1951 } else {
1283 1952 $description = $this->get_meta_description();
1284 1953 }
1285 - if (!$description) {
1286 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1954 + // Skipped for a protected post: core answers get_the_excerpt() with its
1955 + // "There is no excerpt because this is a protected post." placeholder,
1956 + // so this is not a leak — but publishing that sentence as the social
1957 + // description is worse than publishing none (#363).
1958 + if (!$description && !$this->is_content_password_protected()) {
1959 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1287 1960 }
1288 1961
1289 1962 // Determine card type based on image availability
1290 1963 $card_type = 'summary';
@@ -1293,9 +1966,9 @@
1293 1966 }
1294 1967
1295 1968 echo "<!-- ThinkRank SEO Twitter Card Meta Tags -->\n";
1296 1969 echo '<meta name="twitter:card" content="' . esc_attr($card_type) . '" />' . "\n";
1297 - echo "<meta name=\"twitter:title\" content=\"" . esc_attr($title) . "\" />\n";
1970 + echo "<meta name=\"twitter:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1298 1971 echo "<meta name=\"twitter:description\" content=\"" . esc_attr($description) . "\" />\n";
1299 1972
1300 1973 // Add Twitter image with proper fallback priority
1301 1974 $twitter_image_url = $this->get_twitter_image_with_fallback();
@@ -1345,11 +2018,18 @@
1345 2018 }
1346 2019
1347 2020 if (empty($canonical_url)) {
1348 2021 $canonical_url = $this->current_post_id ? get_permalink($this->current_post_id) : get_permalink();
2022 +
2023 + // Core's rel_canonical() keeps the page number; this replaced
2024 + // it with a bare permalink, so every <!--nextpage--> sub-page
2025 + // and every /comment-page-N/ canonicalised to page 1 — a
2026 + // regression against core behaviour (#397). A custom canonical
2027 + // is left exactly as the user typed it.
2028 + $canonical_url = self::with_singular_page($canonical_url);
1349 2029 }
1350 2030 } else {
1351 - $canonical_url = $this->get_non_singular_canonical_url();
2031 + $canonical_url = self::get_non_singular_canonical_url();
1352 2032 }
1353 2033
1354 2034 /**
1355 2035 * Filter the canonical URL before output.
@@ -1363,14 +2043,115 @@
1363 2043 if (empty($canonical_url)) {
1364 2044 return;
1365 2045 }
1366 2046
2047 + // After the filter, so a canonical an add-on supplied is normalized
2048 + // too — and a cross-domain one is left alone, since Url_Scheme only
2049 + // touches URLs on this site's own host.
2050 + $canonical_url = \ThinkRank\SEO\Url_Scheme::apply($canonical_url);
2051 +
1367 2052 echo "<!-- ThinkRank SEO Canonical URL -->\n";
1368 2053 echo "<link rel=\"canonical\" href=\"" . esc_url($canonical_url) . "\" />\n";
1369 2054 echo "<!-- /ThinkRank SEO Canonical URL -->\n";
2055 +
2056 + $this->output_pagination_links();
1370 2057 }
1371 2058
1372 2059 /**
2060 + * Emit rel="prev" / rel="next" on a paginated archive.
2061 + *
2062 + * Nothing emitted these at all (#397). Google stopped using them as an
2063 + * indexing signal in 2019, so this is not an SEO win with Google — Bing
2064 + * still reads them, and they are the standard way to describe a sequence,
2065 + * which is what the pages are.
2066 + *
2067 + * @since 2.0.1
2068 + *
2069 + * @return void
2070 + */
2071 + private function output_pagination_links(): void {
2072 + // Page 1 still wants a rel="next" when there is a page 2, so only
2073 + // singular views are skipped outright.
2074 + if (is_singular()) {
2075 + return;
2076 + }
2077 +
2078 + global $wp_query;
2079 +
2080 + $total = $wp_query ? (int) $wp_query->max_num_pages : 0;
2081 +
2082 + if ($total < 2) {
2083 + return;
2084 + }
2085 +
2086 + $base = self::get_non_singular_canonical_url();
2087 +
2088 + if ('' === $base) {
2089 + return;
2090 + }
2091 +
2092 + // get_non_singular_canonical_url() already carries the current page —
2093 + // strip it back to page 1 before building the neighbours.
2094 + $current = self::current_page_number();
2095 + $base = self::without_pagination($base);
2096 +
2097 + if ($current > 1) {
2098 + printf(
2099 + "<link rel=\"prev\" href=\"%s\" />\n",
2100 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current - 1)))
2101 + );
2102 + }
2103 +
2104 + if ($current < $total) {
2105 + printf(
2106 + "<link rel=\"next\" href=\"%s\" />\n",
2107 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current + 1)))
2108 + );
2109 + }
2110 + }
2111 +
2112 + /**
2113 + * The rewrite base WordPress uses for page numbers ('page' by default).
2114 + *
2115 + * @since 2.0.1
2116 + *
2117 + * @return string
2118 + */
2119 + private static function pagination_base(): string {
2120 + global $wp_rewrite;
2121 +
2122 + return $wp_rewrite && $wp_rewrite->pagination_base ? $wp_rewrite->pagination_base : 'page';
2123 + }
2124 +
2125 + /**
2126 + * Append the sub-page or comment-page number to a singular canonical.
2127 + *
2128 + * @since 2.0.1
2129 + *
2130 + * @param string $url Permalink.
2131 + * @return string Permalink with the current page appended, when there is one.
2132 + */
2133 + public static function with_singular_page(string $url): string {
2134 + global $wp_rewrite;
2135 +
2136 + $page = (int) get_query_var('page');
2137 +
2138 + if ($page > 1) {
2139 + return $wp_rewrite && $wp_rewrite->using_permalinks()
2140 + ? trailingslashit($url) . user_trailingslashit($page, 'single_paged')
2141 + : add_query_arg('page', $page, $url);
2142 + }
2143 +
2144 + $comment_page = (int) get_query_var('cpage');
2145 +
2146 + if ($comment_page > 1) {
2147 + return get_comments_pagenum_link($comment_page);
2148 + }
2149 +
2150 + return $url;
2151 + }
2152 +
2153 + /**
1373 2154 * Build the canonical URL for non-singular contexts.
1374 2155 *
1375 2156 * Covers the blog home, post type / taxonomy / author / date archives.
1376 2157 * Search results and 404 pages get no canonical (they are noindexed).
@@ -1378,9 +2159,9 @@
1378 2159 * self-referential rather than pointing at page 1.
1379 2160 *
1380 2161 * @return string Canonical URL or '' when none applies
1381 2162 */
1382 - private function get_non_singular_canonical_url(): string {
2163 + public static function get_non_singular_canonical_url(): string {
1383 2164 if (is_404() || is_search()) {
1384 2165 return '';
1385 2166 }
1386 2167
@@ -1411,17 +2192,89 @@
1411 2192 return '';
1412 2193 }
1413 2194
1414 2195 // Point paginated archives at their own page, not page 1.
2196 + return self::with_pagination($canonical_url, (int) get_query_var('paged'));
2197 + }
2198 +
2199 + /**
2200 + * Append a page number to a URL the way WordPress does.
2201 + *
2202 + * Extracted so the archive canonical is not the only thing that knows how
2203 + * to build a paged URL: the schema graph derived its @id from the
2204 + * un-paginated link, so every page of an archive claimed the same node
2205 + * identity, and the singular canonical dropped the page entirely (#397).
2206 + *
2207 + * @since 2.0.1
2208 + *
2209 + * @param string $url Base URL.
2210 + * @param int $page Page number; 1 or less returns the URL unchanged.
2211 + * @return string
2212 + */
2213 + public static function with_pagination(string $url, int $page): string {
2214 + if ($page <= 1 || '' === $url) {
2215 + return $url;
2216 + }
2217 +
2218 + global $wp_rewrite;
2219 +
2220 + if ($wp_rewrite && $wp_rewrite->using_permalinks()) {
2221 + return trailingslashit($url) . user_trailingslashit(
2222 + $wp_rewrite->pagination_base . '/' . $page,
2223 + 'paged'
2224 + );
2225 + }
2226 +
2227 + return add_query_arg('paged', $page, $url);
2228 + }
2229 +
2230 + /**
2231 + * Strip a page number from a URL, whichever form it takes.
2232 + *
2233 + * The inverse of with_pagination(). Pretty permalinks carry the page as a
2234 + * /page/N/ path segment, plain permalinks as a `paged` query arg, and a
2235 + * regex over the path alone silently left the latter in place — so
2236 + * rel="prev" on page 2 pointed at page 2 (#397 review).
2237 + *
2238 + * @since 2.0.1
2239 + *
2240 + * @param string $url URL that may carry a page number.
2241 + * @return string URL for page 1.
2242 + */
2243 + public static function without_pagination(string $url): string {
2244 + if ('' === $url) {
2245 + return $url;
2246 + }
2247 +
2248 + $url = remove_query_arg('paged', $url);
2249 +
2250 + return (string) preg_replace(
2251 + '#/' . preg_quote(self::pagination_base(), '#') . '/\d+/?$#',
2252 + '/',
2253 + $url
2254 + );
2255 + }
2256 +
2257 + /**
2258 + * The page number of the current request, archive or multi-page post.
2259 + *
2260 + * `paged` counts archive pages; `page` counts the <!--nextpage--> parts of
2261 + * a single post. They are never both set.
2262 + *
2263 + * @since 2.0.1
2264 + *
2265 + * @return int Page number, 1 when this is the first page.
2266 + */
2267 + public static function current_page_number(): int {
1415 2268 $paged = (int) get_query_var('paged');
2269 +
1416 2270 if ($paged > 1) {
1417 - global $wp_rewrite;
1418 - $canonical_url = $wp_rewrite->using_permalinks()
1419 - ? trailingslashit($canonical_url) . user_trailingslashit($wp_rewrite->pagination_base . '/' . $paged, 'paged')
1420 - : add_query_arg('paged', $paged, $canonical_url);
2271 + return $paged;
1421 2272 }
1422 2273
1423 - return $canonical_url;
2274 + $page = (int) get_query_var('page');
2275 +
2276 + return $page > 1 ? $page : 1;
1424 2277 }
1425 2278
1426 2279
1427 2280 /**
@@ -1429,12 +2282,13 @@
1429 2282 *
1430 2283 * @return bool True if has ThinkRank metadata
1431 2284 */
1432 2285 private function has_thinkrank_metadata(): bool {
1433 - if (!is_singular()) {
1434 - return false;
1435 - }
1436 -
2286 + // Populated by initialize_current_context() for singular views and for
2287 + // term archives, and left empty everywhere else — so the emptiness
2288 + // check is the whole test. The `!is_singular()` early return this
2289 + // replaced is what made every stored term title and description inert:
2290 + // the entire title/description cascade hangs off this method (#386).
1437 2291 return !empty($this->current_metadata['title']) || !empty($this->current_metadata['description']);
1438 2292 }
1439 2293
1440 2294 /**
@@ -1568,12 +2422,21 @@
1568 2422
1569 2423 // Get excerpt
1570 2424 $post = get_post($this->current_post_id);
1571 2425 if ($post) {
1572 - $excerpt = !empty($post->post_excerpt)
1573 - ? $post->post_excerpt
1574 - : wp_trim_words(wp_strip_all_tags($post->post_content), 25, '...');
1575 - $placeholders['%excerpt%'] = $excerpt;
2426 + // An authored post_excerpt is written for public consumption, so
2427 + // it stays. Falling back to the body does not: for a protected
2428 + // post that derivation leaks the gated content through any
2429 + // template containing %excerpt%, and this branch runs BEFORE the
2430 + // derive-from-content priority below, so guarding only that one
2431 + // would leave this path open (#363).
2432 + if (!empty($post->post_excerpt)) {
2433 + $placeholders['%excerpt%'] = $post->post_excerpt;
2434 + } elseif (!$this->is_content_password_protected($post->ID)) {
2435 + $placeholders['%excerpt%'] = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt(
2436 + \ThinkRank\SEO\Builder_Content::visible_content($post)
2437 + );
2438 + }
1576 2439 }
1577 2440
1578 2441 // Get author
1579 2442 $author_id = get_post_field('post_author', $this->current_post_id);
@@ -1633,8 +2496,17 @@
1633 2496 $settings = $this->site_identity_manager->get_settings('site');
1634 2497
1635 2498 switch ($this->current_context) {
1636 2499 case 'homepage':
2500 + // detect_current_context() collapses the static posts page into
2501 + // 'homepage', so it rendered the front page's title template and
2502 + // the two pages shipped the same <title> — a duplicate title on
2503 + // the site's two most-linked URLs (#397 review). It is a page,
2504 + // and it has its own name, so it gets the page template.
2505 + if (self::is_static_posts_page()) {
2506 + return $settings['page_title'] ?? $settings['homepage_title'] ?? null;
2507 + }
2508 +
1637 2509 return $settings['homepage_title'] ?? null;
1638 2510 case 'post':
1639 2511 return $settings['post_title'] ?? null;
1640 2512 case 'page':
@@ -1665,12 +2537,18 @@
1665 2537 $settings = $this->site_identity_manager->get_settings('site');
1666 2538 $separator = $this->get_title_separator($settings['title_separator'] ?? 'pipe');
1667 2539
1668 2540 $placeholders = [
1669 - '%site_title%' => $settings['site_name'] ?? get_bloginfo('name'),
1670 - '%site_name%' => $settings['site_name'] ?? get_bloginfo('name'),
1671 - '%site_description%' => $settings['site_description'] ?? get_bloginfo('description'),
1672 - '%tagline%' => $settings['tagline'] ?? get_bloginfo('description'),
2541 + // first_non_empty(), not `??`: Site Identity persists these as ''
2542 + // rather than leaving them unset, and '' is not null — so the
2543 + // null-coalesce stopped dead on the empty string and the WordPress
2544 + // fallback was unreachable. A site with a tagline set in Settings →
2545 + // General rendered "%site_description%" as nothing (#398). This is
2546 + // the same reasoning first_non_empty()'s own docblock records.
2547 + '%site_title%' => $this->first_non_empty($settings['site_name'] ?? '', get_bloginfo('name')),
2548 + '%site_name%' => $this->first_non_empty($settings['site_name'] ?? '', get_bloginfo('name')),
2549 + '%site_description%' => $this->first_non_empty($settings['site_description'] ?? '', get_bloginfo('description')),
2550 + '%tagline%' => $this->first_non_empty($settings['tagline'] ?? '', get_bloginfo('description')),
1673 2551 '%separator%' => ' ' . $separator . ' ',
1674 2552 '%sep%' => ' ' . $separator . ' ',
1675 2553 '%date%' => gmdate('F Y'),
1676 2554 ];
@@ -1723,10 +2601,26 @@
1723 2601 $placeholders['%search_term%'] = get_search_query();
1724 2602 break;
1725 2603
1726 2604 case 'archive':
1727 - $placeholders['%archive_title%'] = get_the_archive_title();
2605 + // Stripped: get_the_archive_title() wraps its subject in a
2606 + // <span>, and this placeholder feeds the document <title> as
2607 + // well as og:title and twitter:title — a date archive rendered
2608 + // as "Month: <span>August 2026</span> | Site".
2609 + $placeholders['%archive_title%'] = self::archive_subject();
1728 2610 break;
2611 +
2612 + case 'homepage':
2613 + // The page template resolved for a static posts page needs the
2614 + // page's own name; without it %title%/%page_title% would render
2615 + // empty and collapse back to the site title.
2616 + if (self::is_static_posts_page()) {
2617 + $posts_page_title = get_the_title((int) get_option('page_for_posts'));
2618 + $placeholders['%title%'] = $posts_page_title;
2619 + $placeholders['%page_title%'] = $posts_page_title;
2620 + $placeholders['%post_title%'] = $posts_page_title;
2621 + }
2622 + break;
1729 2623 }
1730 2624
1731 2625 return $placeholders;
1732 2626 }
@@ -1731,8 +2625,19 @@
1731 2625 return $placeholders;
1732 2626 }
1733 2627
1734 2628 /**
2629 + * Whether this request is a static posts page rather than the front page.
2630 + *
2631 + * @since 2.0.1
2632 + *
2633 + * @return bool
2634 + */
2635 + private static function is_static_posts_page(): bool {
2636 + return is_home() && !is_front_page() && (int) get_option('page_for_posts') > 0;
2637 + }
2638 +
2639 + /**
1735 2640 * Process title template with placeholders
1736 2641 *
1737 2642 * @param string $template Template string
1738 2643 * @param array $placeholders Placeholder values
@@ -1769,8 +2674,42 @@
1769 2674 return \ThinkRank\SEO\Site_Identity_Manager::$title_separators[$separator_type]['symbol'] ?? \ThinkRank\SEO\Site_Identity_Manager::$title_separators['pipe']['symbol'];
1770 2675 }
1771 2676
1772 2677 /**
2678 + * Whether a post's body must not be read for a public surface.
2679 + *
2680 + * Deriving metadata from `post_content` publishes that content to everyone
2681 + * who requests the URL — and to every crawler and link-preview unfurler
2682 + * that reads og:description — while the page itself still shows only the
2683 + * password form, so the leak is invisible to the site owner (#363).
2684 + *
2685 + * This is the one thing every content reader should call before touching
2686 + * `post_content` for output. It mirrors core: a visitor who has already
2687 + * entered the correct password sees the body anyway, so nothing is hidden
2688 + * from them here either.
2689 + *
2690 + * @param int|null $post_id Optional. Post ID. Defaults to the current post.
2691 + * @return bool True when the body is password-gated for this visitor.
2692 + */
2693 + private function is_content_password_protected(?int $post_id = null): bool {
2694 + $post_id = $post_id ?? $this->current_post_id;
2695 +
2696 + if (!$post_id) {
2697 + return false;
2698 + }
2699 +
2700 + $post = get_post($post_id);
2701 +
2702 + if (!$post) {
2703 + return false;
2704 + }
2705 +
2706 + // Guarded for the same reason the schema path guards it: this class is
2707 + // also exercised outside a full front-end request.
2708 + return function_exists('post_password_required') && post_password_required($post);
2709 + }
2710 +
2711 + /**
1773 2712 * Get meta description with fallback system
1774 2713 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates > WordPress defaults
1775 2714 *
1776 2715 * @return string|null Meta description or null if none available
@@ -1803,13 +2742,23 @@
1803 2742 return $default_description;
1804 2743 }
1805 2744 }
1806 2745
1807 - // Fourth priority: Generate from content for posts/pages
1808 - if (is_singular() && $this->current_post_id) {
1809 - $post_content = get_post_field('post_content', $this->current_post_id);
2746 + // Fourth priority: Generate from content for posts/pages.
2747 + // Never for a password-protected post — deriving the description from a
2748 + // gated body published its first ~25 words in the page head, and the
2749 + // same value is reused for og:description and twitter:description, so
2750 + // one unguarded read leaked through three tags (#363).
2751 + if (is_singular() && $this->current_post_id && !$this->is_content_password_protected()) {
2752 + // Not the raw column: a Bricks page discards `post_content`, so
2753 + // whatever is still stored there is invisible — and this one value
2754 + // becomes the meta, og: and twitter: descriptions (#651).
2755 + $described = get_post($this->current_post_id);
2756 + $post_content = $described instanceof \WP_Post
2757 + ? \ThinkRank\SEO\Builder_Content::visible_content($described)
2758 + : get_post_field('post_content', $this->current_post_id);
1810 2759 if ($post_content) {
1811 - $excerpt = wp_trim_words(wp_strip_all_tags($post_content), 25, '...');
2760 + $excerpt = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt((string) $post_content);
1812 2761 if (!empty($excerpt)) {
1813 2762 return $excerpt;
1814 2763 }
1815 2764 }
@@ -1853,11 +2802,13 @@
1853 2802 if ($description === '') {
1854 2803 return null;
1855 2804 }
1856 2805
1857 - if (strlen($description) > 160) {
1858 - $description = wp_trim_words($description, 25, '...');
1859 - }
2806 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2807 + // CJK description tripped this limit at a third of its length, and
2808 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2809 + // English, 25 characters in Thai (#687).
2810 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
1860 2811
1861 2812 return $description;
1862 2813 }
1863 2814
@@ -1904,11 +2855,13 @@
1904 2855 $description = preg_replace('/\s+/', ' ', $description);
1905 2856 $description = trim($description);
1906 2857
1907 2858 // Ensure description doesn't exceed recommended length (160 characters)
1908 - if (strlen($description) > 160) {
1909 - $description = wp_trim_words($description, 25, '...');
1910 - }
2859 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2860 + // CJK description tripped this limit at a third of its length, and
2861 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2862 + // English, 25 characters in Thai (#687).
2863 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
1911 2864
1912 2865 return $description;
1913 2866 }
1914 2867
@@ -1922,8 +2875,19 @@
1922 2875 public function output_site_schema_markup(): void {
1923 2876 $has_schema_manager_output = false;
1924 2877 $has_website_schema = false;
1925 2878
2879 + // The master switch on Essential SEO -> Schema Manager. Until #461 this
2880 + // was never read here, so turning schema off left every deployed entity
2881 + // on the page. Read it once and bail before touching the graph.
2882 + if ($this->schema_manager) {
2883 + $schema_settings = $this->schema_manager->get_settings('site', null);
2884 +
2885 + if (isset($schema_settings['enabled']) && !$schema_settings['enabled']) {
2886 + return;
2887 + }
2888 + }
2889 +
1926 2890 // PRIORITY 1: Always output site-wide schemas (Organization, Website, LocalBusiness, Person)
1927 2891 if ($this->schema_manager) {
1928 2892 $site_wide_schemas = $this->schema_manager->get_deployed_schemas('site', null);
1929 2893
@@ -1928,9 +2892,9 @@
1928 2892 $site_wide_schemas = $this->schema_manager->get_deployed_schemas('site', null);
1929 2893
1930 2894 if (!empty($site_wide_schemas)) {
1931 2895 foreach ($site_wide_schemas as $schema_type => $schema_info) {
1932 - $this->output_schema_markup($schema_info['data'], $schema_type, 'Schema Manager');
2896 + Schema_Graph::instance()->add_supporting($schema_info['data'], (string) $schema_type);
1933 2897 }
1934 2898 $has_schema_manager_output = true;
1935 2899 $has_website_schema = isset($site_wide_schemas['WebSite']);
1936 2900 }
@@ -1951,9 +2915,9 @@
1951 2915 */
1952 2916 $website_schema = apply_filters('thinkrank_website_schema', $website_schema);
1953 2917
1954 2918 if (!empty($website_schema)) {
1955 - $this->output_schema_markup($website_schema, 'WebSite', 'Site Identity');
2919 + Schema_Graph::instance()->add_supporting($website_schema, 'WebSite');
1956 2920 }
1957 2921 }
1958 2922
1959 2923 // PRIORITY 2: Also output page-specific schemas (Article, HowTo, FAQ, etc.) on individual posts/pages
@@ -1959,15 +2923,24 @@
1959 2923 // PRIORITY 2: Also output page-specific schemas (Article, HowTo, FAQ, etc.) on individual posts/pages
1960 2924 if ($this->schema_manager && (is_single() || is_page())) {
1961 2925 $context_id = get_the_ID();
1962 2926 $context_type = get_post_type( $context_id );
1963 - $context_type = in_array( $context_type, [ 'site', 'post', 'page', 'product' ] ) ? $context_type : 'post';
2927 + $context_type = in_array( $context_type, [ 'site', 'post', 'page', 'product' ] , true) ? $context_type : 'post';
1964 2928
1965 2929 $page_specific_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
1966 2930
1967 2931 if (!empty($page_specific_schemas)) {
1968 - // Apply filter for Pro to allow multiple schemas
1969 - // In free version, it's limited to 1 schema if not filtered
2932 + // Every deployed schema is rendered, on every plan. How many a
2933 + // page carries is decided when schemas are activated in the
2934 + // editor, not trimmed here by plan (#673).
2935 +
2936 + /**
2937 + * Filter the page-specific schemas rendered on the current page.
2938 + *
2939 + * @param array $page_specific_schemas Deployed schemas keyed by schema type.
2940 + * @param string $context_type Context type (post, page, product, site).
2941 + * @param int $context_id Post ID.
2942 + */
1970 2943 $page_specific_schemas = apply_filters(
1971 2944 'thinkrank_page_schemas_to_render',
1972 2945 $page_specific_schemas,
1973 2946 $context_type,
@@ -1973,21 +2946,25 @@
1973 2946 $context_type,
1974 2947 $context_id
1975 2948 );
1976 2949
1977 - // If still multiple schemas and not Pro, limit to 1 (enforcing free limit)
1978 - $is_pro = \ThinkRank\Core\Plan_Config::is_pro();
1979 - if (!$is_pro && count($page_specific_schemas) > 2) {
1980 - $page_specific_schemas = array_slice($page_specific_schemas, 0, 2, true);
1981 - }
1982 -
1983 2950 foreach ($page_specific_schemas as $schema_type => $schema_info) {
1984 - $this->output_schema_markup($schema_info['data'], $schema_type, 'Schema Manager');
2951 + Schema_Graph::instance()->add_primary($schema_info['data'], (string) $schema_type, 'schema_manager');
1985 2952 }
1986 2953 $has_schema_manager_output = true;
1987 2954 }
1988 2955 }
1989 2956
2957 + // Absorb FAQ content from the post body (FAQ block / Elementor widget)
2958 + // so it merges into the graph's single FAQPage instead of each producer
2959 + // emitting its own competing one.
2960 + if (is_singular()) {
2961 + $queried_post = get_post();
2962 + if ($queried_post instanceof \WP_Post) {
2963 + Schema_Graph::instance()->collect_post_faq($queried_post);
2964 + }
2965 + }
2966 +
1990 2967 // Skip Site Identity fallback if any Schema Manager schemas were output
1991 2968 if ($has_schema_manager_output) {
1992 2969 return;
1993 2970 }
@@ -2006,9 +2983,9 @@
2006 2983
2007 2984 $schema = $this->generate_organization_schema($settings);
2008 2985
2009 2986 if ($schema) {
2010 - $this->output_schema_markup($schema, 'Organization', 'Site Identity');
2987 + Schema_Graph::instance()->add_supporting($schema, 'Organization');
2011 2988 }
2012 2989 }
2013 2990
2014 2991 /**
@@ -2034,38 +3011,42 @@
2034 3011 if (!empty($description)) {
2035 3012 $schema['description'] = $description;
2036 3013 }
2037 3014
2038 - $schema['potentialAction'] = [
2039 - '@type' => 'SearchAction',
2040 - 'target' => [
2041 - '@type' => 'EntryPoint',
2042 - 'urlTemplate' => home_url('/?s={search_term_string}'),
2043 - ],
2044 - 'query-input' => 'required name=search_term_string',
2045 - ];
3015 + // Site Identity has accepted an alternate name since the setup wizard
3016 + // shipped, and the MCP ability describes it as "published as schema
3017 + // alternateName" — but no producer ever read it, so the promise was
3018 + // false and every imported Yoast/Rank Math value sat unused (#692).
3019 + $alternate_name = \ThinkRank\SEO\Site_Identity_Manager::alternate_name_for_schema($settings['alternate_name'] ?? null);
3020 + if (null !== $alternate_name) {
3021 + $schema['alternateName'] = $alternate_name;
3022 + }
2046 3023
2047 - return $schema;
2048 - }
3024 + // The sitelinks searchbox switch was honoured only for a deployed
3025 + // WebSite row; this live fallback added potentialAction unconditionally,
3026 + // so website_enable_search = 0 still shipped the SearchAction (#688).
3027 + // Absent means not configured, which stays enabled.
3028 + $search_enabled = true;
3029 + if ($this->schema_manager) {
3030 + $schema_settings = $this->schema_manager->get_settings('site', null);
2049 3031
2050 - /**
2051 - * Output schema markup with consistent formatting
2052 - *
2053 - * @param array $schema_data Schema data
2054 - * @param string $schema_type Schema type name
2055 - * @param string $source Source of schema (Schema Manager, Site Identity, etc.)
2056 - * @return void
2057 - */
2058 - private function output_schema_markup(array $schema_data, string $schema_type, string $source): void {
2059 - if (empty($schema_data)) {
2060 - return;
3032 + if (array_key_exists('website_enable_search', $schema_settings)) {
3033 + $search_enabled = !empty($schema_settings['website_enable_search']);
3034 + }
2061 3035 }
2062 3036
2063 - echo '<!-- ThinkRank ' . esc_html($source) . ': ' . esc_html($schema_type) . ' Schema -->' . "\n";
2064 - echo '<script type="application/ld+json">' . "\n";
2065 - echo wp_json_encode($schema_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . "\n";
2066 - echo '</script>' . "\n";
2067 - echo '<!-- /ThinkRank ' . esc_html($source) . ': ' . esc_html($schema_type) . ' Schema -->' . "\n";
3037 + if ($search_enabled) {
3038 + $schema['potentialAction'] = [
3039 + '@type' => 'SearchAction',
3040 + 'target' => [
3041 + '@type' => 'EntryPoint',
3042 + 'urlTemplate' => home_url('/?s={search_term_string}'),
3043 + ],
3044 + 'query-input' => 'required name=search_term_string',
3045 + ];
3046 + }
3047 +
3048 + return $schema;
2068 3049 }
2069 3050
2070 3051 /**
2071 3052 * Generate organization schema markup
@@ -2261,8 +3242,15 @@
2261 3242 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
2262 3243 return;
2263 3244 }
2264 3245
3246 + // A breadcrumb trail for a URL that does not exist, or for a search
3247 + // results page, describes nothing — and the plugin already emits no
3248 + // canonical on either (#471).
3249 + if (is_404() || is_search()) {
3250 + return;
3251 + }
3252 +
2265 3253 $settings = $this->site_identity_manager->get_settings('site');
2266 3254
2267 3255 // Only output if breadcrumbs are enabled
2268 3256 if (empty($settings['breadcrumbs_enabled'])) {
@@ -2268,42 +3256,74 @@
2268 3256 if (empty($settings['breadcrumbs_enabled'])) {
2269 3257 return;
2270 3258 }
2271 3259
3260 + // Schema Manager's own breadcrumb switch. Only Site Identity's
3261 + // breadcrumbs_enabled was consulted here, so enable_breadcrumbs_schema
3262 + // = 0 removed a deployed BreadcrumbList row and left this live one
3263 + // emitting the node anyway (#688). Absent means not configured, which
3264 + // stays enabled.
3265 + if ($this->schema_manager) {
3266 + $schema_settings = $this->schema_manager->get_settings('site', null);
3267 +
3268 + if (array_key_exists('enable_breadcrumbs_schema', $schema_settings)
3269 + && empty($schema_settings['enable_breadcrumbs_schema'])) {
3270 + return;
3271 + }
3272 + }
3273 +
2272 3274 $breadcrumbs = $this->generate_breadcrumbs($settings);
2273 3275
2274 3276 if (!empty($breadcrumbs['schema'])) {
2275 - echo "<!-- ThinkRank SEO Breadcrumb Schema Markup -->\n";
2276 - echo '<script type="application/ld+json">' . "\n";
2277 - echo wp_json_encode($breadcrumbs['schema'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . "\n";
2278 - echo '</script>' . "\n";
2279 - echo "<!-- /ThinkRank SEO Breadcrumb Schema Markup -->\n";
3277 + Schema_Graph::instance()->add_supporting($breadcrumbs['schema'], 'BreadcrumbList');
2280 3278 }
2281 3279 }
2282 3280
2283 3281 /**
3282 + * Emit everything ThinkRank collected for this request as one linked @graph.
3283 + *
3284 + * Runs after every producer has registered (site schema 7, breadcrumbs 8,
3285 + * Global SEO 15), so the graph can arbitrate between them.
3286 + *
3287 + * @since 1.32.0
3288 + * @return void
3289 + */
3290 + public function output_schema_graph(): void {
3291 + Schema_Graph::instance()->render();
3292 + }
3293 +
3294 + /**
2284 3295 * Output closing comment for ThinkRank SEO
2285 3296 *
2286 3297 * @return void
2287 3298 */
2288 3299 public function output_closing_comment(): void {
2289 - // Only output if we've output any SEO content
2290 - static $header_output = false;
2291 - if ($header_output || $this->has_seo_output()) {
3300 + // Close only what was actually opened. has_seo_output() is true on
3301 + // nearly every page, so testing it here printed a closing comment with
3302 + // no matching opener whenever the meta description was empty (search
3303 + // results, author archives without a description).
3304 + if (self::$opening_comment_output) {
2292 3305 echo "<!-- /ThinkRank SEO -->\n";
2293 3306 }
2294 3307 }
2295 3308
2296 3309 /**
2297 - * Check if any SEO content has been output
3310 + * Print the opening ThinkRank comment, once per request.
2298 3311 *
2299 - * @return bool True if SEO content was output
3312 + * Public and static so Author_Archives_Manager — which prints its own meta
3313 + * description on wp_head at priority 5 — opens the block through the same
3314 + * flag the closing comment reads.
3315 + *
3316 + * @since 2.0.1
3317 + * @return void
2300 3318 */
2301 - private function has_seo_output(): bool {
2302 - // Check if we have meta description or any other SEO data
2303 - return !empty($this->get_meta_description()) ||
2304 - $this->has_thinkrank_metadata() ||
2305 - ($this->site_identity_data && $this->site_identity_data['enabled']);
3319 + public static function note_opening_comment(): void {
3320 + if (self::$opening_comment_output) {
3321 + return;
3322 + }
3323 +
3324 + echo "<!-- Search Engine Optimization by ThinkRank - https://thinkrank.ai/ -->\n";
3325 + self::$opening_comment_output = true;
2306 3326 }
2307 3327
2308 3328 /**
2309 3329 * Display breadcrumbs HTML
@@ -2497,15 +3517,64 @@
2497 3517 return $breadcrumbs;
2498 3518 }
2499 3519
2500 3520 /**
3521 + * Serve /llms.txt through PHP so the response declares UTF-8.
3522 + *
3523 + * Cheap guard first: every other front-end request leaves without loading
3524 + * the manager.
3525 + *
3526 + * @since 1.32.0
3527 + *
3528 + * @return void
3529 + */
3530 + public function maybe_serve_llms_txt(): void {
3531 + if (!$this->is_llms_txt_request()) {
3532 + return;
3533 + }
3534 +
3535 + if (!class_exists('ThinkRank\\SEO\\LLMs_Txt_Manager')) {
3536 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-llms-txt-manager.php';
3537 + }
3538 +
3539 + $manager = new \ThinkRank\SEO\LLMs_Txt_Manager();
3540 + $manager->serve_llms_txt();
3541 + }
3542 +
3543 + /**
3544 + * Whether the current request is for /llms.txt.
3545 + *
3546 + * @since 1.32.0
3547 + *
3548 + * @return bool
3549 + */
3550 + private function is_llms_txt_request(): bool {
3551 + if (empty($_SERVER['REQUEST_URI'])) {
3552 + return false;
3553 + }
3554 +
3555 + $path = wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), PHP_URL_PATH);
3556 + if (!is_string($path) || '' === $path) {
3557 + return false;
3558 + }
3559 +
3560 + // Strip the install's home path so subdirectory installs match too.
3561 + $home_path = (string) wp_parse_url(home_url('/'), PHP_URL_PATH);
3562 + if ('' !== $home_path && '/' !== $home_path && 0 === strpos($path, $home_path)) {
3563 + $path = substr($path, strlen($home_path));
3564 + }
3565 +
3566 + return 'llms.txt' === strtolower(trim($path, '/'));
3567 + }
3568 +
3569 + /**
2501 3570 * Filter WordPress robots.txt output
2502 3571 *
2503 3572 * @param string $output The default robots.txt output
2504 - * @param string $public Whether the site is public
3573 + * @param string $is_public Whether the site is public
2505 3574 * @return string Modified robots.txt content
2506 3575 */
2507 - public function filter_robots_txt(string $output, string $public): string {
3576 + public function filter_robots_txt(string $output, string $is_public): string {
2508 3577 // Only override if Site Identity is enabled and robots.txt management is enabled
2509 3578 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
2510 3579 return $output;
2511 3580 }
@@ -2531,8 +3600,179 @@
2531 3600 return $output;
2532 3601 }
2533 3602
2534 3603 /**
3604 + * Disable WordPress core's sitemap while ThinkRank's sitemap is enabled.
3605 + *
3606 + * Prevents the site from publishing two competing sitemap indexes. Core's
3607 + * /wp-sitemap.xml is taken offline (it 404s) and, as a consequence, core
3608 + * stops adding its own "Sitemap:" directive to robots.txt — including on the
3609 + * paths where ThinkRank does not own the robots.txt output.
3610 + *
3611 + * Only ever turns core's sitemap *off*: when ThinkRank's sitemap is disabled
3612 + * the incoming value is returned untouched, so core (or another plugin
3613 + * filtering this) keeps whatever behaviour it already had.
3614 + *
3615 + * @since 1.31.0
3616 + *
3617 + * @param bool $enabled Whether core's sitemap functionality is enabled.
3618 + * @return bool Filtered value.
3619 + */
3620 + public function filter_wp_sitemaps_enabled($enabled): bool {
3621 + return $this->should_disable_core_sitemap() ? false : (bool) $enabled;
3622 + }
3623 +
3624 + /**
3625 + * Redirect the sitemap URLs core owns to the sitemap ThinkRank publishes.
3626 + *
3627 + * Only the *index* route is redirected. Core's per-type children
3628 + * (/wp-sitemap-posts-post-1.xml and friends) are genuinely gone once core is
3629 + * switched off, and a 404 is the honest answer for those; the index is the
3630 + * one URL crawlers and humans actually guess, and the one core's own
3631 + * /sitemap.xml rule funnels into.
3632 + *
3633 + * @since 1.31.0
3634 + *
3635 + * @return void
3636 + */
3637 + public function redirect_core_sitemap_requests(): void {
3638 + if ('index' !== get_query_var('sitemap')) {
3639 + return;
3640 + }
3641 +
3642 + $request_uri = isset($_SERVER['REQUEST_URI'])
3643 + ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI']))
3644 + : '';
3645 +
3646 + $target = $this->resolve_core_sitemap_redirect(
3647 + (string) wp_parse_url($request_uri, PHP_URL_PATH)
3648 + );
3649 +
3650 + if ('' === $target) {
3651 + return;
3652 + }
3653 +
3654 + wp_safe_redirect($target, 301, 'ThinkRank');
3655 + exit;
3656 + }
3657 +
3658 + /**
3659 + * Where a request for one of core's sitemap URLs should be sent, if anywhere.
3660 + *
3661 + * Split out from the hook so the rules are testable without dispatching a
3662 + * request — the caller above is the only part that cannot be (it exits).
3663 + *
3664 + * @since 1.31.0
3665 + *
3666 + * @param string $requested_path Path of the incoming request.
3667 + * @return string Absolute URL to redirect to, or '' to leave the request alone.
3668 + */
3669 + private function resolve_core_sitemap_redirect(string $requested_path): string {
3670 + // Nothing to redirect to unless we have actually taken core offline,
3671 + // which already implies our own sitemap file is on disk.
3672 + if (!$this->should_disable_core_sitemap()) {
3673 + return '';
3674 + }
3675 +
3676 + $target = $this->thinkrank_sitemap_url;
3677 + if ('' === $target) {
3678 + return '';
3679 + }
3680 +
3681 + // Never redirect a URL to itself. A site publishing at /sitemap.xml
3682 + // normally has the web server serve that file before WordPress sees the
3683 + // request, but on a setup where the request does reach PHP this is the
3684 + // difference between a redirect and a loop.
3685 + $destination = (string) wp_parse_url($target, PHP_URL_PATH);
3686 +
3687 + if ('' !== $requested_path && untrailingslashit($requested_path) === untrailingslashit($destination)) {
3688 + return '';
3689 + }
3690 +
3691 + return $target;
3692 + }
3693 +
3694 + /**
3695 + * Whether core's sitemap should be switched off for this site.
3696 + *
3697 + * True when ThinkRank publishes its own sitemap — except in two cases where
3698 + * taking core offline would leave a URL answering nothing:
3699 + *
3700 + * 1. The site is configured to publish *at core's own URL* (the "WordPress
3701 + * Core" preset). Once the static file exists the web server serves it
3702 + * ahead of WordPress anyway, so core can be left alone.
3703 + * 2. ThinkRank's own sitemap file is not on disk yet. Sitemaps here are
3704 + * static files with no dynamic route (see save_sitemap_to_file()), so
3705 + * while the file is missing core's /sitemap.xml -> /wp-sitemap.xml
3706 + * redirect is the only thing answering that URL; suppressing core would
3707 + * turn a recoverable "enabled but not generated" state into a hard 404
3708 + * for crawlers. Core is taken offline as soon as our file appears, so the
3709 + * duplicate-index conflict this filter exists to prevent cannot occur —
3710 + * two indexes are only ever reachable if both are actually published.
3711 + *
3712 + * Once our file does exist, the URLs core stops answering are handed to
3713 + * redirect_core_sitemap_requests() rather than left to 404 — which is why
3714 + * this also resolves the destination.
3715 + *
3716 + * Resolved lazily and memoised: this is consulted from an `init`-time filter
3717 + * on every request, and the underlying settings read is object-cached. The
3718 + * memoisation also keeps the file_exists() call to one per request.
3719 + *
3720 + * @since 1.31.0
3721 + *
3722 + * @return bool True when WordPress core's sitemap should be disabled.
3723 + */
3724 + private function should_disable_core_sitemap(): bool {
3725 + if ($this->thinkrank_sitemap_enabled === null) {
3726 + try {
3727 + // Read-only instance — passing false keeps it from registering a
3728 + // second copy of the save_post/term auto-generation hooks.
3729 + $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3730 + $settings = $generator->get_settings('site');
3731 +
3732 + $this->thinkrank_sitemap_enabled = !empty($settings['enabled'])
3733 + && !$this->publishes_at_core_sitemap_url($settings)
3734 + && $generator->primary_sitemap_file_exists($settings);
3735 +
3736 + if ($this->thinkrank_sitemap_enabled) {
3737 + $this->thinkrank_sitemap_url = $generator->get_primary_sitemap_url($settings);
3738 + }
3739 + } catch (\Exception $e) {
3740 + // Settings unreadable — leave core's sitemap alone rather than
3741 + // removing a working sitemap on the strength of a failed read.
3742 + $this->thinkrank_sitemap_enabled = false;
3743 + $this->thinkrank_sitemap_url = '';
3744 + }
3745 + }
3746 +
3747 + return $this->thinkrank_sitemap_enabled;
3748 + }
3749 +
3750 + /**
3751 + * Whether any configured sitemap URL is WordPress core's own wp-sitemap.xml.
3752 + *
3753 + * @since 1.31.0
3754 + *
3755 + * @param array $settings Sitemap settings.
3756 + * @return bool True when the site publishes at core's sitemap URL.
3757 + */
3758 + private function publishes_at_core_sitemap_url(array $settings): bool {
3759 + foreach ((array) ($settings['sitemap_urls'] ?? []) as $sitemap) {
3760 + if (!is_array($sitemap)) {
3761 + continue;
3762 + }
3763 +
3764 + $path = (string) wp_parse_url((string) ($sitemap['url'] ?? ''), PHP_URL_PATH);
3765 +
3766 + if (ltrim($path, '/') === 'wp-sitemap.xml') {
3767 + return true;
3768 + }
3769 + }
3770 +
3771 + return false;
3772 + }
3773 +
3774 + /**
2535 3775 * Re-sync the physical robots.txt when WordPress's "Discourage search
2536 3776 * engines" setting (blog_public) changes.
2537 3777 *
2538 3778 * Only acts when ThinkRank robots management is enabled AND a physical
@@ -2582,15 +3822,17 @@
2582 3822 if (empty($settings['enabled'])) {
2583 3823 return (string) $url;
2584 3824 }
2585 3825
3826 + $size = (int) $size;
3827 +
2586 3828 // Apple touch icon has its own dedicated setting
2587 - if ((int) $size === 180 && !empty($settings['apple_touch_icon_url'])) {
2588 - return esc_url($settings['apple_touch_icon_url']);
3829 + if ($size === 180 && !empty($settings['apple_touch_icon_url'])) {
3830 + return $this->resolve_icon_url((string) $settings['apple_touch_icon_url'], $size);
2589 3831 }
2590 3832
2591 3833 if (!empty($settings['favicon_url'])) {
2592 - return esc_url($settings['favicon_url']);
3834 + return $this->resolve_icon_url((string) $settings['favicon_url'], $size);
2593 3835 }
2594 3836
2595 3837 return (string) $url;
2596 3838 }
@@ -2595,8 +3837,178 @@
2595 3837 return (string) $url;
2596 3838 }
2597 3839
2598 3840 /**
3841 + * Whether breadcrumb labels should prefer the SEO title.
3842 + *
3843 + * Off unless the site turns it on, so updating the plugin never rewrites an
3844 + * existing trail.
3845 + *
3846 + * @since 2.3.1
3847 + *
3848 + * @param array $settings Breadcrumb settings.
3849 + * @return bool
3850 + */
3851 + private function breadcrumbs_use_seo_title(array $settings): bool {
3852 + return !empty($settings['breadcrumb_use_seo_title']);
3853 + }
3854 +
3855 + /**
3856 + * Label for a post in the breadcrumb trail.
3857 + *
3858 + * With the toggle on, the post's own SEO title wins — the same
3859 + * `_thinkrank_seo_title` value (variable tags resolved) the document title
3860 + * uses — so the trail under a search snippet reads the same as the snippet
3861 + * itself. Anything empty falls back to the raw post title; the global title
3862 + * pattern is deliberately NOT part of the chain, since resolving it would
3863 + * append the site name to every crumb.
3864 + *
3865 + * @since 2.3.1
3866 + *
3867 + * @param int $post_id Post ID.
3868 + * @param array $settings Breadcrumb settings.
3869 + * @return string Breadcrumb label.
3870 + */
3871 + private function get_breadcrumb_post_title(int $post_id, array $settings): string {
3872 + $title = (string) get_the_title($post_id);
3873 +
3874 + if (!$this->breadcrumbs_use_seo_title($settings)) {
3875 + return $title;
3876 + }
3877 +
3878 + $seo_title = trim((string) get_post_meta($post_id, '_thinkrank_seo_title', true));
3879 +
3880 + if ('' === $seo_title) {
3881 + return $title;
3882 + }
3883 +
3884 + $resolved = trim(\ThinkRank\SEO\Pattern_Resolver::resolve_value($seo_title, $post_id));
3885 +
3886 + return '' !== $resolved ? $resolved : $title;
3887 + }
3888 +
3889 + /**
3890 + * Label for a term in the breadcrumb trail.
3891 + *
3892 + * Term counterpart to {@see self::get_breadcrumb_post_title()}, resolving
3893 + * the term's `_thinkrank_seo_title` against its own values.
3894 + *
3895 + * @since 2.3.1
3896 + *
3897 + * @param object $term Term object.
3898 + * @param array $settings Breadcrumb settings.
3899 + * @return string Breadcrumb label.
3900 + */
3901 + private function get_breadcrumb_term_title($term, array $settings): string {
3902 + $name = (string) ($term->name ?? '');
3903 +
3904 + if (!$this->breadcrumbs_use_seo_title($settings) || empty($term->term_id)) {
3905 + return $name;
3906 + }
3907 +
3908 + $seo_title = trim((string) get_term_meta((int) $term->term_id, '_thinkrank_seo_title', true));
3909 +
3910 + if ('' === $seo_title) {
3911 + return $name;
3912 + }
3913 +
3914 + $resolved = trim(\ThinkRank\SEO\Pattern_Resolver::resolve_term_value($seo_title, (int) $term->term_id));
3915 +
3916 + return '' !== $resolved ? $resolved : $name;
3917 + }
3918 +
3919 + /**
3920 + * Resolve a configured icon URL to the derivative that fits $size.
3921 + *
3922 + * wp_site_icon() calls get_site_icon_url() four times — 32, 192, 180 and
3923 + * 270 — and pairs the first two with a hardcoded sizes="" attribute. This
3924 + * filter used to answer all four with the same configured URL, so one
3925 + * upload was declared as every size at once: a 1536x1536 original served
3926 + * to paint a 32px tab icon, under a sizes="32x32" label that was simply
3927 + * untrue (#571).
3928 + *
3929 + * Resolution mirrors core's own get_site_icon_url(), including the
3930 + * >= 512 -> 'full' branch, so ThinkRank's override and the core pipeline
3931 + * pick the same file for the same request.
3932 + *
3933 + * An unresolvable URL (one hosted off-site) is returned unchanged. Nothing
3934 + * is knowable about its dimensions, and suppressing it instead would leave
3935 + * the page with no rel="icon" at all — a worse outcome than an approximate
3936 + * size hint.
3937 + *
3938 + * @param string $configured Configured icon URL.
3939 + * @param int $size Icon size core is asking for.
3940 + * @return string Icon URL for that size.
3941 + */
3942 + private function resolve_icon_url(string $configured, int $size): string {
3943 + $cache_key = md5($configured) . ':' . $size;
3944 + $cached = $this->icon_urls();
3945 +
3946 + if (isset($cached[$cache_key])) {
3947 + return $cached[$cache_key];
3948 + }
3949 +
3950 + $attachment_id = \ThinkRank\SEO\Site_Identity_Manager::icon_attachment_id($configured);
3951 +
3952 + if (!$attachment_id) {
3953 + $resolved = esc_url($configured);
3954 + } else {
3955 + // Mirrors core: at 512 and above the original is what is wanted, and
3956 + // asking for an intermediate size that large would only fall back to it.
3957 + $size_data = $size >= 512 ? 'full' : [$size, $size];
3958 + $url = wp_get_attachment_image_url($attachment_id, $size_data);
3959 + $resolved = $url ? esc_url($url) : esc_url($configured);
3960 + }
3961 +
3962 + $this->icon_urls[$cache_key] = $resolved;
3963 +
3964 + if (!$this->icon_urls_dirty) {
3965 + $this->icon_urls_dirty = true;
3966 + // Written once, after the response is assembled, rather than once
3967 + // per size: wp_site_icon() resolves four in a row.
3968 + add_action('shutdown', [$this, 'persist_icon_urls'], 5);
3969 + }
3970 +
3971 + return $resolved;
3972 + }
3973 +
3974 + /**
3975 + * The resolved-icon-URL map, loaded from its transient on first use.
3976 + *
3977 + * @return array<string, string>
3978 + */
3979 + private function icon_urls(): array {
3980 + if ($this->icon_urls === null) {
3981 + $stored = get_transient(\ThinkRank\SEO\Site_Identity_Manager::ICON_URL_TRANSIENT);
3982 + $this->icon_urls = is_array($stored) ? $stored : [];
3983 + }
3984 +
3985 + return $this->icon_urls;
3986 + }
3987 +
3988 + /**
3989 + * Persist newly resolved icon URLs.
3990 + *
3991 + * Public because it runs on `shutdown`. Invalidated wholesale whenever the
3992 + * site identity settings are saved, which is the only moment the icon
3993 + * choice — or the derivatives behind it — can change.
3994 + *
3995 + * @return void
3996 + */
3997 + public function persist_icon_urls(): void {
3998 + if (!$this->icon_urls_dirty || !is_array($this->icon_urls)) {
3999 + return;
4000 + }
4001 +
4002 + $this->icon_urls_dirty = false;
4003 + set_transient(
4004 + \ThinkRank\SEO\Site_Identity_Manager::ICON_URL_TRANSIENT,
4005 + $this->icon_urls,
4006 + DAY_IN_SECONDS
4007 + );
4008 + }
4009 +
4010 + /**
2599 4011 * Get breadcrumb items for current page
2600 4012 *
2601 4013 * @param array $settings Breadcrumb settings
2602 4014 * @return array Breadcrumb items
@@ -2624,9 +4036,9 @@
2624 4036 $categories = get_the_category($current_post_id);
2625 4037 if (!empty($categories)) {
2626 4038 $category = $categories[0];
2627 4039 $items[] = [
2628 - 'title' => $category->name,
4040 + 'title' => $this->get_breadcrumb_term_title($category, $settings),
2629 4041 'url' => get_category_link($category->term_id),
2630 4042 'position' => $position++
2631 4043 ];
2632 4044 }
@@ -2631,12 +4043,18 @@
2631 4043 ];
2632 4044 }
2633 4045 }
2634 4046
2635 - // Add current post
2636 - if (empty($settings['show_current_page']) || $settings['show_current_page']) {
4047 + // Add current post. `empty($x) || $x` is true for every possible
4048 + // value — an unset key, false, 0, '' and any truthy value alike —
4049 + // so the setting had no effect on the rendered breadcrumb or on
4050 + // the BreadcrumbList JSON-LD, while the admin preview honoured it
4051 + // and disagreed with live output (#398). Site_Identity_Manager
4052 + // already had the correct form: default to on, respect an
4053 + // explicit off.
4054 + if ($settings['show_current_page'] ?? true) {
2637 4055 $items[] = [
2638 - 'title' => get_the_title($current_post_id),
4056 + 'title' => $this->get_breadcrumb_post_title($current_post_id, $settings),
2639 4057 'url' => get_permalink($current_post_id),
2640 4058 'position' => $position,
2641 4059 'current' => true
2642 4060 ];
@@ -2653,9 +4071,9 @@
2653 4071 while ($parent_id) {
2654 4072 $parent = get_post($parent_id);
2655 4073 if ($parent) {
2656 4074 $parents[] = [
2657 - 'title' => get_the_title($parent->ID),
4075 + 'title' => $this->get_breadcrumb_post_title($parent->ID, $settings),
2658 4076 'url' => get_permalink($parent->ID),
2659 4077 'position' => 0 // Will be set later
2660 4078 ];
2661 4079 $parent_id = $parent->post_parent;
@@ -2673,11 +4091,11 @@
2673 4091 $items[] = $parent;
2674 4092 }
2675 4093
2676 4094 // Add current page
2677 - if (empty($settings['show_current_page']) || $settings['show_current_page']) {
4095 + if ($settings['show_current_page'] ?? true) {
2678 4096 $items[] = [
2679 - 'title' => get_the_title($current_post_id),
4097 + 'title' => $this->get_breadcrumb_post_title($current_post_id, $settings),
2680 4098 'url' => get_permalink($current_post_id),
2681 4099 'position' => $position,
2682 4100 'current' => true
2683 4101 ];
@@ -2693,9 +4111,9 @@
2693 4111 while ($parent_id) {
2694 4112 $parent = get_category($parent_id);
2695 4113 if ($parent && !is_wp_error($parent)) {
2696 4114 $parents[] = [
2697 - 'title' => $parent->name,
4115 + 'title' => $this->get_breadcrumb_term_title($parent, $settings),
2698 4116 'url' => get_category_link($parent->term_id),
2699 4117 'position' => 0 // Will be set later
2700 4118 ];
2701 4119 $parent_id = $parent->parent;
@@ -2713,11 +4131,11 @@
2713 4131 $items[] = $parent;
2714 4132 }
2715 4133
2716 4134 // Add current category
2717 - if (empty($settings['show_current_page']) || $settings['show_current_page']) {
4135 + if ($settings['show_current_page'] ?? true) {
2718 4136 $items[] = [
2719 - 'title' => $category->name,
4137 + 'title' => $this->get_breadcrumb_term_title($category, $settings),
2720 4138 'url' => get_category_link($category->term_id),
2721 4139 'position' => $position,
2722 4140 'current' => true
2723 4141 ];