PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 All 50 releases
← All changes | includes/frontend/class-seo-manager.php +1234 -108 1.32.0 → 2.9.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,24 @@
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 + /**
94 147 * Memoised "should core's sitemap be disabled" flag. Null until resolved.
95 148 *
96 149 * @var bool|null
97 150 */
@@ -122,17 +175,22 @@
122 175
123 176 // Initialize Global SEO Schema Output
124 177 $this->initialize_global_seo_schema();
125 178
126 - // Initialize Google Analytics Tracking Manager
127 - $this->initialize_google_analytics_tracking();
128 -
129 179 // Initialize Image SEO Manager
130 180 $this->initialize_image_seo_manager();
131 181
182 + // Initialize External Links Manager (rel=nofollow / target=_blank)
183 + $this->initialize_external_links_manager();
184 +
132 185 // Initialize current post and context data first
133 186 add_action('wp', [$this, 'initialize_current_context']);
134 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 +
135 193 // Use HIGH PRIORITY hooks to override other SEO plugins
136 194 // Priority 1-5 ensures ThinkRank runs before other SEO plugins
137 195
138 196 // Override WordPress title with HIGH priority
@@ -163,8 +221,18 @@
163 221 // the page would emit two <link rel="canonical"> tags on singular views.
164 222 remove_action('wp_head', 'rel_canonical');
165 223 add_action('wp_head', [$this, 'output_canonical_url'], 6);
166 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 +
167 235 // Add Site Identity specific outputs
168 236 add_action('wp_head', [$this, 'output_site_schema_markup'], 7);
169 237 add_action('wp_head', [$this, 'output_breadcrumb_schema'], 8);
170 238 // Late enough that Global_SEO_Schema_Output (priority 15) has registered.
@@ -198,8 +266,16 @@
198 266 // LLMs_Txt_Manager for the static file) guarantees an explicit UTF-8
199 267 // charset. Priority 8 keeps it ahead of redirect_canonical().
200 268 add_action('template_redirect', [$this, 'maybe_serve_llms_txt'], 8);
201 269
270 + // Serve the sitemap from PHP on sites whose web root cannot be written.
271 + // ThinkRank publishes sitemaps as real files, so where that is possible
272 + // the web server answers first and this never runs; where it is not,
273 + // this is the only thing that answers at all, and without it the
274 + // feature was simply unavailable (#752). Same priority 8, and for the
275 + // same reason: ahead of redirect_canonical().
276 + add_action('template_redirect', [$this, 'maybe_serve_sitemap'], 8);
277 +
202 278 // Take WordPress core's own sitemap offline while ThinkRank's is active.
203 279 // Two sitemap indexes on one site is a crawl conflict: core keeps
204 280 // /wp-sitemap.xml served and injects its own "Sitemap:" line into
205 281 // robots.txt (WP_Sitemaps::add_robots, priority 0). Until now that line
@@ -231,8 +307,17 @@
231 307 // Serve the Site Identity favicon through core's site-icon pipeline so
232 308 // wp_site_icon() outputs it on the front-end (and previews pick it up)
233 309 add_filter('get_site_icon_url', [$this, 'filter_site_icon_url'], 10, 2);
234 310
311 + // Rewrite outbound anchors (rel=nofollow / target=_blank). Runs at
312 + // the very end of the_content, after core's formatting AND after the
313 + // image filter above, so it sees the markup the visitor will get. The
314 + // stored post_content is never touched — turning the settings off
315 + // restores the author's markup exactly.
316 + add_filter('the_content', [$this, 'filter_external_links'], 100000);
317 + add_filter('the_excerpt', [$this, 'filter_external_links'], 100000);
318 + add_filter('widget_text_content', [$this, 'filter_external_links'], 100000);
319 +
235 320 // Process image SEO in content
236 321 add_filter('the_content', [$this, 'filter_content_images'], 99999);
237 322 add_filter('post_thumbnail_html', [$this, 'filter_content_images'], 11, 2);
238 323 add_filter('woocommerce_single_product_image_thumbnail_html', [$this, 'filter_content_images'], 11);
@@ -296,35 +381,61 @@
296 381 $this->global_seo_schema->init();
297 382 }
298 383
299 384 /**
300 - * Initialize Google Analytics Tracking Manager
385 + * Initialize Image SEO Manager
301 386 *
302 387 * @return void
303 388 */
304 - private function initialize_google_analytics_tracking(): void {
305 - if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
306 - require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-google-analytics-tracking-manager.php';
389 + private function initialize_image_seo_manager(): void {
390 + if (!class_exists('ThinkRank\\SEO\\Image_SEO_Manager')) {
391 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-image-seo-manager.php';
307 392 }
308 393
309 - // Initialize Google Analytics Tracking Manager
310 - new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
394 + $this->image_seo_manager = new \ThinkRank\SEO\Image_SEO_Manager();
311 395 }
312 396
313 397 /**
314 - * Initialize Image SEO Manager
398 + * Initialize External Links Manager
315 399 *
400 + * @since 2.5.0
316 401 * @return void
317 402 */
318 - private function initialize_image_seo_manager(): void {
319 - if (!class_exists('ThinkRank\\SEO\\Image_SEO_Manager')) {
320 - require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-image-seo-manager.php';
403 + private function initialize_external_links_manager(): void {
404 + if (!class_exists('ThinkRank\\SEO\\External_Links_Manager')) {
405 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-external-links-manager.php';
321 406 }
322 407
323 - $this->image_seo_manager = new \ThinkRank\SEO\Image_SEO_Manager();
408 + $this->external_links_manager = new \ThinkRank\SEO\External_Links_Manager();
324 409 }
325 410
326 411 /**
412 + * Filter rendered content to annotate external links
413 + *
414 + * @since 2.5.0
415 + * @param mixed $content Content to filter; passed through untouched when
416 + * it is not a string.
417 + * @return mixed Filtered content.
418 + */
419 + public function filter_external_links($content) {
420 + // No return type: a filter value another plugin hands through as null
421 + // or an object belongs to whoever set it, and coercing it to '' would
422 + // silently drop their content on the floor.
423 + if (!is_string($content) || $content === '' || !$this->external_links_manager) {
424 + return $content;
425 + }
426 +
427 + // Feeds carry the same markup to a reader we do not control; leave
428 + // them as authored rather than annotating for a context that has no
429 + // browser tab to open.
430 + if (is_feed()) {
431 + return $content;
432 + }
433 +
434 + return $this->external_links_manager->process_content($content);
435 + }
436 +
437 + /**
327 438 * Filter content to inject image SEO attributes
328 439 *
329 440 * @since 1.0.0
330 441 * @param string $content Content to filter
@@ -376,18 +487,74 @@
376 487 $this->current_metadata = $this->get_post_seo_metadata($post_id);
377 488 }
378 489 }
379 490
491 + // Term archives. Category, tag and custom-taxonomy pages store their SEO
492 + // title and description as term meta — written by the term UI, by the
493 + // abilities API and by the Yoast/RankMath/AIOSEO/SEOPress importer — but
494 + // nothing here ever read them, so the whole title/description cascade
495 + // fell through to the theme default and no description tag was printed
496 + // at all. Term robots was fixed for the same reason in 1.31.0 (#290);
497 + // this is the title and description half (#386).
498 + if (is_category() || is_tag() || is_tax()) {
499 + $queried = get_queried_object();
500 + if ($queried instanceof \WP_Term) {
501 + $this->current_term_id = $queried->term_id;
502 + $this->current_metadata = $this->get_term_seo_metadata($queried->term_id);
503 + }
504 + }
505 +
380 506 // Load site identity data
381 507 $this->load_site_identity_data();
382 508 }
383 509
384 510 /**
511 + * Drop the request's post identity once it has become a 404.
512 + *
513 + * `initialize_current_context()` runs on `wp`, but a request can be turned
514 + * into a 404 after that: `set_404()` on `template_redirect` is the ordinary
515 + * way to refuse a URL that did resolve to a real post, and both core and
516 + * plugins do it — ThinkRank Pro's Markdown for AI refuses an ineligible
517 + * `.md` URL that way. The snapshot still said `post`/`page` and still held
518 + * the post id and its metadata, so the error page shipped that post's meta
519 + * description, focus keywords and — where the social emitters got that far
520 + * — its og:description and twitter:description, all of which a request that
521 + * was a 404 from the start never prints (#655).
522 + *
523 + * Clearing the snapshot rather than special-casing each emitter is what
524 + * makes every consumer agree, including the ones that read
525 + * `$current_metadata` without ever asking what the context is.
526 + *
527 + * @since 2.3.1
528 + *
529 + * @return void
530 + */
531 + public function recheck_404_context(): void {
532 + if (!is_404() || '404' === $this->current_context) {
533 + return;
534 + }
535 +
536 + $this->current_context = '404';
537 + $this->current_post_id = null;
538 + $this->current_term_id = null;
539 + $this->current_metadata = [];
540 + }
541 +
542 + /**
385 543 * Detect current page context
386 544 *
387 545 * @return string Current context type
388 546 */
389 547 private function detect_current_context(): string {
548 + // 404 first: a not-found request matches none of the branches below and
549 + // used to fall through to 'site', which handed crawlers the homepage's
550 + // social identity for an error page. It gets its own context so the
551 + // social layer can skip it, matching get_non_singular_canonical_url(),
552 + // which already suppresses the canonical for 404 and search.
553 + if (is_404()) {
554 + return '404';
555 + }
556 +
390 557 if (is_home() || is_front_page()) {
391 558 return 'homepage';
392 559 } elseif (is_single()) {
393 560 return 'post';
@@ -443,8 +610,37 @@
443 610 ];
444 611 }
445 612
446 613 /**
614 + * Get SEO metadata for a term.
615 + *
616 + * Mirrors get_post_seo_metadata(): the stored values may carry variable
617 + * tags, so they are resolved against the term's own values. Focus keyword
618 + * and score have no term equivalent on the frontend and stay empty.
619 + *
620 + * @since 2.0.1
621 + *
622 + * @param int $term_id Term ID.
623 + * @return array SEO metadata.
624 + */
625 + private function get_term_seo_metadata(int $term_id): array {
626 + $title = get_term_meta($term_id, '_thinkrank_seo_title', true);
627 + $description = get_term_meta($term_id, '_thinkrank_meta_description', true);
628 +
629 + return [
630 + 'title' => $title
631 + ? \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) $title, $term_id)
632 + : '',
633 + 'description' => $description
634 + ? \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) $description, $term_id)
635 + : '',
636 + 'focus_keyword' => '',
637 + 'focus_keywords' => [],
638 + 'seo_score' => '',
639 + ];
640 + }
641 +
642 + /**
447 643 * Resolve the effective SEO title for the current request.
448 644 *
449 645 * Same priority chain as override_document_title() — post-specific
450 646 * ThinkRank metadata (resolved _thinkrank_seo_title) > Global SEO
@@ -469,17 +665,23 @@
469 665 * @param string $title Original title
470 666 * @return string Modified title
471 667 */
472 668 public function override_document_title($title): string {
669 + // A content type with metas switched off keeps whatever title the theme
670 + // and WordPress produce (#660).
671 + if (!$this->metas_enabled()) {
672 + return $title;
673 + }
674 +
473 675 // First priority: Post-specific ThinkRank metadata
474 676 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
475 - return $this->current_metadata['title'];
677 + return self::with_page_suffix($this->current_metadata['title']);
476 678 }
477 679
478 680 // Second priority: Global SEO templates, Third priority: Site Identity templates
479 681 $generated_title = $this->generate_context_title();
480 682 if ($generated_title) {
481 - return $generated_title;
683 + return self::with_page_suffix($generated_title);
482 684 }
483 685
484 686 return $title;
485 687 }
@@ -484,8 +686,117 @@
484 686 return $title;
485 687 }
486 688
487 689 /**
690 + * Append a page indicator to a title on page 2 and beyond.
691 + *
692 + * This filter short-circuits pre_get_document_title at priority 1, which
693 + * drops the " – Page 2" core would otherwise add — so every page of an
694 + * archive, and every part of a multi-page post, shared one <title> (#397).
695 + * The templates have no %page% token, so the suffix is added here rather
696 + * than asking every site to edit its title format.
697 + *
698 + * @since 2.0.1
699 + *
700 + * @param string $title Resolved title.
701 + * @return string Title with the page indicator, when there is one.
702 + */
703 + /**
704 + * The archive's subject, without the label WordPress prefixes it with.
705 + *
706 + * `get_the_archive_title()` returns "Month: September 2026", "Archives:
707 + * Recipes", "Category: Uncategorized" — the label is core's, aimed at an
708 + * archive heading on the page, and it reads badly in a browser tab, an
709 + * og:title or a search result. Category, tag and author contexts already
710 + * avoid it by using the raw name; the generic archive context did not, so
711 + * date, custom-post-type and custom-taxonomy archives carried it (#640).
712 + *
713 + * Removed through core's own `get_the_archive_title_prefix` filter rather
714 + * than by matching the prefix text, because that text is translated and
715 + * differs per archive type — a string comparison would work in English and
716 + * silently stop working everywhere else.
717 + *
718 + * A site that wants a prefix can put one in its title template, where it is
719 + * visible and editable, instead of inheriting one it cannot see.
720 + *
721 + * @since 2.7.0
722 + *
723 + * @return string Archive subject, with markup and the core prefix removed.
724 + */
725 + private static function archive_subject(): string {
726 + $drop_prefix = static function (): string {
727 + return '';
728 + };
729 +
730 + add_filter('get_the_archive_title_prefix', $drop_prefix, 99);
731 +
732 + $title = (string) get_the_archive_title();
733 +
734 + remove_filter('get_the_archive_title_prefix', $drop_prefix, 99);
735 +
736 + // The <span> core wraps the subject in survives the prefix filter.
737 + return trim(wp_strip_all_tags($title));
738 + }
739 +
740 + /**
741 + * Remove HTML from a title that is about to be emitted.
742 + *
743 + * A title carrying markup is broken twice over, in two different ways, and
744 + * both were reaching real pages: inside `<title>` the tags render literally,
745 + * because that element is RCDATA and never parses them; inside `og:title`
746 + * and `twitter:title` they are attribute-escaped, so the reader sees
747 + * `&lt;em&gt;` as visible text (#640).
748 + *
749 + * Applied at the point of emission rather than at each source, so it covers
750 + * every branch that can produce a title — post meta, Global SEO templates,
751 + * Site Identity templates — without each having to remember.
752 + *
753 + * Unconditional rather than a setting: there is no title for which markup is
754 + * the correct output. The filter is the escape hatch for anyone who
755 + * disagrees, and lets a site keep entities it deliberately encoded.
756 + *
757 + * @since 2.7.0
758 + *
759 + * @param string $title Title about to be emitted.
760 + * @return string Title with any markup removed.
761 + */
762 + public static function strip_title_tags(string $title): string {
763 + /**
764 + * Filter whether HTML is stripped from generated titles.
765 + *
766 + * @since 2.7.0
767 + *
768 + * @param bool $strip Whether to strip. Default true.
769 + * @param string $title The title being emitted.
770 + */
771 + if (!apply_filters('thinkrank_strip_title_tags', true, $title)) {
772 + return $title;
773 + }
774 +
775 + return trim(wp_strip_all_tags($title));
776 + }
777 +
778 + public static function with_page_suffix(string $title): string {
779 + $title = self::strip_title_tags($title);
780 +
781 + $page = self::current_page_number();
782 +
783 + if ($page <= 1 || '' === $title) {
784 + return $title;
785 + }
786 +
787 + $separator = class_exists('\ThinkRank\SEO\Site_Identity_Manager')
788 + ? \ThinkRank\SEO\Site_Identity_Manager::get_active_separator_symbol()
789 + : '|';
790 +
791 + return $title . ' ' . $separator . ' ' . sprintf(
792 + /* translators: %d: page number. */
793 + __('Page %d', 'thinkrank'),
794 + $page
795 + );
796 + }
797 +
798 + /**
488 799 * Override WordPress wp_title (HIGH PRIORITY)
489 800 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates
490 801 *
491 802 * @param string $title Original title
@@ -492,18 +803,24 @@
492 803 * @param string $sep Title separator
493 804 * @return string Modified title
494 805 */
495 806 public function override_wp_title(string $title, string $sep = ''): string {
807 + if (!$this->metas_enabled()) {
808 + return $title;
809 + }
810 +
496 811 // First priority: Post-specific ThinkRank metadata
497 812 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
498 813 $site_name = get_bloginfo('name');
499 - return $this->current_metadata['title'] . ($sep ? " $sep " : ' | ') . $site_name;
814 + return self::with_page_suffix(
815 + $this->current_metadata['title'] . ($sep ? " $sep " : ' | ') . $site_name
816 + );
500 817 }
501 818
502 819 // Second priority: Global SEO templates, Third priority: Site Identity templates
503 820 $generated_title = $this->generate_context_title();
504 821 if ($generated_title) {
505 - return $generated_title;
822 + return self::with_page_suffix($generated_title);
506 823 }
507 824
508 825 return $title;
509 826 }
@@ -508,8 +825,25 @@
508 825 return $title;
509 826 }
510 827
511 828 /**
829 + * Whether ThinkRank owns the title and meta description for this request.
830 + *
831 + * Metas are on site-wide by default; the per-content-type matrix can switch
832 + * them off for one content type, in which case ThinkRank stops overriding
833 + * the document title and prints no meta description (#660).
834 + *
835 + * @since 2.5.0
836 + * @return bool
837 + */
838 + private function metas_enabled(): bool {
839 + return \ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
840 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_META,
841 + true
842 + );
843 + }
844 +
845 + /**
512 846 * Output meta description (HIGH PRIORITY)
513 847 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates > WordPress defaults
514 848 *
515 849 * Author archives are skipped entirely: Author_Archives_Manager owns that
@@ -524,22 +858,24 @@
524 858 if (is_author()) {
525 859 return;
526 860 }
527 861
862 + if (!$this->metas_enabled()) {
863 + return;
864 + }
865 +
528 866 $description = $this->get_meta_description();
529 867
530 868 if ($description) {
531 869 // Output main ThinkRank SEO header comment (only once)
532 - static $header_output = false;
533 - if (!$header_output) {
534 - echo "<!-- Search Engine Optimization by ThinkRank - https://thinkrank.ai/ -->\n";
535 - $header_output = true;
536 - }
870 + self::note_opening_comment();
537 871
538 872 // Ensure description is within optimal length (150-160 characters)
539 - if (strlen($description) > 160) {
540 - $description = wp_trim_words($description, 25, '...');
541 - }
873 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
874 + // CJK description tripped this limit at a third of its length, and
875 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
876 + // English, 25 characters in Thai (#687).
877 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
542 878
543 879 echo "<!-- ThinkRank SEO Meta Description -->\n";
544 880 echo '<meta name="description" content="' . esc_attr($description) . '" />' . "\n";
545 881 echo "<!-- /ThinkRank SEO Meta Description -->\n";
@@ -580,13 +916,41 @@
580 916
581 917 // Output generator meta tag
582 918 echo '<meta name="generator" content="ThinkRank ' . esc_attr(THINKRANK_VERSION) . '" />' . "\n";
583 919
584 - // Output viewport meta tag if not already present
585 - if (!has_action('wp_head', 'wp_site_icon') || !wp_is_mobile()) {
586 - echo '<meta name="viewport" content="width=device-width, initial-scale=1.0" />' . "\n";
920 + // No viewport tag here. The viewport is the theme's responsibility and
921 + // every modern theme ships one, so emitting our own only ever produced a
922 + // second <meta name="viewport"> in the document. The old guard could not
923 + // prevent that either: has_action() returns the registered priority
924 + // (truthy), so its first operand was always false, and !wp_is_mobile() is
925 + // true for every desktop request.
926 + echo "<!-- /ThinkRank SEO Meta Tags -->\n";
927 + }
928 +
929 + /**
930 + * Build the basic robots directive list from a set of robots flags.
931 + *
932 + * Shared by the search/404 branch of get_robots_meta_content() so those
933 + * pages resolve their directives through the same rules as everything else
934 + * rather than a hardcoded literal.
935 + *
936 + * @since 2.5.0
937 + * @param array $settings Robots flags (index/noindex/nofollow/...).
938 + * @return string[] Directives.
939 + */
940 + private static function build_robots_directives(array $settings): array {
941 + $robots = [];
942 +
943 + $robots[] = !empty($settings['noindex']) ? 'noindex' : 'index';
944 + $robots[] = !empty($settings['nofollow']) ? 'nofollow' : 'follow';
945 +
946 + foreach (['noarchive', 'noimageindex', 'nosnippet'] as $directive) {
947 + if (!empty($settings[$directive])) {
948 + $robots[] = $directive;
949 + }
587 950 }
588 - echo "<!-- /ThinkRank SEO Meta Tags -->\n";
951 +
952 + return $robots;
589 953 }
590 954
591 955 /**
592 956 * Get robots meta content based on context and settings
@@ -595,13 +959,22 @@
595 959 */
596 960 private function get_robots_meta_content(): string {
597 961 $robots = [];
598 962
599 - // 404 and search results must never be indexed, regardless of the
600 - // configured global/post-type directives. Links are still followed so
601 - // crawlers can discover the rest of the site.
963 + // 404 and search results are noindex/follow by default — the behaviour
964 + // that used to be hardcoded here. It is now settings-driven (#660): the
965 + // Content Type Matrix can give either its own robots directives, and an
966 + // install that never touched them resolves to exactly the old pair.
602 967 if (is_404() || is_search()) {
603 - $robots = apply_filters('thinkrank_robots_meta', ['noindex', 'follow']);
968 + $entity = is_404()
969 + ? \ThinkRank\SEO\Content_Type_Settings::ENTITY_404
970 + : \ThinkRank\SEO\Content_Type_Settings::ENTITY_SEARCH;
971 +
972 + $robots = self::build_robots_directives(
973 + \ThinkRank\SEO\Content_Type_Settings::resolve_robots_meta($entity)
974 + );
975 +
976 + $robots = apply_filters('thinkrank_robots_meta', $robots);
604 977 return implode(', ', array_unique($robots));
605 978 }
606 979
607 980 // 1. Get global robot meta settings (Base)
@@ -630,8 +1003,24 @@
630 1003 $current_settings = array_merge($current_settings, $global_seo_settings[$post_type]['robots_meta']);
631 1004 }
632 1005 }
633 1006
1007 + // 2b. Apply the per-entity directives for the non-singular content
1008 + // types the matrix covers — taxonomy archives plus author and date
1009 + // archives. Terms keep their own per-term override, applied further
1010 + // down so it still wins over the taxonomy-wide value (#660).
1011 + if (!is_singular()) {
1012 + $entity_key = \ThinkRank\SEO\Content_Type_Settings::current_entity_key();
1013 +
1014 + if ($entity_key !== null) {
1015 + $entity_settings = \ThinkRank\SEO\Content_Type_Settings::get_entity_settings($entity_key);
1016 +
1017 + if (!empty($entity_settings['robots_meta_enabled']) && is_array($entity_settings['robots_meta'] ?? null)) {
1018 + $current_settings = array_merge($current_settings, $entity_settings['robots_meta']);
1019 + }
1020 + }
1021 + }
1022 +
634 1023 // Determine Index/Noindex based on merged settings
635 1024 // Priority: if noindex is true, it overrides index
636 1025 if (!empty($current_settings['noindex'])) {
637 1026 $robots[] = 'noindex';
@@ -695,12 +1084,30 @@
695 1084 $robots = $this->apply_post_robots_override(get_the_ID(), $robots, $current_settings);
696 1085 }
697 1086
698 1087 // Check for archive pages (search is handled by the early return above)
699 - if (is_archive()) {
1088 + //
1089 + // is_home() is deliberately included: the blog listing is not an
1090 + // is_archive(), so page 2 of a term archive was noindex while page 2 of
1091 + // the blog listing was index — the same kind of page, treated two
1092 + // different ways, on the same site (#397).
1093 + if (is_archive() || is_home()) {
700 1094 // Allow indexing of category/tag archives but be more conservative
701 1095 if (is_paged()) {
702 - $robots = ['noindex', 'follow'];
1096 + /**
1097 + * Filter whether a paginated archive is set noindex.
1098 + *
1099 + * Rank Math and Yoast now index paginated archives with a
1100 + * self-referential canonical by default, so a site that wants
1101 + * that can have it without patching.
1102 + *
1103 + * @since 2.0.1
1104 + *
1105 + * @param bool $noindex Whether to noindex this paginated page.
1106 + */
1107 + if (apply_filters('thinkrank_noindex_paged_archives', true)) {
1108 + $robots = ['noindex', 'follow'];
1109 + }
703 1110 }
704 1111
705 1112 // Honor the global date-archive noindex toggle (written by the
706 1113 // Rank Math/Yoast settings importer). Author archives are handled
@@ -980,9 +1387,9 @@
980 1387 *
981 1388 * @param array $og_tags Open Graph tags array
982 1389 * @return void
983 1390 */
984 - private function output_social_og_tags(array $og_tags): void {
1391 + private function output_social_og_tags(array $og_tags, array $extra_images = []): void {
985 1392 // Honor the thinkrank_og_type filter here too — this "Enhanced" path is
986 1393 // the active OG emitter, so add-ons (e.g. Pro's WooCommerce module which
987 1394 // sets 'product' on product pages) must be applied to it, not only to
988 1395 // output_open_graph_tags().
@@ -1026,12 +1433,62 @@
1026 1433 echo '<meta property="' . esc_attr($property) . '" content="' . $this->esc_meta_value($property, $content) . '" />' . "\n";
1027 1434 }
1028 1435 }
1029 1436
1437 + // Alternatives, after the primary and everything belonging to it.
1438 + // Order is the whole point: a consumer reads og:image tags in document
1439 + // order and treats the first as primary, and a structured property
1440 + // attaches to the most recently declared image — so each alternative's
1441 + // companions have to follow its own URL, not be grouped at the end.
1442 + self::output_extra_og_images($extra_images);
1443 +
1030 1444 echo "<!-- /ThinkRank SEO Open Graph Tags -->\n";
1031 1445 }
1032 1446
1033 1447 /**
1448 + * Emit the secondary og:image tags a page offers.
1449 + *
1450 + * Shared by the enhanced and basic emitters so both describe an
1451 + * alternative image the same way (#636).
1452 + *
1453 + * @since 2.7.0
1454 + *
1455 + * @param array $images Each with url, and width/height/type/alt where known.
1456 + * @return void
1457 + */
1458 + private static function output_extra_og_images(array $images): void {
1459 + foreach ($images as $image) {
1460 + $url = isset($image['url']) ? (string) $image['url'] : '';
1461 +
1462 + if ('' === $url) {
1463 + continue;
1464 + }
1465 +
1466 + echo '<meta property="og:image" content="' . esc_url($url) . '" />' . "\n";
1467 +
1468 + if (strpos($url, 'https://') === 0) {
1469 + echo '<meta property="og:image:secure_url" content="' . esc_url($url) . '" />' . "\n";
1470 + }
1471 +
1472 + // Only what is actually known: a dimension guessed for a remote
1473 + // image is a number a consumer lays a card out with before it has
1474 + // fetched the file.
1475 + if (!empty($image['width']) && !empty($image['height'])) {
1476 + echo '<meta property="og:image:width" content="' . esc_attr((string) $image['width']) . '" />' . "\n";
1477 + echo '<meta property="og:image:height" content="' . esc_attr((string) $image['height']) . '" />' . "\n";
1478 + }
1479 +
1480 + if (!empty($image['type'])) {
1481 + echo '<meta property="og:image:type" content="' . esc_attr((string) $image['type']) . '" />' . "\n";
1482 + }
1483 +
1484 + if (!empty($image['alt'])) {
1485 + echo '<meta property="og:image:alt" content="' . esc_attr((string) $image['alt']) . '" />' . "\n";
1486 + }
1487 + }
1488 + }
1489 +
1490 + /**
1034 1491 * Output social media Twitter Card tags from Social Meta Manager
1035 1492 *
1036 1493 * @param array $twitter_tags Twitter Card tags array
1037 1494 * @return void
@@ -1096,8 +1553,16 @@
1096 1553 *
1097 1554 * @return void
1098 1555 */
1099 1556 public function output_platform_meta_tags(): void {
1557 + // Same reasoning as the Open Graph and Twitter emitters: an error page
1558 + // has no shareable identity, and passing '404' through as a social
1559 + // context asks the manager for settings that describe a page which does
1560 + // not exist. Guarding all three keeps them from disagreeing.
1561 + if ($this->current_context === '404') {
1562 + return;
1563 + }
1564 +
1100 1565 // Try Social Meta Manager for platform tags
1101 1566 if ($this->social_manager) {
1102 1567 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1103 1568 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
@@ -1149,8 +1614,23 @@
1149 1614 *
1150 1615 * @return void
1151 1616 */
1152 1617 public function output_open_graph_tags(): void {
1618 + // Per-content-type Open Graph switch. 'inherit' (the default) keeps the
1619 + // site-wide Social Media setting, which the emitters below read (#660).
1620 + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
1621 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_OPEN_GRAPH,
1622 + true
1623 + )) {
1624 + return;
1625 + }
1626 +
1627 + // An error page has no shareable identity. Emitting Open Graph here
1628 + // advertised the homepage as the og:url of a URL that does not exist.
1629 + if ($this->current_context === '404') {
1630 + return;
1631 + }
1632 +
1153 1633 // Priority 1: Try Social Meta Manager (Social Media tab settings)
1154 1634 if ($this->social_manager) {
1155 1635 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1156 1636 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
@@ -1171,9 +1651,12 @@
1171 1651 // The Social Meta Manager ran, so it owns Open Graph output. If OG is
1172 1652 // toggled off, emit nothing — do NOT fall through to the basic
1173 1653 // emitter (which would re-add a full OG block despite the toggle).
1174 1654 if (!empty($social_data['og_enabled'])) {
1175 - $this->output_social_og_tags($social_data['og_tags']);
1655 + $this->output_social_og_tags(
1656 + $social_data['og_tags'],
1657 + $social_data['og_extra_images'] ?? []
1658 + );
1176 1659 }
1177 1660 return;
1178 1661 }
1179 1662
@@ -1201,8 +1684,21 @@
1201 1684 (string) get_post_meta($this->current_post_id, '_thinkrank_og_description', true),
1202 1685 $this->current_post_id
1203 1686 );
1204 1687 $og_image_override = get_post_meta($this->current_post_id, '_thinkrank_og_image', true);
1688 + } elseif ($this->current_term_id) {
1689 + // Terms carry the same social override keys — the abilities API
1690 + // writes them — so honour them here rather than letting the term's
1691 + // SEO title stand in for an explicit og:title.
1692 + $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value(
1693 + (string) get_term_meta($this->current_term_id, '_thinkrank_og_title', true),
1694 + $this->current_term_id
1695 + );
1696 + $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value(
1697 + (string) get_term_meta($this->current_term_id, '_thinkrank_og_description', true),
1698 + $this->current_term_id
1699 + );
1700 + $og_image_override = get_term_meta($this->current_term_id, '_thinkrank_og_image', true);
1205 1701 }
1206 1702
1207 1703 // Get title using priority system: OG override > post-specific > Global SEO > Site Identity > default
1208 1704 $title = '';
@@ -1228,9 +1724,9 @@
1228 1724 // "There is no excerpt because this is a protected post." placeholder,
1229 1725 // so this is not a leak — but publishing that sentence as the social
1230 1726 // description is worse than publishing none (#363).
1231 1727 if (!$description && !$this->is_content_password_protected()) {
1232 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1728 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1233 1729 }
1234 1730
1235 1731 $url = is_singular() ? get_permalink() : home_url();
1236 1732 $site_name = $this->site_identity_data && !empty($this->site_identity_data['identity']['site_name'])
@@ -1258,11 +1754,11 @@
1258 1754 $og_type = apply_filters('thinkrank_og_type', $og_type);
1259 1755
1260 1756 echo "<!-- ThinkRank SEO Open Graph Meta Tags -->\n";
1261 1757 echo "<meta property=\"og:type\" content=\"" . esc_attr($og_type) . "\" />\n";
1262 - echo "<meta property=\"og:title\" content=\"" . esc_attr($title) . "\" />\n";
1758 + echo "<meta property=\"og:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1263 1759 echo "<meta property=\"og:description\" content=\"" . esc_attr($description) . "\" />\n";
1264 - echo "<meta property=\"og:url\" content=\"" . esc_url($url) . "\" />\n";
1760 + echo "<meta property=\"og:url\" content=\"" . esc_url(\ThinkRank\SEO\Url_Scheme::apply($url)) . "\" />\n";
1265 1761 echo "<meta property=\"og:site_name\" content=\"" . esc_attr($site_name) . "\" />\n";
1266 1762 /**
1267 1763 * Filter the og:locale value.
1268 1764 *
@@ -1279,14 +1775,17 @@
1279 1775 $og_locale = (string) apply_filters('thinkrank_og_locale', get_locale());
1280 1776 echo "<meta property=\"og:locale\" content=\"" . esc_attr($og_locale) . "\" />\n";
1281 1777
1282 1778 // Add OG image — per-post override > featured image
1779 + $primary_og_image = '';
1283 1780 if (is_singular() && $this->current_post_id) {
1284 1781 if (!empty($og_image_override)) {
1782 + $primary_og_image = (string) $og_image_override;
1285 1783 echo "<meta property=\"og:image\" content=\"" . esc_url($og_image_override) . "\" />\n";
1286 1784 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($og_image_override) . "\" />\n";
1287 1785 } elseif (has_post_thumbnail($this->current_post_id)) {
1288 1786 $image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
1787 + $primary_og_image = (string) $image_url;
1289 1788 echo "<meta property=\"og:image\" content=\"" . esc_url($image_url) . "\" />\n";
1290 1789 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($image_url) . "\" />\n";
1291 1790
1292 1791 // Get image dimensions and alt text
@@ -1315,8 +1814,30 @@
1315 1814 echo "<meta property=\"og:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n";
1316 1815 }
1317 1816 }
1318 1817
1818 + // Alternatives, same as the enhanced emitter above. This path only
1819 + // runs when the Social Meta Manager is unavailable, but the issue
1820 + // reported against it (#636) and a site that lands here should not
1821 + // silently lose a feature it switched on.
1822 + if (!empty($primary_og_image)) {
1823 + $social_settings = $this->social_manager
1824 + ? $this->social_manager->get_settings(
1825 + $this->current_context === 'homepage' ? 'site' : $this->current_context,
1826 + $this->current_post_id
1827 + )
1828 + : [];
1829 +
1830 + if (!empty($social_settings['og_multiple_images'])) {
1831 + self::output_extra_og_images(
1832 + \ThinkRank\SEO\Social_Images::additional(
1833 + (int) $this->current_post_id,
1834 + $primary_og_image
1835 + )
1836 + );
1837 + }
1838 + }
1839 +
1319 1840 // Add article specific tags for posts only
1320 1841 if ($og_type === 'article') {
1321 1842 echo '<meta property="article:published_time" content="' . esc_attr(get_the_date('c', $this->current_post_id)) . '" />' . "\n";
1322 1843 echo '<meta property="article:modified_time" content="' . esc_attr(get_the_modified_date('c', $this->current_post_id)) . '" />' . "\n";
@@ -1344,8 +1865,21 @@
1344 1865 *
1345 1866 * @return void
1346 1867 */
1347 1868 public function output_twitter_card_tags(): void {
1869 + // Per-content-type Twitter card switch; see output_open_graph_tags().
1870 + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
1871 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_TWITTER,
1872 + true
1873 + )) {
1874 + return;
1875 + }
1876 +
1877 + // Same reasoning as the Open Graph block: nothing on a 404 is shareable.
1878 + if ($this->current_context === '404') {
1879 + return;
1880 + }
1881 +
1348 1882 // Priority 1: Try Social Meta Manager (Social Media tab settings)
1349 1883 if ($this->social_manager) {
1350 1884 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1351 1885 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
@@ -1392,8 +1926,14 @@
1392 1926 $twitter_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_twitter_title', true), $pid);
1393 1927 $twitter_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_twitter_description', true), $pid);
1394 1928 $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_og_title', true), $pid);
1395 1929 $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_og_description', true), $pid);
1930 + } elseif ($this->current_term_id) {
1931 + $tid = $this->current_term_id;
1932 + $twitter_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_twitter_title', true), $tid);
1933 + $twitter_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_twitter_description', true), $tid);
1934 + $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_og_title', true), $tid);
1935 + $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_og_description', true), $tid);
1396 1936 }
1397 1937
1398 1938 // Title cascade: Twitter override > OG override > Global SEO > Site Identity > default
1399 1939 $title = '';
@@ -1423,9 +1963,9 @@
1423 1963 // "There is no excerpt because this is a protected post." placeholder,
1424 1964 // so this is not a leak — but publishing that sentence as the social
1425 1965 // description is worse than publishing none (#363).
1426 1966 if (!$description && !$this->is_content_password_protected()) {
1427 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1967 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1428 1968 }
1429 1969
1430 1970 // Determine card type based on image availability
1431 1971 $card_type = 'summary';
@@ -1434,9 +1974,9 @@
1434 1974 }
1435 1975
1436 1976 echo "<!-- ThinkRank SEO Twitter Card Meta Tags -->\n";
1437 1977 echo '<meta name="twitter:card" content="' . esc_attr($card_type) . '" />' . "\n";
1438 - echo "<meta name=\"twitter:title\" content=\"" . esc_attr($title) . "\" />\n";
1978 + echo "<meta name=\"twitter:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1439 1979 echo "<meta name=\"twitter:description\" content=\"" . esc_attr($description) . "\" />\n";
1440 1980
1441 1981 // Add Twitter image with proper fallback priority
1442 1982 $twitter_image_url = $this->get_twitter_image_with_fallback();
@@ -1486,11 +2026,18 @@
1486 2026 }
1487 2027
1488 2028 if (empty($canonical_url)) {
1489 2029 $canonical_url = $this->current_post_id ? get_permalink($this->current_post_id) : get_permalink();
2030 +
2031 + // Core's rel_canonical() keeps the page number; this replaced
2032 + // it with a bare permalink, so every <!--nextpage--> sub-page
2033 + // and every /comment-page-N/ canonicalised to page 1 — a
2034 + // regression against core behaviour (#397). A custom canonical
2035 + // is left exactly as the user typed it.
2036 + $canonical_url = self::with_singular_page($canonical_url);
1490 2037 }
1491 2038 } else {
1492 - $canonical_url = $this->get_non_singular_canonical_url();
2039 + $canonical_url = self::get_non_singular_canonical_url();
1493 2040 }
1494 2041
1495 2042 /**
1496 2043 * Filter the canonical URL before output.
@@ -1504,14 +2051,115 @@
1504 2051 if (empty($canonical_url)) {
1505 2052 return;
1506 2053 }
1507 2054
2055 + // After the filter, so a canonical an add-on supplied is normalized
2056 + // too — and a cross-domain one is left alone, since Url_Scheme only
2057 + // touches URLs on this site's own host.
2058 + $canonical_url = \ThinkRank\SEO\Url_Scheme::apply($canonical_url);
2059 +
1508 2060 echo "<!-- ThinkRank SEO Canonical URL -->\n";
1509 2061 echo "<link rel=\"canonical\" href=\"" . esc_url($canonical_url) . "\" />\n";
1510 2062 echo "<!-- /ThinkRank SEO Canonical URL -->\n";
2063 +
2064 + $this->output_pagination_links();
1511 2065 }
1512 2066
1513 2067 /**
2068 + * Emit rel="prev" / rel="next" on a paginated archive.
2069 + *
2070 + * Nothing emitted these at all (#397). Google stopped using them as an
2071 + * indexing signal in 2019, so this is not an SEO win with Google — Bing
2072 + * still reads them, and they are the standard way to describe a sequence,
2073 + * which is what the pages are.
2074 + *
2075 + * @since 2.0.1
2076 + *
2077 + * @return void
2078 + */
2079 + private function output_pagination_links(): void {
2080 + // Page 1 still wants a rel="next" when there is a page 2, so only
2081 + // singular views are skipped outright.
2082 + if (is_singular()) {
2083 + return;
2084 + }
2085 +
2086 + global $wp_query;
2087 +
2088 + $total = $wp_query ? (int) $wp_query->max_num_pages : 0;
2089 +
2090 + if ($total < 2) {
2091 + return;
2092 + }
2093 +
2094 + $base = self::get_non_singular_canonical_url();
2095 +
2096 + if ('' === $base) {
2097 + return;
2098 + }
2099 +
2100 + // get_non_singular_canonical_url() already carries the current page —
2101 + // strip it back to page 1 before building the neighbours.
2102 + $current = self::current_page_number();
2103 + $base = self::without_pagination($base);
2104 +
2105 + if ($current > 1) {
2106 + printf(
2107 + "<link rel=\"prev\" href=\"%s\" />\n",
2108 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current - 1)))
2109 + );
2110 + }
2111 +
2112 + if ($current < $total) {
2113 + printf(
2114 + "<link rel=\"next\" href=\"%s\" />\n",
2115 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current + 1)))
2116 + );
2117 + }
2118 + }
2119 +
2120 + /**
2121 + * The rewrite base WordPress uses for page numbers ('page' by default).
2122 + *
2123 + * @since 2.0.1
2124 + *
2125 + * @return string
2126 + */
2127 + private static function pagination_base(): string {
2128 + global $wp_rewrite;
2129 +
2130 + return $wp_rewrite && $wp_rewrite->pagination_base ? $wp_rewrite->pagination_base : 'page';
2131 + }
2132 +
2133 + /**
2134 + * Append the sub-page or comment-page number to a singular canonical.
2135 + *
2136 + * @since 2.0.1
2137 + *
2138 + * @param string $url Permalink.
2139 + * @return string Permalink with the current page appended, when there is one.
2140 + */
2141 + public static function with_singular_page(string $url): string {
2142 + global $wp_rewrite;
2143 +
2144 + $page = (int) get_query_var('page');
2145 +
2146 + if ($page > 1) {
2147 + return $wp_rewrite && $wp_rewrite->using_permalinks()
2148 + ? trailingslashit($url) . user_trailingslashit($page, 'single_paged')
2149 + : add_query_arg('page', $page, $url);
2150 + }
2151 +
2152 + $comment_page = (int) get_query_var('cpage');
2153 +
2154 + if ($comment_page > 1) {
2155 + return get_comments_pagenum_link($comment_page);
2156 + }
2157 +
2158 + return $url;
2159 + }
2160 +
2161 + /**
1514 2162 * Build the canonical URL for non-singular contexts.
1515 2163 *
1516 2164 * Covers the blog home, post type / taxonomy / author / date archives.
1517 2165 * Search results and 404 pages get no canonical (they are noindexed).
@@ -1519,9 +2167,9 @@
1519 2167 * self-referential rather than pointing at page 1.
1520 2168 *
1521 2169 * @return string Canonical URL or '' when none applies
1522 2170 */
1523 - private function get_non_singular_canonical_url(): string {
2171 + public static function get_non_singular_canonical_url(): string {
1524 2172 if (is_404() || is_search()) {
1525 2173 return '';
1526 2174 }
1527 2175
@@ -1552,17 +2200,89 @@
1552 2200 return '';
1553 2201 }
1554 2202
1555 2203 // Point paginated archives at their own page, not page 1.
2204 + return self::with_pagination($canonical_url, (int) get_query_var('paged'));
2205 + }
2206 +
2207 + /**
2208 + * Append a page number to a URL the way WordPress does.
2209 + *
2210 + * Extracted so the archive canonical is not the only thing that knows how
2211 + * to build a paged URL: the schema graph derived its @id from the
2212 + * un-paginated link, so every page of an archive claimed the same node
2213 + * identity, and the singular canonical dropped the page entirely (#397).
2214 + *
2215 + * @since 2.0.1
2216 + *
2217 + * @param string $url Base URL.
2218 + * @param int $page Page number; 1 or less returns the URL unchanged.
2219 + * @return string
2220 + */
2221 + public static function with_pagination(string $url, int $page): string {
2222 + if ($page <= 1 || '' === $url) {
2223 + return $url;
2224 + }
2225 +
2226 + global $wp_rewrite;
2227 +
2228 + if ($wp_rewrite && $wp_rewrite->using_permalinks()) {
2229 + return trailingslashit($url) . user_trailingslashit(
2230 + $wp_rewrite->pagination_base . '/' . $page,
2231 + 'paged'
2232 + );
2233 + }
2234 +
2235 + return add_query_arg('paged', $page, $url);
2236 + }
2237 +
2238 + /**
2239 + * Strip a page number from a URL, whichever form it takes.
2240 + *
2241 + * The inverse of with_pagination(). Pretty permalinks carry the page as a
2242 + * /page/N/ path segment, plain permalinks as a `paged` query arg, and a
2243 + * regex over the path alone silently left the latter in place — so
2244 + * rel="prev" on page 2 pointed at page 2 (#397 review).
2245 + *
2246 + * @since 2.0.1
2247 + *
2248 + * @param string $url URL that may carry a page number.
2249 + * @return string URL for page 1.
2250 + */
2251 + public static function without_pagination(string $url): string {
2252 + if ('' === $url) {
2253 + return $url;
2254 + }
2255 +
2256 + $url = remove_query_arg('paged', $url);
2257 +
2258 + return (string) preg_replace(
2259 + '#/' . preg_quote(self::pagination_base(), '#') . '/\d+/?$#',
2260 + '/',
2261 + $url
2262 + );
2263 + }
2264 +
2265 + /**
2266 + * The page number of the current request, archive or multi-page post.
2267 + *
2268 + * `paged` counts archive pages; `page` counts the <!--nextpage--> parts of
2269 + * a single post. They are never both set.
2270 + *
2271 + * @since 2.0.1
2272 + *
2273 + * @return int Page number, 1 when this is the first page.
2274 + */
2275 + public static function current_page_number(): int {
1556 2276 $paged = (int) get_query_var('paged');
2277 +
1557 2278 if ($paged > 1) {
1558 - global $wp_rewrite;
1559 - $canonical_url = $wp_rewrite->using_permalinks()
1560 - ? trailingslashit($canonical_url) . user_trailingslashit($wp_rewrite->pagination_base . '/' . $paged, 'paged')
1561 - : add_query_arg('paged', $paged, $canonical_url);
2279 + return $paged;
1562 2280 }
1563 2281
1564 - return $canonical_url;
2282 + $page = (int) get_query_var('page');
2283 +
2284 + return $page > 1 ? $page : 1;
1565 2285 }
1566 2286
1567 2287
1568 2288 /**
@@ -1570,12 +2290,13 @@
1570 2290 *
1571 2291 * @return bool True if has ThinkRank metadata
1572 2292 */
1573 2293 private function has_thinkrank_metadata(): bool {
1574 - if (!is_singular()) {
1575 - return false;
1576 - }
1577 -
2294 + // Populated by initialize_current_context() for singular views and for
2295 + // term archives, and left empty everywhere else — so the emptiness
2296 + // check is the whole test. The `!is_singular()` early return this
2297 + // replaced is what made every stored term title and description inert:
2298 + // the entire title/description cascade hangs off this method (#386).
1578 2299 return !empty($this->current_metadata['title']) || !empty($this->current_metadata['description']);
1579 2300 }
1580 2301
1581 2302 /**
@@ -1718,9 +2439,11 @@
1718 2439 // would leave this path open (#363).
1719 2440 if (!empty($post->post_excerpt)) {
1720 2441 $placeholders['%excerpt%'] = $post->post_excerpt;
1721 2442 } elseif (!$this->is_content_password_protected($post->ID)) {
1722 - $placeholders['%excerpt%'] = wp_trim_words(wp_strip_all_tags($post->post_content), 25, '...');
2443 + $placeholders['%excerpt%'] = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt(
2444 + \ThinkRank\SEO\Builder_Content::visible_content($post)
2445 + );
1723 2446 }
1724 2447 }
1725 2448
1726 2449 // Get author
@@ -1781,8 +2504,17 @@
1781 2504 $settings = $this->site_identity_manager->get_settings('site');
1782 2505
1783 2506 switch ($this->current_context) {
1784 2507 case 'homepage':
2508 + // detect_current_context() collapses the static posts page into
2509 + // 'homepage', so it rendered the front page's title template and
2510 + // the two pages shipped the same <title> — a duplicate title on
2511 + // the site's two most-linked URLs (#397 review). It is a page,
2512 + // and it has its own name, so it gets the page template.
2513 + if (self::is_static_posts_page()) {
2514 + return $settings['page_title'] ?? $settings['homepage_title'] ?? null;
2515 + }
2516 +
1785 2517 return $settings['homepage_title'] ?? null;
1786 2518 case 'post':
1787 2519 return $settings['post_title'] ?? null;
1788 2520 case 'page':
@@ -1813,12 +2545,18 @@
1813 2545 $settings = $this->site_identity_manager->get_settings('site');
1814 2546 $separator = $this->get_title_separator($settings['title_separator'] ?? 'pipe');
1815 2547
1816 2548 $placeholders = [
1817 - '%site_title%' => $settings['site_name'] ?? get_bloginfo('name'),
1818 - '%site_name%' => $settings['site_name'] ?? get_bloginfo('name'),
1819 - '%site_description%' => $settings['site_description'] ?? get_bloginfo('description'),
1820 - '%tagline%' => $settings['tagline'] ?? get_bloginfo('description'),
2549 + // first_non_empty(), not `??`: Site Identity persists these as ''
2550 + // rather than leaving them unset, and '' is not null — so the
2551 + // null-coalesce stopped dead on the empty string and the WordPress
2552 + // fallback was unreachable. A site with a tagline set in Settings →
2553 + // General rendered "%site_description%" as nothing (#398). This is
2554 + // the same reasoning first_non_empty()'s own docblock records.
2555 + '%site_title%' => $this->first_non_empty($settings['site_name'] ?? '', get_bloginfo('name')),
2556 + '%site_name%' => $this->first_non_empty($settings['site_name'] ?? '', get_bloginfo('name')),
2557 + '%site_description%' => $this->first_non_empty($settings['site_description'] ?? '', get_bloginfo('description')),
2558 + '%tagline%' => $this->first_non_empty($settings['tagline'] ?? '', get_bloginfo('description')),
1821 2559 '%separator%' => ' ' . $separator . ' ',
1822 2560 '%sep%' => ' ' . $separator . ' ',
1823 2561 '%date%' => gmdate('F Y'),
1824 2562 ];
@@ -1871,10 +2609,26 @@
1871 2609 $placeholders['%search_term%'] = get_search_query();
1872 2610 break;
1873 2611
1874 2612 case 'archive':
1875 - $placeholders['%archive_title%'] = get_the_archive_title();
2613 + // Stripped: get_the_archive_title() wraps its subject in a
2614 + // <span>, and this placeholder feeds the document <title> as
2615 + // well as og:title and twitter:title — a date archive rendered
2616 + // as "Month: <span>August 2026</span> | Site".
2617 + $placeholders['%archive_title%'] = self::archive_subject();
1876 2618 break;
2619 +
2620 + case 'homepage':
2621 + // The page template resolved for a static posts page needs the
2622 + // page's own name; without it %title%/%page_title% would render
2623 + // empty and collapse back to the site title.
2624 + if (self::is_static_posts_page()) {
2625 + $posts_page_title = get_the_title((int) get_option('page_for_posts'));
2626 + $placeholders['%title%'] = $posts_page_title;
2627 + $placeholders['%page_title%'] = $posts_page_title;
2628 + $placeholders['%post_title%'] = $posts_page_title;
2629 + }
2630 + break;
1877 2631 }
1878 2632
1879 2633 return $placeholders;
1880 2634 }
@@ -1879,8 +2633,19 @@
1879 2633 return $placeholders;
1880 2634 }
1881 2635
1882 2636 /**
2637 + * Whether this request is a static posts page rather than the front page.
2638 + *
2639 + * @since 2.0.1
2640 + *
2641 + * @return bool
2642 + */
2643 + private static function is_static_posts_page(): bool {
2644 + return is_home() && !is_front_page() && (int) get_option('page_for_posts') > 0;
2645 + }
2646 +
2647 + /**
1883 2648 * Process title template with placeholders
1884 2649 *
1885 2650 * @param string $template Template string
1886 2651 * @param array $placeholders Placeholder values
@@ -1991,11 +2756,17 @@
1991 2756 // gated body published its first ~25 words in the page head, and the
1992 2757 // same value is reused for og:description and twitter:description, so
1993 2758 // one unguarded read leaked through three tags (#363).
1994 2759 if (is_singular() && $this->current_post_id && !$this->is_content_password_protected()) {
1995 - $post_content = get_post_field('post_content', $this->current_post_id);
2760 + // Not the raw column: a Bricks page discards `post_content`, so
2761 + // whatever is still stored there is invisible — and this one value
2762 + // becomes the meta, og: and twitter: descriptions (#651).
2763 + $described = get_post($this->current_post_id);
2764 + $post_content = $described instanceof \WP_Post
2765 + ? \ThinkRank\SEO\Builder_Content::visible_content($described)
2766 + : get_post_field('post_content', $this->current_post_id);
1996 2767 if ($post_content) {
1997 - $excerpt = wp_trim_words(wp_strip_all_tags($post_content), 25, '...');
2768 + $excerpt = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt((string) $post_content);
1998 2769 if (!empty($excerpt)) {
1999 2770 return $excerpt;
2000 2771 }
2001 2772 }
@@ -2039,11 +2810,13 @@
2039 2810 if ($description === '') {
2040 2811 return null;
2041 2812 }
2042 2813
2043 - if (strlen($description) > 160) {
2044 - $description = wp_trim_words($description, 25, '...');
2045 - }
2814 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2815 + // CJK description tripped this limit at a third of its length, and
2816 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2817 + // English, 25 characters in Thai (#687).
2818 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2046 2819
2047 2820 return $description;
2048 2821 }
2049 2822
@@ -2090,11 +2863,13 @@
2090 2863 $description = preg_replace('/\s+/', ' ', $description);
2091 2864 $description = trim($description);
2092 2865
2093 2866 // Ensure description doesn't exceed recommended length (160 characters)
2094 - if (strlen($description) > 160) {
2095 - $description = wp_trim_words($description, 25, '...');
2096 - }
2867 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2868 + // CJK description tripped this limit at a third of its length, and
2869 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2870 + // English, 25 characters in Thai (#687).
2871 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2097 2872
2098 2873 return $description;
2099 2874 }
2100 2875
@@ -2108,8 +2883,19 @@
2108 2883 public function output_site_schema_markup(): void {
2109 2884 $has_schema_manager_output = false;
2110 2885 $has_website_schema = false;
2111 2886
2887 + // The master switch on Essential SEO -> Schema Manager. Until #461 this
2888 + // was never read here, so turning schema off left every deployed entity
2889 + // on the page. Read it once and bail before touching the graph.
2890 + if ($this->schema_manager) {
2891 + $schema_settings = $this->schema_manager->get_settings('site', null);
2892 +
2893 + if (isset($schema_settings['enabled']) && !$schema_settings['enabled']) {
2894 + return;
2895 + }
2896 + }
2897 +
2112 2898 // PRIORITY 1: Always output site-wide schemas (Organization, Website, LocalBusiness, Person)
2113 2899 if ($this->schema_manager) {
2114 2900 $site_wide_schemas = $this->schema_manager->get_deployed_schemas('site', null);
2115 2901
@@ -2150,10 +2936,19 @@
2150 2936
2151 2937 $page_specific_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
2152 2938
2153 2939 if (!empty($page_specific_schemas)) {
2154 - // Apply filter for Pro to allow multiple schemas
2155 - // In free version, it's limited to 1 schema if not filtered
2940 + // Every deployed schema is rendered, on every plan. How many a
2941 + // page carries is decided when schemas are activated in the
2942 + // editor, not trimmed here by plan (#673).
2943 +
2944 + /**
2945 + * Filter the page-specific schemas rendered on the current page.
2946 + *
2947 + * @param array $page_specific_schemas Deployed schemas keyed by schema type.
2948 + * @param string $context_type Context type (post, page, product, site).
2949 + * @param int $context_id Post ID.
2950 + */
2156 2951 $page_specific_schemas = apply_filters(
2157 2952 'thinkrank_page_schemas_to_render',
2158 2953 $page_specific_schemas,
2159 2954 $context_type,
@@ -2159,14 +2954,8 @@
2159 2954 $context_type,
2160 2955 $context_id
2161 2956 );
2162 2957
2163 - // If still multiple schemas and not Pro, limit to 1 (enforcing free limit)
2164 - $is_pro = \ThinkRank\Core\Plan_Config::is_pro();
2165 - if (!$is_pro && count($page_specific_schemas) > 2) {
2166 - $page_specific_schemas = array_slice($page_specific_schemas, 0, 2, true);
2167 - }
2168 -
2169 2958 foreach ($page_specific_schemas as $schema_type => $schema_info) {
2170 2959 Schema_Graph::instance()->add_primary($schema_info['data'], (string) $schema_type, 'schema_manager');
2171 2960 }
2172 2961 $has_schema_manager_output = true;
@@ -2230,17 +3019,41 @@
2230 3019 if (!empty($description)) {
2231 3020 $schema['description'] = $description;
2232 3021 }
2233 3022
2234 - $schema['potentialAction'] = [
2235 - '@type' => 'SearchAction',
2236 - 'target' => [
2237 - '@type' => 'EntryPoint',
2238 - 'urlTemplate' => home_url('/?s={search_term_string}'),
2239 - ],
2240 - 'query-input' => 'required name=search_term_string',
2241 - ];
3023 + // Site Identity has accepted an alternate name since the setup wizard
3024 + // shipped, and the MCP ability describes it as "published as schema
3025 + // alternateName" — but no producer ever read it, so the promise was
3026 + // false and every imported Yoast/Rank Math value sat unused (#692).
3027 + $alternate_name = \ThinkRank\SEO\Site_Identity_Manager::alternate_name_for_schema($settings['alternate_name'] ?? null);
3028 + if (null !== $alternate_name) {
3029 + $schema['alternateName'] = $alternate_name;
3030 + }
2242 3031
3032 + // The sitelinks searchbox switch was honoured only for a deployed
3033 + // WebSite row; this live fallback added potentialAction unconditionally,
3034 + // so website_enable_search = 0 still shipped the SearchAction (#688).
3035 + // Absent means not configured, which stays enabled.
3036 + $search_enabled = true;
3037 + if ($this->schema_manager) {
3038 + $schema_settings = $this->schema_manager->get_settings('site', null);
3039 +
3040 + if (array_key_exists('website_enable_search', $schema_settings)) {
3041 + $search_enabled = !empty($schema_settings['website_enable_search']);
3042 + }
3043 + }
3044 +
3045 + if ($search_enabled) {
3046 + $schema['potentialAction'] = [
3047 + '@type' => 'SearchAction',
3048 + 'target' => [
3049 + '@type' => 'EntryPoint',
3050 + 'urlTemplate' => home_url('/?s={search_term_string}'),
3051 + ],
3052 + 'query-input' => 'required name=search_term_string',
3053 + ];
3054 + }
3055 +
2243 3056 return $schema;
2244 3057 }
2245 3058
2246 3059 /**
@@ -2437,8 +3250,15 @@
2437 3250 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
2438 3251 return;
2439 3252 }
2440 3253
3254 + // A breadcrumb trail for a URL that does not exist, or for a search
3255 + // results page, describes nothing — and the plugin already emits no
3256 + // canonical on either (#471).
3257 + if (is_404() || is_search()) {
3258 + return;
3259 + }
3260 +
2441 3261 $settings = $this->site_identity_manager->get_settings('site');
2442 3262
2443 3263 // Only output if breadcrumbs are enabled
2444 3264 if (empty($settings['breadcrumbs_enabled'])) {
@@ -2444,8 +3264,22 @@
2444 3264 if (empty($settings['breadcrumbs_enabled'])) {
2445 3265 return;
2446 3266 }
2447 3267
3268 + // Schema Manager's own breadcrumb switch. Only Site Identity's
3269 + // breadcrumbs_enabled was consulted here, so enable_breadcrumbs_schema
3270 + // = 0 removed a deployed BreadcrumbList row and left this live one
3271 + // emitting the node anyway (#688). Absent means not configured, which
3272 + // stays enabled.
3273 + if ($this->schema_manager) {
3274 + $schema_settings = $this->schema_manager->get_settings('site', null);
3275 +
3276 + if (array_key_exists('enable_breadcrumbs_schema', $schema_settings)
3277 + && empty($schema_settings['enable_breadcrumbs_schema'])) {
3278 + return;
3279 + }
3280 + }
3281 +
2448 3282 $breadcrumbs = $this->generate_breadcrumbs($settings);
2449 3283
2450 3284 if (!empty($breadcrumbs['schema'])) {
2451 3285 Schema_Graph::instance()->add_supporting($breadcrumbs['schema'], 'BreadcrumbList');
@@ -2470,25 +3304,34 @@
2470 3304 *
2471 3305 * @return void
2472 3306 */
2473 3307 public function output_closing_comment(): void {
2474 - // Only output if we've output any SEO content
2475 - static $header_output = false;
2476 - if ($header_output || $this->has_seo_output()) {
3308 + // Close only what was actually opened. has_seo_output() is true on
3309 + // nearly every page, so testing it here printed a closing comment with
3310 + // no matching opener whenever the meta description was empty (search
3311 + // results, author archives without a description).
3312 + if (self::$opening_comment_output) {
2477 3313 echo "<!-- /ThinkRank SEO -->\n";
2478 3314 }
2479 3315 }
2480 3316
2481 3317 /**
2482 - * Check if any SEO content has been output
3318 + * Print the opening ThinkRank comment, once per request.
2483 3319 *
2484 - * @return bool True if SEO content was output
3320 + * Public and static so Author_Archives_Manager — which prints its own meta
3321 + * description on wp_head at priority 5 — opens the block through the same
3322 + * flag the closing comment reads.
3323 + *
3324 + * @since 2.0.1
3325 + * @return void
2485 3326 */
2486 - private function has_seo_output(): bool {
2487 - // Check if we have meta description or any other SEO data
2488 - return !empty($this->get_meta_description()) ||
2489 - $this->has_thinkrank_metadata() ||
2490 - ($this->site_identity_data && $this->site_identity_data['enabled']);
3327 + public static function note_opening_comment(): void {
3328 + if (self::$opening_comment_output) {
3329 + return;
3330 + }
3331 +
3332 + echo "<!-- Search Engine Optimization by ThinkRank - https://thinkrank.ai/ -->\n";
3333 + self::$opening_comment_output = true;
2491 3334 }
2492 3335
2493 3336 /**
2494 3337 * Display breadcrumbs HTML
@@ -2691,8 +3534,102 @@
2691 3534 * @since 1.32.0
2692 3535 *
2693 3536 * @return void
2694 3537 */
3538 + /**
3539 + * Serve a ThinkRank sitemap document for this request, when it is one.
3540 + *
3541 + * Only acts in dynamic delivery mode. In static mode a real file exists and
3542 + * the web server returns it without WordPress ever loading, so answering
3543 + * here as well would mean two sources for the same bytes.
3544 + *
3545 + * @since 2.9.0
3546 + *
3547 + * @return void
3548 + */
3549 + public function maybe_serve_sitemap(): void {
3550 + $filename = $this->requested_sitemap_filename();
3551 + if ('' === $filename) {
3552 + return;
3553 + }
3554 +
3555 + try {
3556 + // Read-only instance: passing false keeps it from registering a
3557 + // second copy of the auto-generation hooks.
3558 + $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3559 + $settings = $generator->get_settings('site');
3560 +
3561 + if (empty($settings['enabled'])) {
3562 + return;
3563 + }
3564 +
3565 + if ('dynamic' !== $generator->resolve_delivery_mode($settings)) {
3566 + return;
3567 + }
3568 +
3569 + if (!$generator->publishes_document_name($filename, $settings)) {
3570 + return;
3571 + }
3572 +
3573 + $xml = $generator->render_document($filename, $settings);
3574 + } catch (\Throwable $e) {
3575 + // A failed render must not replace the sitemap with a fatal. Leave
3576 + // the request alone so WordPress answers as it otherwise would.
3577 + return;
3578 + }
3579 +
3580 + if (!is_string($xml) || '' === trim($xml)) {
3581 + return;
3582 + }
3583 +
3584 + status_header(200);
3585 + header('Content-Type: application/xml; charset=UTF-8');
3586 + header('X-Robots-Tag: noindex, follow', true);
3587 +
3588 + // Built XML, escaped by the builders as they assemble it; escaping the
3589 + // document here would corrupt it.
3590 + echo $xml; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
3591 + exit;
3592 + }
3593 +
3594 + /**
3595 + * The sitemap file name this request is asking for, if it looks like one.
3596 + *
3597 + * Deliberately a cheap shape test. Whether the site actually publishes the
3598 + * name is settled by the caller against the generator, so that a request
3599 + * for someone else's sitemap is never answered here.
3600 + *
3601 + * @since 2.9.0
3602 + *
3603 + * @return string File name, or '' when this is not a sitemap request.
3604 + */
3605 + private function requested_sitemap_filename(): string {
3606 + if (empty($_SERVER['REQUEST_URI'])) {
3607 + return '';
3608 + }
3609 +
3610 + $path = wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), PHP_URL_PATH);
3611 + if (!is_string($path) || '' === $path) {
3612 + return '';
3613 + }
3614 +
3615 + // Strip the install's home path so subdirectory installs match too.
3616 + $home_path = (string) wp_parse_url(home_url('/'), PHP_URL_PATH);
3617 + if ('' !== $home_path && '/' !== $home_path && 0 === strpos($path, $home_path)) {
3618 + $path = substr($path, strlen($home_path));
3619 + }
3620 +
3621 + $candidate = strtolower(trim($path, '/'));
3622 +
3623 + // One path segment ending in .xml. Anything nested is not a file we
3624 + // publish to the web root.
3625 + if ('' === $candidate || strpos($candidate, '/') !== false) {
3626 + return '';
3627 + }
3628 +
3629 + return substr($candidate, -4) === '.xml' ? $candidate : '';
3630 + }
3631 +
2695 3632 public function maybe_serve_llms_txt(): void {
2696 3633 if (!$this->is_llms_txt_request()) {
2697 3634 return;
2698 3635 }
@@ -2893,11 +3830,22 @@
2893 3830 // second copy of the save_post/term auto-generation hooks.
2894 3831 $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
2895 3832 $settings = $generator->get_settings('site');
2896 3833
3834 + // "Can ThinkRank actually answer its sitemap URL right now?" In
3835 + // static mode that means the file is on disk; in dynamic mode
3836 + // maybe_serve_sitemap() answers it, so there is nothing to look
3837 + // for. Keeping the file test as the only answer would have left
3838 + // core's sitemap in place on every dynamic site, which is the
3839 + // crawl conflict this suppression exists to prevent (#752).
3840 + // The #346 behaviour is unchanged: a static site with nothing
3841 + // published still falls through to core rather than 404ing.
3842 + $can_serve = 'dynamic' === $generator->resolve_delivery_mode($settings)
3843 + || $generator->primary_sitemap_file_exists($settings);
3844 +
2897 3845 $this->thinkrank_sitemap_enabled = !empty($settings['enabled'])
2898 3846 && !$this->publishes_at_core_sitemap_url($settings)
2899 - && $generator->primary_sitemap_file_exists($settings);
3847 + && $can_serve;
2900 3848
2901 3849 if ($this->thinkrank_sitemap_enabled) {
2902 3850 $this->thinkrank_sitemap_url = $generator->get_primary_sitemap_url($settings);
2903 3851 }
@@ -2987,15 +3935,17 @@
2987 3935 if (empty($settings['enabled'])) {
2988 3936 return (string) $url;
2989 3937 }
2990 3938
3939 + $size = (int) $size;
3940 +
2991 3941 // Apple touch icon has its own dedicated setting
2992 - if ((int) $size === 180 && !empty($settings['apple_touch_icon_url'])) {
2993 - return esc_url($settings['apple_touch_icon_url']);
3942 + if ($size === 180 && !empty($settings['apple_touch_icon_url'])) {
3943 + return $this->resolve_icon_url((string) $settings['apple_touch_icon_url'], $size);
2994 3944 }
2995 3945
2996 3946 if (!empty($settings['favicon_url'])) {
2997 - return esc_url($settings['favicon_url']);
3947 + return $this->resolve_icon_url((string) $settings['favicon_url'], $size);
2998 3948 }
2999 3949
3000 3950 return (string) $url;
3001 3951 }
@@ -3000,8 +3950,178 @@
3000 3950 return (string) $url;
3001 3951 }
3002 3952
3003 3953 /**
3954 + * Whether breadcrumb labels should prefer the SEO title.
3955 + *
3956 + * Off unless the site turns it on, so updating the plugin never rewrites an
3957 + * existing trail.
3958 + *
3959 + * @since 2.3.1
3960 + *
3961 + * @param array $settings Breadcrumb settings.
3962 + * @return bool
3963 + */
3964 + private function breadcrumbs_use_seo_title(array $settings): bool {
3965 + return !empty($settings['breadcrumb_use_seo_title']);
3966 + }
3967 +
3968 + /**
3969 + * Label for a post in the breadcrumb trail.
3970 + *
3971 + * With the toggle on, the post's own SEO title wins — the same
3972 + * `_thinkrank_seo_title` value (variable tags resolved) the document title
3973 + * uses — so the trail under a search snippet reads the same as the snippet
3974 + * itself. Anything empty falls back to the raw post title; the global title
3975 + * pattern is deliberately NOT part of the chain, since resolving it would
3976 + * append the site name to every crumb.
3977 + *
3978 + * @since 2.3.1
3979 + *
3980 + * @param int $post_id Post ID.
3981 + * @param array $settings Breadcrumb settings.
3982 + * @return string Breadcrumb label.
3983 + */
3984 + private function get_breadcrumb_post_title(int $post_id, array $settings): string {
3985 + $title = (string) get_the_title($post_id);
3986 +
3987 + if (!$this->breadcrumbs_use_seo_title($settings)) {
3988 + return $title;
3989 + }
3990 +
3991 + $seo_title = trim((string) get_post_meta($post_id, '_thinkrank_seo_title', true));
3992 +
3993 + if ('' === $seo_title) {
3994 + return $title;
3995 + }
3996 +
3997 + $resolved = trim(\ThinkRank\SEO\Pattern_Resolver::resolve_value($seo_title, $post_id));
3998 +
3999 + return '' !== $resolved ? $resolved : $title;
4000 + }
4001 +
4002 + /**
4003 + * Label for a term in the breadcrumb trail.
4004 + *
4005 + * Term counterpart to {@see self::get_breadcrumb_post_title()}, resolving
4006 + * the term's `_thinkrank_seo_title` against its own values.
4007 + *
4008 + * @since 2.3.1
4009 + *
4010 + * @param object $term Term object.
4011 + * @param array $settings Breadcrumb settings.
4012 + * @return string Breadcrumb label.
4013 + */
4014 + private function get_breadcrumb_term_title($term, array $settings): string {
4015 + $name = (string) ($term->name ?? '');
4016 +
4017 + if (!$this->breadcrumbs_use_seo_title($settings) || empty($term->term_id)) {
4018 + return $name;
4019 + }
4020 +
4021 + $seo_title = trim((string) get_term_meta((int) $term->term_id, '_thinkrank_seo_title', true));
4022 +
4023 + if ('' === $seo_title) {
4024 + return $name;
4025 + }
4026 +
4027 + $resolved = trim(\ThinkRank\SEO\Pattern_Resolver::resolve_term_value($seo_title, (int) $term->term_id));
4028 +
4029 + return '' !== $resolved ? $resolved : $name;
4030 + }
4031 +
4032 + /**
4033 + * Resolve a configured icon URL to the derivative that fits $size.
4034 + *
4035 + * wp_site_icon() calls get_site_icon_url() four times — 32, 192, 180 and
4036 + * 270 — and pairs the first two with a hardcoded sizes="" attribute. This
4037 + * filter used to answer all four with the same configured URL, so one
4038 + * upload was declared as every size at once: a 1536x1536 original served
4039 + * to paint a 32px tab icon, under a sizes="32x32" label that was simply
4040 + * untrue (#571).
4041 + *
4042 + * Resolution mirrors core's own get_site_icon_url(), including the
4043 + * >= 512 -> 'full' branch, so ThinkRank's override and the core pipeline
4044 + * pick the same file for the same request.
4045 + *
4046 + * An unresolvable URL (one hosted off-site) is returned unchanged. Nothing
4047 + * is knowable about its dimensions, and suppressing it instead would leave
4048 + * the page with no rel="icon" at all — a worse outcome than an approximate
4049 + * size hint.
4050 + *
4051 + * @param string $configured Configured icon URL.
4052 + * @param int $size Icon size core is asking for.
4053 + * @return string Icon URL for that size.
4054 + */
4055 + private function resolve_icon_url(string $configured, int $size): string {
4056 + $cache_key = md5($configured) . ':' . $size;
4057 + $cached = $this->icon_urls();
4058 +
4059 + if (isset($cached[$cache_key])) {
4060 + return $cached[$cache_key];
4061 + }
4062 +
4063 + $attachment_id = \ThinkRank\SEO\Site_Identity_Manager::icon_attachment_id($configured);
4064 +
4065 + if (!$attachment_id) {
4066 + $resolved = esc_url($configured);
4067 + } else {
4068 + // Mirrors core: at 512 and above the original is what is wanted, and
4069 + // asking for an intermediate size that large would only fall back to it.
4070 + $size_data = $size >= 512 ? 'full' : [$size, $size];
4071 + $url = wp_get_attachment_image_url($attachment_id, $size_data);
4072 + $resolved = $url ? esc_url($url) : esc_url($configured);
4073 + }
4074 +
4075 + $this->icon_urls[$cache_key] = $resolved;
4076 +
4077 + if (!$this->icon_urls_dirty) {
4078 + $this->icon_urls_dirty = true;
4079 + // Written once, after the response is assembled, rather than once
4080 + // per size: wp_site_icon() resolves four in a row.
4081 + add_action('shutdown', [$this, 'persist_icon_urls'], 5);
4082 + }
4083 +
4084 + return $resolved;
4085 + }
4086 +
4087 + /**
4088 + * The resolved-icon-URL map, loaded from its transient on first use.
4089 + *
4090 + * @return array<string, string>
4091 + */
4092 + private function icon_urls(): array {
4093 + if ($this->icon_urls === null) {
4094 + $stored = get_transient(\ThinkRank\SEO\Site_Identity_Manager::ICON_URL_TRANSIENT);
4095 + $this->icon_urls = is_array($stored) ? $stored : [];
4096 + }
4097 +
4098 + return $this->icon_urls;
4099 + }
4100 +
4101 + /**
4102 + * Persist newly resolved icon URLs.
4103 + *
4104 + * Public because it runs on `shutdown`. Invalidated wholesale whenever the
4105 + * site identity settings are saved, which is the only moment the icon
4106 + * choice — or the derivatives behind it — can change.
4107 + *
4108 + * @return void
4109 + */
4110 + public function persist_icon_urls(): void {
4111 + if (!$this->icon_urls_dirty || !is_array($this->icon_urls)) {
4112 + return;
4113 + }
4114 +
4115 + $this->icon_urls_dirty = false;
4116 + set_transient(
4117 + \ThinkRank\SEO\Site_Identity_Manager::ICON_URL_TRANSIENT,
4118 + $this->icon_urls,
4119 + DAY_IN_SECONDS
4120 + );
4121 + }
4122 +
4123 + /**
3004 4124 * Get breadcrumb items for current page
3005 4125 *
3006 4126 * @param array $settings Breadcrumb settings
3007 4127 * @return array Breadcrumb items
@@ -3029,9 +4149,9 @@
3029 4149 $categories = get_the_category($current_post_id);
3030 4150 if (!empty($categories)) {
3031 4151 $category = $categories[0];
3032 4152 $items[] = [
3033 - 'title' => $category->name,
4153 + 'title' => $this->get_breadcrumb_term_title($category, $settings),
3034 4154 'url' => get_category_link($category->term_id),
3035 4155 'position' => $position++
3036 4156 ];
3037 4157 }
@@ -3036,12 +4156,18 @@
3036 4156 ];
3037 4157 }
3038 4158 }
3039 4159
3040 - // Add current post
3041 - if (empty($settings['show_current_page']) || $settings['show_current_page']) {
4160 + // Add current post. `empty($x) || $x` is true for every possible
4161 + // value — an unset key, false, 0, '' and any truthy value alike —
4162 + // so the setting had no effect on the rendered breadcrumb or on
4163 + // the BreadcrumbList JSON-LD, while the admin preview honoured it
4164 + // and disagreed with live output (#398). Site_Identity_Manager
4165 + // already had the correct form: default to on, respect an
4166 + // explicit off.
4167 + if ($settings['show_current_page'] ?? true) {
3042 4168 $items[] = [
3043 - 'title' => get_the_title($current_post_id),
4169 + 'title' => $this->get_breadcrumb_post_title($current_post_id, $settings),
3044 4170 'url' => get_permalink($current_post_id),
3045 4171 'position' => $position,
3046 4172 'current' => true
3047 4173 ];
@@ -3058,9 +4184,9 @@
3058 4184 while ($parent_id) {
3059 4185 $parent = get_post($parent_id);
3060 4186 if ($parent) {
3061 4187 $parents[] = [
3062 - 'title' => get_the_title($parent->ID),
4188 + 'title' => $this->get_breadcrumb_post_title($parent->ID, $settings),
3063 4189 'url' => get_permalink($parent->ID),
3064 4190 'position' => 0 // Will be set later
3065 4191 ];
3066 4192 $parent_id = $parent->post_parent;
@@ -3078,11 +4204,11 @@
3078 4204 $items[] = $parent;
3079 4205 }
3080 4206
3081 4207 // Add current page
3082 - if (empty($settings['show_current_page']) || $settings['show_current_page']) {
4208 + if ($settings['show_current_page'] ?? true) {
3083 4209 $items[] = [
3084 - 'title' => get_the_title($current_post_id),
4210 + 'title' => $this->get_breadcrumb_post_title($current_post_id, $settings),
3085 4211 'url' => get_permalink($current_post_id),
3086 4212 'position' => $position,
3087 4213 'current' => true
3088 4214 ];
@@ -3098,9 +4224,9 @@
3098 4224 while ($parent_id) {
3099 4225 $parent = get_category($parent_id);
3100 4226 if ($parent && !is_wp_error($parent)) {
3101 4227 $parents[] = [
3102 - 'title' => $parent->name,
4228 + 'title' => $this->get_breadcrumb_term_title($parent, $settings),
3103 4229 'url' => get_category_link($parent->term_id),
3104 4230 'position' => 0 // Will be set later
3105 4231 ];
3106 4232 $parent_id = $parent->parent;
@@ -3118,11 +4244,11 @@
3118 4244 $items[] = $parent;
3119 4245 }
3120 4246
3121 4247 // Add current category
3122 - if (empty($settings['show_current_page']) || $settings['show_current_page']) {
4248 + if ($settings['show_current_page'] ?? true) {
3123 4249 $items[] = [
3124 - 'title' => $category->name,
4250 + 'title' => $this->get_breadcrumb_term_title($category, $settings),
3125 4251 'url' => get_category_link($category->term_id),
3126 4252 'position' => $position,
3127 4253 'current' => true
3128 4254 ];