PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
← All changes | includes/frontend/class-seo-manager.php +1258 -108 2.0.0 → 2.10.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);
@@ -292,39 +377,73 @@
292 377 }
293 378
294 379 // Initialize Global SEO Schema Output and store reference
295 380 $this->global_seo_schema = new Global_SEO_Schema_Output();
381 + // Let schema reuse the description this class already resolves, so the
382 + // JSON-LD and the meta/og/twitter tags cannot disagree about what the
383 + // page is (#766). Passed as a callback rather than a value: schema is
384 + // built during wp_head, by which point the request context this
385 + // resolution depends on is set, and it must not be captured earlier.
386 + $this->global_seo_schema->set_description_resolver(
387 + fn (): string => (string) $this->get_meta_description()
388 + );
296 389 $this->global_seo_schema->init();
297 390 }
298 391
299 392 /**
300 - * Initialize Google Analytics Tracking Manager
393 + * Initialize Image SEO Manager
301 394 *
302 395 * @return void
303 396 */
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';
397 + private function initialize_image_seo_manager(): void {
398 + if (!class_exists('ThinkRank\\SEO\\Image_SEO_Manager')) {
399 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-image-seo-manager.php';
307 400 }
308 401
309 - // Initialize Google Analytics Tracking Manager
310 - new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
402 + $this->image_seo_manager = new \ThinkRank\SEO\Image_SEO_Manager();
311 403 }
312 404
313 405 /**
314 - * Initialize Image SEO Manager
406 + * Initialize External Links Manager
315 407 *
408 + * @since 2.5.0
316 409 * @return void
317 410 */
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';
411 + private function initialize_external_links_manager(): void {
412 + if (!class_exists('ThinkRank\\SEO\\External_Links_Manager')) {
413 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-external-links-manager.php';
321 414 }
322 415
323 - $this->image_seo_manager = new \ThinkRank\SEO\Image_SEO_Manager();
416 + $this->external_links_manager = new \ThinkRank\SEO\External_Links_Manager();
324 417 }
325 418
326 419 /**
420 + * Filter rendered content to annotate external links
421 + *
422 + * @since 2.5.0
423 + * @param mixed $content Content to filter; passed through untouched when
424 + * it is not a string.
425 + * @return mixed Filtered content.
426 + */
427 + public function filter_external_links($content) {
428 + // No return type: a filter value another plugin hands through as null
429 + // or an object belongs to whoever set it, and coercing it to '' would
430 + // silently drop their content on the floor.
431 + if (!is_string($content) || $content === '' || !$this->external_links_manager) {
432 + return $content;
433 + }
434 +
435 + // Feeds carry the same markup to a reader we do not control; leave
436 + // them as authored rather than annotating for a context that has no
437 + // browser tab to open.
438 + if (is_feed()) {
439 + return $content;
440 + }
441 +
442 + return $this->external_links_manager->process_content($content);
443 + }
444 +
445 + /**
327 446 * Filter content to inject image SEO attributes
328 447 *
329 448 * @since 1.0.0
330 449 * @param string $content Content to filter
@@ -376,18 +495,74 @@
376 495 $this->current_metadata = $this->get_post_seo_metadata($post_id);
377 496 }
378 497 }
379 498
499 + // Term archives. Category, tag and custom-taxonomy pages store their SEO
500 + // title and description as term meta — written by the term UI, by the
501 + // abilities API and by the Yoast/RankMath/AIOSEO/SEOPress importer — but
502 + // nothing here ever read them, so the whole title/description cascade
503 + // fell through to the theme default and no description tag was printed
504 + // at all. Term robots was fixed for the same reason in 1.31.0 (#290);
505 + // this is the title and description half (#386).
506 + if (is_category() || is_tag() || is_tax()) {
507 + $queried = get_queried_object();
508 + if ($queried instanceof \WP_Term) {
509 + $this->current_term_id = $queried->term_id;
510 + $this->current_metadata = $this->get_term_seo_metadata($queried->term_id);
511 + }
512 + }
513 +
380 514 // Load site identity data
381 515 $this->load_site_identity_data();
382 516 }
383 517
384 518 /**
519 + * Drop the request's post identity once it has become a 404.
520 + *
521 + * `initialize_current_context()` runs on `wp`, but a request can be turned
522 + * into a 404 after that: `set_404()` on `template_redirect` is the ordinary
523 + * way to refuse a URL that did resolve to a real post, and both core and
524 + * plugins do it — ThinkRank Pro's Markdown for AI refuses an ineligible
525 + * `.md` URL that way. The snapshot still said `post`/`page` and still held
526 + * the post id and its metadata, so the error page shipped that post's meta
527 + * description, focus keywords and — where the social emitters got that far
528 + * — its og:description and twitter:description, all of which a request that
529 + * was a 404 from the start never prints (#655).
530 + *
531 + * Clearing the snapshot rather than special-casing each emitter is what
532 + * makes every consumer agree, including the ones that read
533 + * `$current_metadata` without ever asking what the context is.
534 + *
535 + * @since 2.3.1
536 + *
537 + * @return void
538 + */
539 + public function recheck_404_context(): void {
540 + if (!is_404() || '404' === $this->current_context) {
541 + return;
542 + }
543 +
544 + $this->current_context = '404';
545 + $this->current_post_id = null;
546 + $this->current_term_id = null;
547 + $this->current_metadata = [];
548 + }
549 +
550 + /**
385 551 * Detect current page context
386 552 *
387 553 * @return string Current context type
388 554 */
389 555 private function detect_current_context(): string {
556 + // 404 first: a not-found request matches none of the branches below and
557 + // used to fall through to 'site', which handed crawlers the homepage's
558 + // social identity for an error page. It gets its own context so the
559 + // social layer can skip it, matching get_non_singular_canonical_url(),
560 + // which already suppresses the canonical for 404 and search.
561 + if (is_404()) {
562 + return '404';
563 + }
564 +
390 565 if (is_home() || is_front_page()) {
391 566 return 'homepage';
392 567 } elseif (is_single()) {
393 568 return 'post';
@@ -443,8 +618,37 @@
443 618 ];
444 619 }
445 620
446 621 /**
622 + * Get SEO metadata for a term.
623 + *
624 + * Mirrors get_post_seo_metadata(): the stored values may carry variable
625 + * tags, so they are resolved against the term's own values. Focus keyword
626 + * and score have no term equivalent on the frontend and stay empty.
627 + *
628 + * @since 2.0.1
629 + *
630 + * @param int $term_id Term ID.
631 + * @return array SEO metadata.
632 + */
633 + private function get_term_seo_metadata(int $term_id): array {
634 + $title = get_term_meta($term_id, '_thinkrank_seo_title', true);
635 + $description = get_term_meta($term_id, '_thinkrank_meta_description', true);
636 +
637 + return [
638 + 'title' => $title
639 + ? \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) $title, $term_id)
640 + : '',
641 + 'description' => $description
642 + ? \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) $description, $term_id)
643 + : '',
644 + 'focus_keyword' => '',
645 + 'focus_keywords' => [],
646 + 'seo_score' => '',
647 + ];
648 + }
649 +
650 + /**
447 651 * Resolve the effective SEO title for the current request.
448 652 *
449 653 * Same priority chain as override_document_title() — post-specific
450 654 * ThinkRank metadata (resolved _thinkrank_seo_title) > Global SEO
@@ -469,17 +673,23 @@
469 673 * @param string $title Original title
470 674 * @return string Modified title
471 675 */
472 676 public function override_document_title($title): string {
677 + // A content type with metas switched off keeps whatever title the theme
678 + // and WordPress produce (#660).
679 + if (!$this->metas_enabled()) {
680 + return $title;
681 + }
682 +
473 683 // First priority: Post-specific ThinkRank metadata
474 684 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
475 - return $this->current_metadata['title'];
685 + return self::with_page_suffix($this->current_metadata['title']);
476 686 }
477 687
478 688 // Second priority: Global SEO templates, Third priority: Site Identity templates
479 689 $generated_title = $this->generate_context_title();
480 690 if ($generated_title) {
481 - return $generated_title;
691 + return self::with_page_suffix($generated_title);
482 692 }
483 693
484 694 return $title;
485 695 }
@@ -484,8 +694,117 @@
484 694 return $title;
485 695 }
486 696
487 697 /**
698 + * Append a page indicator to a title on page 2 and beyond.
699 + *
700 + * This filter short-circuits pre_get_document_title at priority 1, which
701 + * drops the " – Page 2" core would otherwise add — so every page of an
702 + * archive, and every part of a multi-page post, shared one <title> (#397).
703 + * The templates have no %page% token, so the suffix is added here rather
704 + * than asking every site to edit its title format.
705 + *
706 + * @since 2.0.1
707 + *
708 + * @param string $title Resolved title.
709 + * @return string Title with the page indicator, when there is one.
710 + */
711 + /**
712 + * The archive's subject, without the label WordPress prefixes it with.
713 + *
714 + * `get_the_archive_title()` returns "Month: September 2026", "Archives:
715 + * Recipes", "Category: Uncategorized" — the label is core's, aimed at an
716 + * archive heading on the page, and it reads badly in a browser tab, an
717 + * og:title or a search result. Category, tag and author contexts already
718 + * avoid it by using the raw name; the generic archive context did not, so
719 + * date, custom-post-type and custom-taxonomy archives carried it (#640).
720 + *
721 + * Removed through core's own `get_the_archive_title_prefix` filter rather
722 + * than by matching the prefix text, because that text is translated and
723 + * differs per archive type — a string comparison would work in English and
724 + * silently stop working everywhere else.
725 + *
726 + * A site that wants a prefix can put one in its title template, where it is
727 + * visible and editable, instead of inheriting one it cannot see.
728 + *
729 + * @since 2.7.0
730 + *
731 + * @return string Archive subject, with markup and the core prefix removed.
732 + */
733 + private static function archive_subject(): string {
734 + $drop_prefix = static function (): string {
735 + return '';
736 + };
737 +
738 + add_filter('get_the_archive_title_prefix', $drop_prefix, 99);
739 +
740 + $title = (string) get_the_archive_title();
741 +
742 + remove_filter('get_the_archive_title_prefix', $drop_prefix, 99);
743 +
744 + // The <span> core wraps the subject in survives the prefix filter.
745 + return trim(wp_strip_all_tags($title));
746 + }
747 +
748 + /**
749 + * Remove HTML from a title that is about to be emitted.
750 + *
751 + * A title carrying markup is broken twice over, in two different ways, and
752 + * both were reaching real pages: inside `<title>` the tags render literally,
753 + * because that element is RCDATA and never parses them; inside `og:title`
754 + * and `twitter:title` they are attribute-escaped, so the reader sees
755 + * `&lt;em&gt;` as visible text (#640).
756 + *
757 + * Applied at the point of emission rather than at each source, so it covers
758 + * every branch that can produce a title — post meta, Global SEO templates,
759 + * Site Identity templates — without each having to remember.
760 + *
761 + * Unconditional rather than a setting: there is no title for which markup is
762 + * the correct output. The filter is the escape hatch for anyone who
763 + * disagrees, and lets a site keep entities it deliberately encoded.
764 + *
765 + * @since 2.7.0
766 + *
767 + * @param string $title Title about to be emitted.
768 + * @return string Title with any markup removed.
769 + */
770 + public static function strip_title_tags(string $title): string {
771 + /**
772 + * Filter whether HTML is stripped from generated titles.
773 + *
774 + * @since 2.7.0
775 + *
776 + * @param bool $strip Whether to strip. Default true.
777 + * @param string $title The title being emitted.
778 + */
779 + if (!apply_filters('thinkrank_strip_title_tags', true, $title)) {
780 + return $title;
781 + }
782 +
783 + return trim(wp_strip_all_tags($title));
784 + }
785 +
786 + public static function with_page_suffix(string $title): string {
787 + $title = self::strip_title_tags($title);
788 +
789 + $page = self::current_page_number();
790 +
791 + if ($page <= 1 || '' === $title) {
792 + return $title;
793 + }
794 +
795 + $separator = class_exists('\ThinkRank\SEO\Site_Identity_Manager')
796 + ? \ThinkRank\SEO\Site_Identity_Manager::get_active_separator_symbol()
797 + : '|';
798 +
799 + return $title . ' ' . $separator . ' ' . sprintf(
800 + /* translators: %d: page number. */
801 + __('Page %d', 'thinkrank'),
802 + $page
803 + );
804 + }
805 +
806 + /**
488 807 * Override WordPress wp_title (HIGH PRIORITY)
489 808 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates
490 809 *
491 810 * @param string $title Original title
@@ -492,18 +811,24 @@
492 811 * @param string $sep Title separator
493 812 * @return string Modified title
494 813 */
495 814 public function override_wp_title(string $title, string $sep = ''): string {
815 + if (!$this->metas_enabled()) {
816 + return $title;
817 + }
818 +
496 819 // First priority: Post-specific ThinkRank metadata
497 820 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
498 821 $site_name = get_bloginfo('name');
499 - return $this->current_metadata['title'] . ($sep ? " $sep " : ' | ') . $site_name;
822 + return self::with_page_suffix(
823 + $this->current_metadata['title'] . ($sep ? " $sep " : ' | ') . $site_name
824 + );
500 825 }
501 826
502 827 // Second priority: Global SEO templates, Third priority: Site Identity templates
503 828 $generated_title = $this->generate_context_title();
504 829 if ($generated_title) {
505 - return $generated_title;
830 + return self::with_page_suffix($generated_title);
506 831 }
507 832
508 833 return $title;
509 834 }
@@ -508,8 +833,25 @@
508 833 return $title;
509 834 }
510 835
511 836 /**
837 + * Whether ThinkRank owns the title and meta description for this request.
838 + *
839 + * Metas are on site-wide by default; the per-content-type matrix can switch
840 + * them off for one content type, in which case ThinkRank stops overriding
841 + * the document title and prints no meta description (#660).
842 + *
843 + * @since 2.5.0
844 + * @return bool
845 + */
846 + private function metas_enabled(): bool {
847 + return \ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
848 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_META,
849 + true
850 + );
851 + }
852 +
853 + /**
512 854 * Output meta description (HIGH PRIORITY)
513 855 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates > WordPress defaults
514 856 *
515 857 * Author archives are skipped entirely: Author_Archives_Manager owns that
@@ -524,22 +866,24 @@
524 866 if (is_author()) {
525 867 return;
526 868 }
527 869
870 + if (!$this->metas_enabled()) {
871 + return;
872 + }
873 +
528 874 $description = $this->get_meta_description();
529 875
530 876 if ($description) {
531 877 // 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 - }
878 + self::note_opening_comment();
537 879
538 880 // Ensure description is within optimal length (150-160 characters)
539 - if (strlen($description) > 160) {
540 - $description = wp_trim_words($description, 25, '...');
541 - }
881 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
882 + // CJK description tripped this limit at a third of its length, and
883 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
884 + // English, 25 characters in Thai (#687).
885 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
542 886
543 887 echo "<!-- ThinkRank SEO Meta Description -->\n";
544 888 echo '<meta name="description" content="' . esc_attr($description) . '" />' . "\n";
545 889 echo "<!-- /ThinkRank SEO Meta Description -->\n";
@@ -580,13 +924,41 @@
580 924
581 925 // Output generator meta tag
582 926 echo '<meta name="generator" content="ThinkRank ' . esc_attr(THINKRANK_VERSION) . '" />' . "\n";
583 927
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";
928 + // No viewport tag here. The viewport is the theme's responsibility and
929 + // every modern theme ships one, so emitting our own only ever produced a
930 + // second <meta name="viewport"> in the document. The old guard could not
931 + // prevent that either: has_action() returns the registered priority
932 + // (truthy), so its first operand was always false, and !wp_is_mobile() is
933 + // true for every desktop request.
934 + echo "<!-- /ThinkRank SEO Meta Tags -->\n";
935 + }
936 +
937 + /**
938 + * Build the basic robots directive list from a set of robots flags.
939 + *
940 + * Shared by the search/404 branch of get_robots_meta_content() so those
941 + * pages resolve their directives through the same rules as everything else
942 + * rather than a hardcoded literal.
943 + *
944 + * @since 2.5.0
945 + * @param array $settings Robots flags (index/noindex/nofollow/...).
946 + * @return string[] Directives.
947 + */
948 + private static function build_robots_directives(array $settings): array {
949 + $robots = [];
950 +
951 + $robots[] = !empty($settings['noindex']) ? 'noindex' : 'index';
952 + $robots[] = !empty($settings['nofollow']) ? 'nofollow' : 'follow';
953 +
954 + foreach (['noarchive', 'noimageindex', 'nosnippet'] as $directive) {
955 + if (!empty($settings[$directive])) {
956 + $robots[] = $directive;
957 + }
587 958 }
588 - echo "<!-- /ThinkRank SEO Meta Tags -->\n";
959 +
960 + return $robots;
589 961 }
590 962
591 963 /**
592 964 * Get robots meta content based on context and settings
@@ -595,13 +967,22 @@
595 967 */
596 968 private function get_robots_meta_content(): string {
597 969 $robots = [];
598 970
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.
971 + // 404 and search results are noindex/follow by default — the behaviour
972 + // that used to be hardcoded here. It is now settings-driven (#660): the
973 + // Content Type Matrix can give either its own robots directives, and an
974 + // install that never touched them resolves to exactly the old pair.
602 975 if (is_404() || is_search()) {
603 - $robots = apply_filters('thinkrank_robots_meta', ['noindex', 'follow']);
976 + $entity = is_404()
977 + ? \ThinkRank\SEO\Content_Type_Settings::ENTITY_404
978 + : \ThinkRank\SEO\Content_Type_Settings::ENTITY_SEARCH;
979 +
980 + $robots = self::build_robots_directives(
981 + \ThinkRank\SEO\Content_Type_Settings::resolve_robots_meta($entity)
982 + );
983 +
984 + $robots = apply_filters('thinkrank_robots_meta', $robots);
604 985 return implode(', ', array_unique($robots));
605 986 }
606 987
607 988 // 1. Get global robot meta settings (Base)
@@ -630,8 +1011,24 @@
630 1011 $current_settings = array_merge($current_settings, $global_seo_settings[$post_type]['robots_meta']);
631 1012 }
632 1013 }
633 1014
1015 + // 2b. Apply the per-entity directives for the non-singular content
1016 + // types the matrix covers — taxonomy archives plus author and date
1017 + // archives. Terms keep their own per-term override, applied further
1018 + // down so it still wins over the taxonomy-wide value (#660).
1019 + if (!is_singular()) {
1020 + $entity_key = \ThinkRank\SEO\Content_Type_Settings::current_entity_key();
1021 +
1022 + if ($entity_key !== null) {
1023 + $entity_settings = \ThinkRank\SEO\Content_Type_Settings::get_entity_settings($entity_key);
1024 +
1025 + if (!empty($entity_settings['robots_meta_enabled']) && is_array($entity_settings['robots_meta'] ?? null)) {
1026 + $current_settings = array_merge($current_settings, $entity_settings['robots_meta']);
1027 + }
1028 + }
1029 + }
1030 +
634 1031 // Determine Index/Noindex based on merged settings
635 1032 // Priority: if noindex is true, it overrides index
636 1033 if (!empty($current_settings['noindex'])) {
637 1034 $robots[] = 'noindex';
@@ -695,12 +1092,30 @@
695 1092 $robots = $this->apply_post_robots_override(get_the_ID(), $robots, $current_settings);
696 1093 }
697 1094
698 1095 // Check for archive pages (search is handled by the early return above)
699 - if (is_archive()) {
1096 + //
1097 + // is_home() is deliberately included: the blog listing is not an
1098 + // is_archive(), so page 2 of a term archive was noindex while page 2 of
1099 + // the blog listing was index — the same kind of page, treated two
1100 + // different ways, on the same site (#397).
1101 + if (is_archive() || is_home()) {
700 1102 // Allow indexing of category/tag archives but be more conservative
701 1103 if (is_paged()) {
702 - $robots = ['noindex', 'follow'];
1104 + /**
1105 + * Filter whether a paginated archive is set noindex.
1106 + *
1107 + * Rank Math and Yoast now index paginated archives with a
1108 + * self-referential canonical by default, so a site that wants
1109 + * that can have it without patching.
1110 + *
1111 + * @since 2.0.1
1112 + *
1113 + * @param bool $noindex Whether to noindex this paginated page.
1114 + */
1115 + if (apply_filters('thinkrank_noindex_paged_archives', true)) {
1116 + $robots = ['noindex', 'follow'];
1117 + }
703 1118 }
704 1119
705 1120 // Honor the global date-archive noindex toggle (written by the
706 1121 // Rank Math/Yoast settings importer). Author archives are handled
@@ -980,9 +1395,9 @@
980 1395 *
981 1396 * @param array $og_tags Open Graph tags array
982 1397 * @return void
983 1398 */
984 - private function output_social_og_tags(array $og_tags): void {
1399 + private function output_social_og_tags(array $og_tags, array $extra_images = []): void {
985 1400 // Honor the thinkrank_og_type filter here too — this "Enhanced" path is
986 1401 // the active OG emitter, so add-ons (e.g. Pro's WooCommerce module which
987 1402 // sets 'product' on product pages) must be applied to it, not only to
988 1403 // output_open_graph_tags().
@@ -1026,12 +1441,62 @@
1026 1441 echo '<meta property="' . esc_attr($property) . '" content="' . $this->esc_meta_value($property, $content) . '" />' . "\n";
1027 1442 }
1028 1443 }
1029 1444
1445 + // Alternatives, after the primary and everything belonging to it.
1446 + // Order is the whole point: a consumer reads og:image tags in document
1447 + // order and treats the first as primary, and a structured property
1448 + // attaches to the most recently declared image — so each alternative's
1449 + // companions have to follow its own URL, not be grouped at the end.
1450 + self::output_extra_og_images($extra_images);
1451 +
1030 1452 echo "<!-- /ThinkRank SEO Open Graph Tags -->\n";
1031 1453 }
1032 1454
1033 1455 /**
1456 + * Emit the secondary og:image tags a page offers.
1457 + *
1458 + * Shared by the enhanced and basic emitters so both describe an
1459 + * alternative image the same way (#636).
1460 + *
1461 + * @since 2.7.0
1462 + *
1463 + * @param array $images Each with url, and width/height/type/alt where known.
1464 + * @return void
1465 + */
1466 + private static function output_extra_og_images(array $images): void {
1467 + foreach ($images as $image) {
1468 + $url = isset($image['url']) ? (string) $image['url'] : '';
1469 +
1470 + if ('' === $url) {
1471 + continue;
1472 + }
1473 +
1474 + echo '<meta property="og:image" content="' . esc_url($url) . '" />' . "\n";
1475 +
1476 + if (strpos($url, 'https://') === 0) {
1477 + echo '<meta property="og:image:secure_url" content="' . esc_url($url) . '" />' . "\n";
1478 + }
1479 +
1480 + // Only what is actually known: a dimension guessed for a remote
1481 + // image is a number a consumer lays a card out with before it has
1482 + // fetched the file.
1483 + if (!empty($image['width']) && !empty($image['height'])) {
1484 + echo '<meta property="og:image:width" content="' . esc_attr((string) $image['width']) . '" />' . "\n";
1485 + echo '<meta property="og:image:height" content="' . esc_attr((string) $image['height']) . '" />' . "\n";
1486 + }
1487 +
1488 + if (!empty($image['type'])) {
1489 + echo '<meta property="og:image:type" content="' . esc_attr((string) $image['type']) . '" />' . "\n";
1490 + }
1491 +
1492 + if (!empty($image['alt'])) {
1493 + echo '<meta property="og:image:alt" content="' . esc_attr((string) $image['alt']) . '" />' . "\n";
1494 + }
1495 + }
1496 + }
1497 +
1498 + /**
1034 1499 * Output social media Twitter Card tags from Social Meta Manager
1035 1500 *
1036 1501 * @param array $twitter_tags Twitter Card tags array
1037 1502 * @return void
@@ -1096,8 +1561,16 @@
1096 1561 *
1097 1562 * @return void
1098 1563 */
1099 1564 public function output_platform_meta_tags(): void {
1565 + // Same reasoning as the Open Graph and Twitter emitters: an error page
1566 + // has no shareable identity, and passing '404' through as a social
1567 + // context asks the manager for settings that describe a page which does
1568 + // not exist. Guarding all three keeps them from disagreeing.
1569 + if ($this->current_context === '404') {
1570 + return;
1571 + }
1572 +
1100 1573 // Try Social Meta Manager for platform tags
1101 1574 if ($this->social_manager) {
1102 1575 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1103 1576 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
@@ -1149,8 +1622,23 @@
1149 1622 *
1150 1623 * @return void
1151 1624 */
1152 1625 public function output_open_graph_tags(): void {
1626 + // Per-content-type Open Graph switch. 'inherit' (the default) keeps the
1627 + // site-wide Social Media setting, which the emitters below read (#660).
1628 + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
1629 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_OPEN_GRAPH,
1630 + true
1631 + )) {
1632 + return;
1633 + }
1634 +
1635 + // An error page has no shareable identity. Emitting Open Graph here
1636 + // advertised the homepage as the og:url of a URL that does not exist.
1637 + if ($this->current_context === '404') {
1638 + return;
1639 + }
1640 +
1153 1641 // Priority 1: Try Social Meta Manager (Social Media tab settings)
1154 1642 if ($this->social_manager) {
1155 1643 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1156 1644 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
@@ -1171,9 +1659,12 @@
1171 1659 // The Social Meta Manager ran, so it owns Open Graph output. If OG is
1172 1660 // toggled off, emit nothing — do NOT fall through to the basic
1173 1661 // emitter (which would re-add a full OG block despite the toggle).
1174 1662 if (!empty($social_data['og_enabled'])) {
1175 - $this->output_social_og_tags($social_data['og_tags']);
1663 + $this->output_social_og_tags(
1664 + $social_data['og_tags'],
1665 + $social_data['og_extra_images'] ?? []
1666 + );
1176 1667 }
1177 1668 return;
1178 1669 }
1179 1670
@@ -1201,8 +1692,21 @@
1201 1692 (string) get_post_meta($this->current_post_id, '_thinkrank_og_description', true),
1202 1693 $this->current_post_id
1203 1694 );
1204 1695 $og_image_override = get_post_meta($this->current_post_id, '_thinkrank_og_image', true);
1696 + } elseif ($this->current_term_id) {
1697 + // Terms carry the same social override keys — the abilities API
1698 + // writes them — so honour them here rather than letting the term's
1699 + // SEO title stand in for an explicit og:title.
1700 + $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value(
1701 + (string) get_term_meta($this->current_term_id, '_thinkrank_og_title', true),
1702 + $this->current_term_id
1703 + );
1704 + $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value(
1705 + (string) get_term_meta($this->current_term_id, '_thinkrank_og_description', true),
1706 + $this->current_term_id
1707 + );
1708 + $og_image_override = get_term_meta($this->current_term_id, '_thinkrank_og_image', true);
1205 1709 }
1206 1710
1207 1711 // Get title using priority system: OG override > post-specific > Global SEO > Site Identity > default
1208 1712 $title = '';
@@ -1228,9 +1732,9 @@
1228 1732 // "There is no excerpt because this is a protected post." placeholder,
1229 1733 // so this is not a leak — but publishing that sentence as the social
1230 1734 // description is worse than publishing none (#363).
1231 1735 if (!$description && !$this->is_content_password_protected()) {
1232 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1736 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1233 1737 }
1234 1738
1235 1739 $url = is_singular() ? get_permalink() : home_url();
1236 1740 $site_name = $this->site_identity_data && !empty($this->site_identity_data['identity']['site_name'])
@@ -1258,11 +1762,11 @@
1258 1762 $og_type = apply_filters('thinkrank_og_type', $og_type);
1259 1763
1260 1764 echo "<!-- ThinkRank SEO Open Graph Meta Tags -->\n";
1261 1765 echo "<meta property=\"og:type\" content=\"" . esc_attr($og_type) . "\" />\n";
1262 - echo "<meta property=\"og:title\" content=\"" . esc_attr($title) . "\" />\n";
1766 + echo "<meta property=\"og:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1263 1767 echo "<meta property=\"og:description\" content=\"" . esc_attr($description) . "\" />\n";
1264 - echo "<meta property=\"og:url\" content=\"" . esc_url($url) . "\" />\n";
1768 + echo "<meta property=\"og:url\" content=\"" . esc_url(\ThinkRank\SEO\Url_Scheme::apply($url)) . "\" />\n";
1265 1769 echo "<meta property=\"og:site_name\" content=\"" . esc_attr($site_name) . "\" />\n";
1266 1770 /**
1267 1771 * Filter the og:locale value.
1268 1772 *
@@ -1279,14 +1783,17 @@
1279 1783 $og_locale = (string) apply_filters('thinkrank_og_locale', get_locale());
1280 1784 echo "<meta property=\"og:locale\" content=\"" . esc_attr($og_locale) . "\" />\n";
1281 1785
1282 1786 // Add OG image — per-post override > featured image
1787 + $primary_og_image = '';
1283 1788 if (is_singular() && $this->current_post_id) {
1284 1789 if (!empty($og_image_override)) {
1790 + $primary_og_image = (string) $og_image_override;
1285 1791 echo "<meta property=\"og:image\" content=\"" . esc_url($og_image_override) . "\" />\n";
1286 1792 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($og_image_override) . "\" />\n";
1287 1793 } elseif (has_post_thumbnail($this->current_post_id)) {
1288 1794 $image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
1795 + $primary_og_image = (string) $image_url;
1289 1796 echo "<meta property=\"og:image\" content=\"" . esc_url($image_url) . "\" />\n";
1290 1797 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($image_url) . "\" />\n";
1291 1798
1292 1799 // Get image dimensions and alt text
@@ -1315,8 +1822,30 @@
1315 1822 echo "<meta property=\"og:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n";
1316 1823 }
1317 1824 }
1318 1825
1826 + // Alternatives, same as the enhanced emitter above. This path only
1827 + // runs when the Social Meta Manager is unavailable, but the issue
1828 + // reported against it (#636) and a site that lands here should not
1829 + // silently lose a feature it switched on.
1830 + if (!empty($primary_og_image)) {
1831 + $social_settings = $this->social_manager
1832 + ? $this->social_manager->get_settings(
1833 + $this->current_context === 'homepage' ? 'site' : $this->current_context,
1834 + $this->current_post_id
1835 + )
1836 + : [];
1837 +
1838 + if (!empty($social_settings['og_multiple_images'])) {
1839 + self::output_extra_og_images(
1840 + \ThinkRank\SEO\Social_Images::additional(
1841 + (int) $this->current_post_id,
1842 + $primary_og_image
1843 + )
1844 + );
1845 + }
1846 + }
1847 +
1319 1848 // Add article specific tags for posts only
1320 1849 if ($og_type === 'article') {
1321 1850 echo '<meta property="article:published_time" content="' . esc_attr(get_the_date('c', $this->current_post_id)) . '" />' . "\n";
1322 1851 echo '<meta property="article:modified_time" content="' . esc_attr(get_the_modified_date('c', $this->current_post_id)) . '" />' . "\n";
@@ -1344,8 +1873,21 @@
1344 1873 *
1345 1874 * @return void
1346 1875 */
1347 1876 public function output_twitter_card_tags(): void {
1877 + // Per-content-type Twitter card switch; see output_open_graph_tags().
1878 + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
1879 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_TWITTER,
1880 + true
1881 + )) {
1882 + return;
1883 + }
1884 +
1885 + // Same reasoning as the Open Graph block: nothing on a 404 is shareable.
1886 + if ($this->current_context === '404') {
1887 + return;
1888 + }
1889 +
1348 1890 // Priority 1: Try Social Meta Manager (Social Media tab settings)
1349 1891 if ($this->social_manager) {
1350 1892 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1351 1893 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
@@ -1392,8 +1934,14 @@
1392 1934 $twitter_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_twitter_title', true), $pid);
1393 1935 $twitter_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_twitter_description', true), $pid);
1394 1936 $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_og_title', true), $pid);
1395 1937 $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_og_description', true), $pid);
1938 + } elseif ($this->current_term_id) {
1939 + $tid = $this->current_term_id;
1940 + $twitter_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_twitter_title', true), $tid);
1941 + $twitter_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_twitter_description', true), $tid);
1942 + $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_og_title', true), $tid);
1943 + $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_og_description', true), $tid);
1396 1944 }
1397 1945
1398 1946 // Title cascade: Twitter override > OG override > Global SEO > Site Identity > default
1399 1947 $title = '';
@@ -1423,9 +1971,9 @@
1423 1971 // "There is no excerpt because this is a protected post." placeholder,
1424 1972 // so this is not a leak — but publishing that sentence as the social
1425 1973 // description is worse than publishing none (#363).
1426 1974 if (!$description && !$this->is_content_password_protected()) {
1427 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1975 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1428 1976 }
1429 1977
1430 1978 // Determine card type based on image availability
1431 1979 $card_type = 'summary';
@@ -1434,9 +1982,9 @@
1434 1982 }
1435 1983
1436 1984 echo "<!-- ThinkRank SEO Twitter Card Meta Tags -->\n";
1437 1985 echo '<meta name="twitter:card" content="' . esc_attr($card_type) . '" />' . "\n";
1438 - echo "<meta name=\"twitter:title\" content=\"" . esc_attr($title) . "\" />\n";
1986 + echo "<meta name=\"twitter:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1439 1987 echo "<meta name=\"twitter:description\" content=\"" . esc_attr($description) . "\" />\n";
1440 1988
1441 1989 // Add Twitter image with proper fallback priority
1442 1990 $twitter_image_url = $this->get_twitter_image_with_fallback();
@@ -1486,11 +2034,18 @@
1486 2034 }
1487 2035
1488 2036 if (empty($canonical_url)) {
1489 2037 $canonical_url = $this->current_post_id ? get_permalink($this->current_post_id) : get_permalink();
2038 +
2039 + // Core's rel_canonical() keeps the page number; this replaced
2040 + // it with a bare permalink, so every <!--nextpage--> sub-page
2041 + // and every /comment-page-N/ canonicalised to page 1 — a
2042 + // regression against core behaviour (#397). A custom canonical
2043 + // is left exactly as the user typed it.
2044 + $canonical_url = self::with_singular_page($canonical_url);
1490 2045 }
1491 2046 } else {
1492 - $canonical_url = $this->get_non_singular_canonical_url();
2047 + $canonical_url = self::get_non_singular_canonical_url();
1493 2048 }
1494 2049
1495 2050 /**
1496 2051 * Filter the canonical URL before output.
@@ -1504,14 +2059,115 @@
1504 2059 if (empty($canonical_url)) {
1505 2060 return;
1506 2061 }
1507 2062
2063 + // After the filter, so a canonical an add-on supplied is normalized
2064 + // too — and a cross-domain one is left alone, since Url_Scheme only
2065 + // touches URLs on this site's own host.
2066 + $canonical_url = \ThinkRank\SEO\Url_Scheme::apply($canonical_url);
2067 +
1508 2068 echo "<!-- ThinkRank SEO Canonical URL -->\n";
1509 2069 echo "<link rel=\"canonical\" href=\"" . esc_url($canonical_url) . "\" />\n";
1510 2070 echo "<!-- /ThinkRank SEO Canonical URL -->\n";
2071 +
2072 + $this->output_pagination_links();
1511 2073 }
1512 2074
1513 2075 /**
2076 + * Emit rel="prev" / rel="next" on a paginated archive.
2077 + *
2078 + * Nothing emitted these at all (#397). Google stopped using them as an
2079 + * indexing signal in 2019, so this is not an SEO win with Google — Bing
2080 + * still reads them, and they are the standard way to describe a sequence,
2081 + * which is what the pages are.
2082 + *
2083 + * @since 2.0.1
2084 + *
2085 + * @return void
2086 + */
2087 + private function output_pagination_links(): void {
2088 + // Page 1 still wants a rel="next" when there is a page 2, so only
2089 + // singular views are skipped outright.
2090 + if (is_singular()) {
2091 + return;
2092 + }
2093 +
2094 + global $wp_query;
2095 +
2096 + $total = $wp_query ? (int) $wp_query->max_num_pages : 0;
2097 +
2098 + if ($total < 2) {
2099 + return;
2100 + }
2101 +
2102 + $base = self::get_non_singular_canonical_url();
2103 +
2104 + if ('' === $base) {
2105 + return;
2106 + }
2107 +
2108 + // get_non_singular_canonical_url() already carries the current page —
2109 + // strip it back to page 1 before building the neighbours.
2110 + $current = self::current_page_number();
2111 + $base = self::without_pagination($base);
2112 +
2113 + if ($current > 1) {
2114 + printf(
2115 + "<link rel=\"prev\" href=\"%s\" />\n",
2116 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current - 1)))
2117 + );
2118 + }
2119 +
2120 + if ($current < $total) {
2121 + printf(
2122 + "<link rel=\"next\" href=\"%s\" />\n",
2123 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current + 1)))
2124 + );
2125 + }
2126 + }
2127 +
2128 + /**
2129 + * The rewrite base WordPress uses for page numbers ('page' by default).
2130 + *
2131 + * @since 2.0.1
2132 + *
2133 + * @return string
2134 + */
2135 + private static function pagination_base(): string {
2136 + global $wp_rewrite;
2137 +
2138 + return $wp_rewrite && $wp_rewrite->pagination_base ? $wp_rewrite->pagination_base : 'page';
2139 + }
2140 +
2141 + /**
2142 + * Append the sub-page or comment-page number to a singular canonical.
2143 + *
2144 + * @since 2.0.1
2145 + *
2146 + * @param string $url Permalink.
2147 + * @return string Permalink with the current page appended, when there is one.
2148 + */
2149 + public static function with_singular_page(string $url): string {
2150 + global $wp_rewrite;
2151 +
2152 + $page = (int) get_query_var('page');
2153 +
2154 + if ($page > 1) {
2155 + return $wp_rewrite && $wp_rewrite->using_permalinks()
2156 + ? trailingslashit($url) . user_trailingslashit($page, 'single_paged')
2157 + : add_query_arg('page', $page, $url);
2158 + }
2159 +
2160 + $comment_page = (int) get_query_var('cpage');
2161 +
2162 + if ($comment_page > 1) {
2163 + return get_comments_pagenum_link($comment_page);
2164 + }
2165 +
2166 + return $url;
2167 + }
2168 +
2169 + /**
1514 2170 * Build the canonical URL for non-singular contexts.
1515 2171 *
1516 2172 * Covers the blog home, post type / taxonomy / author / date archives.
1517 2173 * Search results and 404 pages get no canonical (they are noindexed).
@@ -1519,9 +2175,9 @@
1519 2175 * self-referential rather than pointing at page 1.
1520 2176 *
1521 2177 * @return string Canonical URL or '' when none applies
1522 2178 */
1523 - private function get_non_singular_canonical_url(): string {
2179 + public static function get_non_singular_canonical_url(): string {
1524 2180 if (is_404() || is_search()) {
1525 2181 return '';
1526 2182 }
1527 2183
@@ -1552,17 +2208,89 @@
1552 2208 return '';
1553 2209 }
1554 2210
1555 2211 // Point paginated archives at their own page, not page 1.
2212 + return self::with_pagination($canonical_url, (int) get_query_var('paged'));
2213 + }
2214 +
2215 + /**
2216 + * Append a page number to a URL the way WordPress does.
2217 + *
2218 + * Extracted so the archive canonical is not the only thing that knows how
2219 + * to build a paged URL: the schema graph derived its @id from the
2220 + * un-paginated link, so every page of an archive claimed the same node
2221 + * identity, and the singular canonical dropped the page entirely (#397).
2222 + *
2223 + * @since 2.0.1
2224 + *
2225 + * @param string $url Base URL.
2226 + * @param int $page Page number; 1 or less returns the URL unchanged.
2227 + * @return string
2228 + */
2229 + public static function with_pagination(string $url, int $page): string {
2230 + if ($page <= 1 || '' === $url) {
2231 + return $url;
2232 + }
2233 +
2234 + global $wp_rewrite;
2235 +
2236 + if ($wp_rewrite && $wp_rewrite->using_permalinks()) {
2237 + return trailingslashit($url) . user_trailingslashit(
2238 + $wp_rewrite->pagination_base . '/' . $page,
2239 + 'paged'
2240 + );
2241 + }
2242 +
2243 + return add_query_arg('paged', $page, $url);
2244 + }
2245 +
2246 + /**
2247 + * Strip a page number from a URL, whichever form it takes.
2248 + *
2249 + * The inverse of with_pagination(). Pretty permalinks carry the page as a
2250 + * /page/N/ path segment, plain permalinks as a `paged` query arg, and a
2251 + * regex over the path alone silently left the latter in place — so
2252 + * rel="prev" on page 2 pointed at page 2 (#397 review).
2253 + *
2254 + * @since 2.0.1
2255 + *
2256 + * @param string $url URL that may carry a page number.
2257 + * @return string URL for page 1.
2258 + */
2259 + public static function without_pagination(string $url): string {
2260 + if ('' === $url) {
2261 + return $url;
2262 + }
2263 +
2264 + $url = remove_query_arg('paged', $url);
2265 +
2266 + return (string) preg_replace(
2267 + '#/' . preg_quote(self::pagination_base(), '#') . '/\d+/?$#',
2268 + '/',
2269 + $url
2270 + );
2271 + }
2272 +
2273 + /**
2274 + * The page number of the current request, archive or multi-page post.
2275 + *
2276 + * `paged` counts archive pages; `page` counts the <!--nextpage--> parts of
2277 + * a single post. They are never both set.
2278 + *
2279 + * @since 2.0.1
2280 + *
2281 + * @return int Page number, 1 when this is the first page.
2282 + */
2283 + public static function current_page_number(): int {
1556 2284 $paged = (int) get_query_var('paged');
2285 +
1557 2286 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);
2287 + return $paged;
1562 2288 }
1563 2289
1564 - return $canonical_url;
2290 + $page = (int) get_query_var('page');
2291 +
2292 + return $page > 1 ? $page : 1;
1565 2293 }
1566 2294
1567 2295
1568 2296 /**
@@ -1570,12 +2298,13 @@
1570 2298 *
1571 2299 * @return bool True if has ThinkRank metadata
1572 2300 */
1573 2301 private function has_thinkrank_metadata(): bool {
1574 - if (!is_singular()) {
1575 - return false;
1576 - }
1577 -
2302 + // Populated by initialize_current_context() for singular views and for
2303 + // term archives, and left empty everywhere else — so the emptiness
2304 + // check is the whole test. The `!is_singular()` early return this
2305 + // replaced is what made every stored term title and description inert:
2306 + // the entire title/description cascade hangs off this method (#386).
1578 2307 return !empty($this->current_metadata['title']) || !empty($this->current_metadata['description']);
1579 2308 }
1580 2309
1581 2310 /**
@@ -1718,9 +2447,11 @@
1718 2447 // would leave this path open (#363).
1719 2448 if (!empty($post->post_excerpt)) {
1720 2449 $placeholders['%excerpt%'] = $post->post_excerpt;
1721 2450 } elseif (!$this->is_content_password_protected($post->ID)) {
1722 - $placeholders['%excerpt%'] = wp_trim_words(wp_strip_all_tags($post->post_content), 25, '...');
2451 + $placeholders['%excerpt%'] = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt(
2452 + \ThinkRank\SEO\Builder_Content::visible_content($post)
2453 + );
1723 2454 }
1724 2455 }
1725 2456
1726 2457 // Get author
@@ -1781,8 +2512,17 @@
1781 2512 $settings = $this->site_identity_manager->get_settings('site');
1782 2513
1783 2514 switch ($this->current_context) {
1784 2515 case 'homepage':
2516 + // detect_current_context() collapses the static posts page into
2517 + // 'homepage', so it rendered the front page's title template and
2518 + // the two pages shipped the same <title> — a duplicate title on
2519 + // the site's two most-linked URLs (#397 review). It is a page,
2520 + // and it has its own name, so it gets the page template.
2521 + if (self::is_static_posts_page()) {
2522 + return $settings['page_title'] ?? $settings['homepage_title'] ?? null;
2523 + }
2524 +
1785 2525 return $settings['homepage_title'] ?? null;
1786 2526 case 'post':
1787 2527 return $settings['post_title'] ?? null;
1788 2528 case 'page':
@@ -1813,12 +2553,18 @@
1813 2553 $settings = $this->site_identity_manager->get_settings('site');
1814 2554 $separator = $this->get_title_separator($settings['title_separator'] ?? 'pipe');
1815 2555
1816 2556 $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'),
2557 + // first_non_empty(), not `??`: Site Identity persists these as ''
2558 + // rather than leaving them unset, and '' is not null — so the
2559 + // null-coalesce stopped dead on the empty string and the WordPress
2560 + // fallback was unreachable. A site with a tagline set in Settings →
2561 + // General rendered "%site_description%" as nothing (#398). This is
2562 + // the same reasoning first_non_empty()'s own docblock records.
2563 + '%site_title%' => $this->first_non_empty($settings['site_name'] ?? '', get_bloginfo('name')),
2564 + '%site_name%' => $this->first_non_empty($settings['site_name'] ?? '', get_bloginfo('name')),
2565 + '%site_description%' => $this->first_non_empty($settings['site_description'] ?? '', get_bloginfo('description')),
2566 + '%tagline%' => $this->first_non_empty($settings['tagline'] ?? '', get_bloginfo('description')),
1821 2567 '%separator%' => ' ' . $separator . ' ',
1822 2568 '%sep%' => ' ' . $separator . ' ',
1823 2569 '%date%' => gmdate('F Y'),
1824 2570 ];
@@ -1871,10 +2617,26 @@
1871 2617 $placeholders['%search_term%'] = get_search_query();
1872 2618 break;
1873 2619
1874 2620 case 'archive':
1875 - $placeholders['%archive_title%'] = get_the_archive_title();
2621 + // Stripped: get_the_archive_title() wraps its subject in a
2622 + // <span>, and this placeholder feeds the document <title> as
2623 + // well as og:title and twitter:title — a date archive rendered
2624 + // as "Month: <span>August 2026</span> | Site".
2625 + $placeholders['%archive_title%'] = self::archive_subject();
1876 2626 break;
2627 +
2628 + case 'homepage':
2629 + // The page template resolved for a static posts page needs the
2630 + // page's own name; without it %title%/%page_title% would render
2631 + // empty and collapse back to the site title.
2632 + if (self::is_static_posts_page()) {
2633 + $posts_page_title = get_the_title((int) get_option('page_for_posts'));
2634 + $placeholders['%title%'] = $posts_page_title;
2635 + $placeholders['%page_title%'] = $posts_page_title;
2636 + $placeholders['%post_title%'] = $posts_page_title;
2637 + }
2638 + break;
1877 2639 }
1878 2640
1879 2641 return $placeholders;
1880 2642 }
@@ -1879,8 +2641,19 @@
1879 2641 return $placeholders;
1880 2642 }
1881 2643
1882 2644 /**
2645 + * Whether this request is a static posts page rather than the front page.
2646 + *
2647 + * @since 2.0.1
2648 + *
2649 + * @return bool
2650 + */
2651 + private static function is_static_posts_page(): bool {
2652 + return is_home() && !is_front_page() && (int) get_option('page_for_posts') > 0;
2653 + }
2654 +
2655 + /**
1883 2656 * Process title template with placeholders
1884 2657 *
1885 2658 * @param string $template Template string
1886 2659 * @param array $placeholders Placeholder values
@@ -1991,11 +2764,17 @@
1991 2764 // gated body published its first ~25 words in the page head, and the
1992 2765 // same value is reused for og:description and twitter:description, so
1993 2766 // one unguarded read leaked through three tags (#363).
1994 2767 if (is_singular() && $this->current_post_id && !$this->is_content_password_protected()) {
1995 - $post_content = get_post_field('post_content', $this->current_post_id);
2768 + // Not the raw column: a Bricks page discards `post_content`, so
2769 + // whatever is still stored there is invisible — and this one value
2770 + // becomes the meta, og: and twitter: descriptions (#651).
2771 + $described = get_post($this->current_post_id);
2772 + $post_content = $described instanceof \WP_Post
2773 + ? \ThinkRank\SEO\Builder_Content::visible_content($described)
2774 + : get_post_field('post_content', $this->current_post_id);
1996 2775 if ($post_content) {
1997 - $excerpt = wp_trim_words(wp_strip_all_tags($post_content), 25, '...');
2776 + $excerpt = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt((string) $post_content);
1998 2777 if (!empty($excerpt)) {
1999 2778 return $excerpt;
2000 2779 }
2001 2780 }
@@ -2039,11 +2818,13 @@
2039 2818 if ($description === '') {
2040 2819 return null;
2041 2820 }
2042 2821
2043 - if (strlen($description) > 160) {
2044 - $description = wp_trim_words($description, 25, '...');
2045 - }
2822 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2823 + // CJK description tripped this limit at a third of its length, and
2824 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2825 + // English, 25 characters in Thai (#687).
2826 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2046 2827
2047 2828 return $description;
2048 2829 }
2049 2830
@@ -2090,11 +2871,13 @@
2090 2871 $description = preg_replace('/\s+/', ' ', $description);
2091 2872 $description = trim($description);
2092 2873
2093 2874 // Ensure description doesn't exceed recommended length (160 characters)
2094 - if (strlen($description) > 160) {
2095 - $description = wp_trim_words($description, 25, '...');
2096 - }
2875 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2876 + // CJK description tripped this limit at a third of its length, and
2877 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2878 + // English, 25 characters in Thai (#687).
2879 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2097 2880
2098 2881 return $description;
2099 2882 }
2100 2883
@@ -2108,8 +2891,19 @@
2108 2891 public function output_site_schema_markup(): void {
2109 2892 $has_schema_manager_output = false;
2110 2893 $has_website_schema = false;
2111 2894
2895 + // The master switch on Essential SEO -> Schema Manager. Until #461 this
2896 + // was never read here, so turning schema off left every deployed entity
2897 + // on the page. Read it once and bail before touching the graph.
2898 + if ($this->schema_manager) {
2899 + $schema_settings = $this->schema_manager->get_settings('site', null);
2900 +
2901 + if (isset($schema_settings['enabled']) && !$schema_settings['enabled']) {
2902 + return;
2903 + }
2904 + }
2905 +
2112 2906 // PRIORITY 1: Always output site-wide schemas (Organization, Website, LocalBusiness, Person)
2113 2907 if ($this->schema_manager) {
2114 2908 $site_wide_schemas = $this->schema_manager->get_deployed_schemas('site', null);
2115 2909
@@ -2150,10 +2944,19 @@
2150 2944
2151 2945 $page_specific_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
2152 2946
2153 2947 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
2948 + // Every deployed schema is rendered, on every plan. How many a
2949 + // page carries is decided when schemas are activated in the
2950 + // editor, not trimmed here by plan (#673).
2951 +
2952 + /**
2953 + * Filter the page-specific schemas rendered on the current page.
2954 + *
2955 + * @param array $page_specific_schemas Deployed schemas keyed by schema type.
2956 + * @param string $context_type Context type (post, page, product, site).
2957 + * @param int $context_id Post ID.
2958 + */
2156 2959 $page_specific_schemas = apply_filters(
2157 2960 'thinkrank_page_schemas_to_render',
2158 2961 $page_specific_schemas,
2159 2962 $context_type,
@@ -2159,16 +2962,22 @@
2159 2962 $context_type,
2160 2963 $context_id
2161 2964 );
2162 2965
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 - }
2966 + // A deployed node is a snapshot from Deploy time and outranks
2967 + // the automatic node, so page and article types would publish
2968 + // a frozen excerpt instead of the description the head
2969 + // resolves. Give them the live one, as the automatic node has.
2970 + $context_post = get_post($context_id);
2168 2971
2169 2972 foreach ($page_specific_schemas as $schema_type => $schema_info) {
2170 - Schema_Graph::instance()->add_primary($schema_info['data'], (string) $schema_type, 'schema_manager');
2973 + $node = $schema_info['data'];
2974 +
2975 + if ($this->global_seo_schema && $context_post instanceof \WP_Post) {
2976 + $node = $this->global_seo_schema->refresh_deployed_description($node, (string) $schema_type, $context_post);
2977 + }
2978 +
2979 + Schema_Graph::instance()->add_primary($node, (string) $schema_type, 'schema_manager');
2171 2980 }
2172 2981 $has_schema_manager_output = true;
2173 2982 }
2174 2983 }
@@ -2226,21 +3035,49 @@
2226 3035 'url' => home_url('/'),
2227 3036 ];
2228 3037
2229 3038 $description = !empty($settings['site_description']) ? $settings['site_description'] : get_bloginfo('description');
3039 + // The tagline is stored esc_html()'d by sanitize_option(), so a site
3040 + // called "Fish & Chips" published `&amp;` literally in its WebSite
3041 + // node; nothing decodes JSON-LD downstream.
3042 + $description = \ThinkRank\Core\Seo_Text::normalize_schema_text((string) $description);
2230 3043 if (!empty($description)) {
2231 3044 $schema['description'] = $description;
2232 3045 }
2233 3046
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 - ];
3047 + // Site Identity has accepted an alternate name since the setup wizard
3048 + // shipped, and the MCP ability describes it as "published as schema
3049 + // alternateName" — but no producer ever read it, so the promise was
3050 + // false and every imported Yoast/Rank Math value sat unused (#692).
3051 + $alternate_name = \ThinkRank\SEO\Site_Identity_Manager::alternate_name_for_schema($settings['alternate_name'] ?? null);
3052 + if (null !== $alternate_name) {
3053 + $schema['alternateName'] = $alternate_name;
3054 + }
2242 3055
3056 + // The sitelinks searchbox switch was honoured only for a deployed
3057 + // WebSite row; this live fallback added potentialAction unconditionally,
3058 + // so website_enable_search = 0 still shipped the SearchAction (#688).
3059 + // Absent means not configured, which stays enabled.
3060 + $search_enabled = true;
3061 + if ($this->schema_manager) {
3062 + $schema_settings = $this->schema_manager->get_settings('site', null);
3063 +
3064 + if (array_key_exists('website_enable_search', $schema_settings)) {
3065 + $search_enabled = !empty($schema_settings['website_enable_search']);
3066 + }
3067 + }
3068 +
3069 + if ($search_enabled) {
3070 + $schema['potentialAction'] = [
3071 + '@type' => 'SearchAction',
3072 + 'target' => [
3073 + '@type' => 'EntryPoint',
3074 + 'urlTemplate' => home_url('/?s={search_term_string}'),
3075 + ],
3076 + 'query-input' => 'required name=search_term_string',
3077 + ];
3078 + }
3079 +
2243 3080 return $schema;
2244 3081 }
2245 3082
2246 3083 /**
@@ -2437,8 +3274,15 @@
2437 3274 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
2438 3275 return;
2439 3276 }
2440 3277
3278 + // A breadcrumb trail for a URL that does not exist, or for a search
3279 + // results page, describes nothing — and the plugin already emits no
3280 + // canonical on either (#471).
3281 + if (is_404() || is_search()) {
3282 + return;
3283 + }
3284 +
2441 3285 $settings = $this->site_identity_manager->get_settings('site');
2442 3286
2443 3287 // Only output if breadcrumbs are enabled
2444 3288 if (empty($settings['breadcrumbs_enabled'])) {
@@ -2444,8 +3288,22 @@
2444 3288 if (empty($settings['breadcrumbs_enabled'])) {
2445 3289 return;
2446 3290 }
2447 3291
3292 + // Schema Manager's own breadcrumb switch. Only Site Identity's
3293 + // breadcrumbs_enabled was consulted here, so enable_breadcrumbs_schema
3294 + // = 0 removed a deployed BreadcrumbList row and left this live one
3295 + // emitting the node anyway (#688). Absent means not configured, which
3296 + // stays enabled.
3297 + if ($this->schema_manager) {
3298 + $schema_settings = $this->schema_manager->get_settings('site', null);
3299 +
3300 + if (array_key_exists('enable_breadcrumbs_schema', $schema_settings)
3301 + && empty($schema_settings['enable_breadcrumbs_schema'])) {
3302 + return;
3303 + }
3304 + }
3305 +
2448 3306 $breadcrumbs = $this->generate_breadcrumbs($settings);
2449 3307
2450 3308 if (!empty($breadcrumbs['schema'])) {
2451 3309 Schema_Graph::instance()->add_supporting($breadcrumbs['schema'], 'BreadcrumbList');
@@ -2470,25 +3328,34 @@
2470 3328 *
2471 3329 * @return void
2472 3330 */
2473 3331 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()) {
3332 + // Close only what was actually opened. has_seo_output() is true on
3333 + // nearly every page, so testing it here printed a closing comment with
3334 + // no matching opener whenever the meta description was empty (search
3335 + // results, author archives without a description).
3336 + if (self::$opening_comment_output) {
2477 3337 echo "<!-- /ThinkRank SEO -->\n";
2478 3338 }
2479 3339 }
2480 3340
2481 3341 /**
2482 - * Check if any SEO content has been output
3342 + * Print the opening ThinkRank comment, once per request.
2483 3343 *
2484 - * @return bool True if SEO content was output
3344 + * Public and static so Author_Archives_Manager — which prints its own meta
3345 + * description on wp_head at priority 5 — opens the block through the same
3346 + * flag the closing comment reads.
3347 + *
3348 + * @since 2.0.1
3349 + * @return void
2485 3350 */
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']);
3351 + public static function note_opening_comment(): void {
3352 + if (self::$opening_comment_output) {
3353 + return;
3354 + }
3355 +
3356 + echo "<!-- Search Engine Optimization by ThinkRank - https://thinkrank.ai/ -->\n";
3357 + self::$opening_comment_output = true;
2491 3358 }
2492 3359
2493 3360 /**
2494 3361 * Display breadcrumbs HTML
@@ -2691,8 +3558,102 @@
2691 3558 * @since 1.32.0
2692 3559 *
2693 3560 * @return void
2694 3561 */
3562 + /**
3563 + * Serve a ThinkRank sitemap document for this request, when it is one.
3564 + *
3565 + * Only acts in dynamic delivery mode. In static mode a real file exists and
3566 + * the web server returns it without WordPress ever loading, so answering
3567 + * here as well would mean two sources for the same bytes.
3568 + *
3569 + * @since 2.9.0
3570 + *
3571 + * @return void
3572 + */
3573 + public function maybe_serve_sitemap(): void {
3574 + $filename = $this->requested_sitemap_filename();
3575 + if ('' === $filename) {
3576 + return;
3577 + }
3578 +
3579 + try {
3580 + // Read-only instance: passing false keeps it from registering a
3581 + // second copy of the auto-generation hooks.
3582 + $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3583 + $settings = $generator->get_settings('site');
3584 +
3585 + if (empty($settings['enabled'])) {
3586 + return;
3587 + }
3588 +
3589 + if ('dynamic' !== $generator->resolve_delivery_mode($settings)) {
3590 + return;
3591 + }
3592 +
3593 + if (!$generator->publishes_document_name($filename, $settings)) {
3594 + return;
3595 + }
3596 +
3597 + $xml = $generator->render_document($filename, $settings);
3598 + } catch (\Throwable $e) {
3599 + // A failed render must not replace the sitemap with a fatal. Leave
3600 + // the request alone so WordPress answers as it otherwise would.
3601 + return;
3602 + }
3603 +
3604 + if (!is_string($xml) || '' === trim($xml)) {
3605 + return;
3606 + }
3607 +
3608 + status_header(200);
3609 + header('Content-Type: application/xml; charset=UTF-8');
3610 + header('X-Robots-Tag: noindex, follow', true);
3611 +
3612 + // Built XML, escaped by the builders as they assemble it; escaping the
3613 + // document here would corrupt it.
3614 + echo $xml; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
3615 + exit;
3616 + }
3617 +
3618 + /**
3619 + * The sitemap file name this request is asking for, if it looks like one.
3620 + *
3621 + * Deliberately a cheap shape test. Whether the site actually publishes the
3622 + * name is settled by the caller against the generator, so that a request
3623 + * for someone else's sitemap is never answered here.
3624 + *
3625 + * @since 2.9.0
3626 + *
3627 + * @return string File name, or '' when this is not a sitemap request.
3628 + */
3629 + private function requested_sitemap_filename(): string {
3630 + if (empty($_SERVER['REQUEST_URI'])) {
3631 + return '';
3632 + }
3633 +
3634 + $path = wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), PHP_URL_PATH);
3635 + if (!is_string($path) || '' === $path) {
3636 + return '';
3637 + }
3638 +
3639 + // Strip the install's home path so subdirectory installs match too.
3640 + $home_path = (string) wp_parse_url(home_url('/'), PHP_URL_PATH);
3641 + if ('' !== $home_path && '/' !== $home_path && 0 === strpos($path, $home_path)) {
3642 + $path = substr($path, strlen($home_path));
3643 + }
3644 +
3645 + $candidate = strtolower(trim($path, '/'));
3646 +
3647 + // One path segment ending in .xml. Anything nested is not a file we
3648 + // publish to the web root.
3649 + if ('' === $candidate || strpos($candidate, '/') !== false) {
3650 + return '';
3651 + }
3652 +
3653 + return substr($candidate, -4) === '.xml' ? $candidate : '';
3654 + }
3655 +
2695 3656 public function maybe_serve_llms_txt(): void {
2696 3657 if (!$this->is_llms_txt_request()) {
2697 3658 return;
2698 3659 }
@@ -2893,11 +3854,22 @@
2893 3854 // second copy of the save_post/term auto-generation hooks.
2894 3855 $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
2895 3856 $settings = $generator->get_settings('site');
2896 3857
3858 + // "Can ThinkRank actually answer its sitemap URL right now?" In
3859 + // static mode that means the file is on disk; in dynamic mode
3860 + // maybe_serve_sitemap() answers it, so there is nothing to look
3861 + // for. Keeping the file test as the only answer would have left
3862 + // core's sitemap in place on every dynamic site, which is the
3863 + // crawl conflict this suppression exists to prevent (#752).
3864 + // The #346 behaviour is unchanged: a static site with nothing
3865 + // published still falls through to core rather than 404ing.
3866 + $can_serve = 'dynamic' === $generator->resolve_delivery_mode($settings)
3867 + || $generator->primary_sitemap_file_exists($settings);
3868 +
2897 3869 $this->thinkrank_sitemap_enabled = !empty($settings['enabled'])
2898 3870 && !$this->publishes_at_core_sitemap_url($settings)
2899 - && $generator->primary_sitemap_file_exists($settings);
3871 + && $can_serve;
2900 3872
2901 3873 if ($this->thinkrank_sitemap_enabled) {
2902 3874 $this->thinkrank_sitemap_url = $generator->get_primary_sitemap_url($settings);
2903 3875 }
@@ -2987,15 +3959,17 @@
2987 3959 if (empty($settings['enabled'])) {
2988 3960 return (string) $url;
2989 3961 }
2990 3962
3963 + $size = (int) $size;
3964 +
2991 3965 // 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']);
3966 + if ($size === 180 && !empty($settings['apple_touch_icon_url'])) {
3967 + return $this->resolve_icon_url((string) $settings['apple_touch_icon_url'], $size);
2994 3968 }
2995 3969
2996 3970 if (!empty($settings['favicon_url'])) {
2997 - return esc_url($settings['favicon_url']);
3971 + return $this->resolve_icon_url((string) $settings['favicon_url'], $size);
2998 3972 }
2999 3973
3000 3974 return (string) $url;
3001 3975 }
@@ -3000,8 +3974,178 @@
3000 3974 return (string) $url;
3001 3975 }
3002 3976
3003 3977 /**
3978 + * Whether breadcrumb labels should prefer the SEO title.
3979 + *
3980 + * Off unless the site turns it on, so updating the plugin never rewrites an
3981 + * existing trail.
3982 + *
3983 + * @since 2.3.1
3984 + *
3985 + * @param array $settings Breadcrumb settings.
3986 + * @return bool
3987 + */
3988 + private function breadcrumbs_use_seo_title(array $settings): bool {
3989 + return !empty($settings['breadcrumb_use_seo_title']);
3990 + }
3991 +
3992 + /**
3993 + * Label for a post in the breadcrumb trail.
3994 + *
3995 + * With the toggle on, the post's own SEO title wins — the same
3996 + * `_thinkrank_seo_title` value (variable tags resolved) the document title
3997 + * uses — so the trail under a search snippet reads the same as the snippet
3998 + * itself. Anything empty falls back to the raw post title; the global title
3999 + * pattern is deliberately NOT part of the chain, since resolving it would
4000 + * append the site name to every crumb.
4001 + *
4002 + * @since 2.3.1
4003 + *
4004 + * @param int $post_id Post ID.
4005 + * @param array $settings Breadcrumb settings.
4006 + * @return string Breadcrumb label.
4007 + */
4008 + private function get_breadcrumb_post_title(int $post_id, array $settings): string {
4009 + $title = (string) get_the_title($post_id);
4010 +
4011 + if (!$this->breadcrumbs_use_seo_title($settings)) {
4012 + return $title;
4013 + }
4014 +
4015 + $seo_title = trim((string) get_post_meta($post_id, '_thinkrank_seo_title', true));
4016 +
4017 + if ('' === $seo_title) {
4018 + return $title;
4019 + }
4020 +
4021 + $resolved = trim(\ThinkRank\SEO\Pattern_Resolver::resolve_value($seo_title, $post_id));
4022 +
4023 + return '' !== $resolved ? $resolved : $title;
4024 + }
4025 +
4026 + /**
4027 + * Label for a term in the breadcrumb trail.
4028 + *
4029 + * Term counterpart to {@see self::get_breadcrumb_post_title()}, resolving
4030 + * the term's `_thinkrank_seo_title` against its own values.
4031 + *
4032 + * @since 2.3.1
4033 + *
4034 + * @param object $term Term object.
4035 + * @param array $settings Breadcrumb settings.
4036 + * @return string Breadcrumb label.
4037 + */
4038 + private function get_breadcrumb_term_title($term, array $settings): string {
4039 + $name = (string) ($term->name ?? '');
4040 +
4041 + if (!$this->breadcrumbs_use_seo_title($settings) || empty($term->term_id)) {
4042 + return $name;
4043 + }
4044 +
4045 + $seo_title = trim((string) get_term_meta((int) $term->term_id, '_thinkrank_seo_title', true));
4046 +
4047 + if ('' === $seo_title) {
4048 + return $name;
4049 + }
4050 +
4051 + $resolved = trim(\ThinkRank\SEO\Pattern_Resolver::resolve_term_value($seo_title, (int) $term->term_id));
4052 +
4053 + return '' !== $resolved ? $resolved : $name;
4054 + }
4055 +
4056 + /**
4057 + * Resolve a configured icon URL to the derivative that fits $size.
4058 + *
4059 + * wp_site_icon() calls get_site_icon_url() four times — 32, 192, 180 and
4060 + * 270 — and pairs the first two with a hardcoded sizes="" attribute. This
4061 + * filter used to answer all four with the same configured URL, so one
4062 + * upload was declared as every size at once: a 1536x1536 original served
4063 + * to paint a 32px tab icon, under a sizes="32x32" label that was simply
4064 + * untrue (#571).
4065 + *
4066 + * Resolution mirrors core's own get_site_icon_url(), including the
4067 + * >= 512 -> 'full' branch, so ThinkRank's override and the core pipeline
4068 + * pick the same file for the same request.
4069 + *
4070 + * An unresolvable URL (one hosted off-site) is returned unchanged. Nothing
4071 + * is knowable about its dimensions, and suppressing it instead would leave
4072 + * the page with no rel="icon" at all — a worse outcome than an approximate
4073 + * size hint.
4074 + *
4075 + * @param string $configured Configured icon URL.
4076 + * @param int $size Icon size core is asking for.
4077 + * @return string Icon URL for that size.
4078 + */
4079 + private function resolve_icon_url(string $configured, int $size): string {
4080 + $cache_key = md5($configured) . ':' . $size;
4081 + $cached = $this->icon_urls();
4082 +
4083 + if (isset($cached[$cache_key])) {
4084 + return $cached[$cache_key];
4085 + }
4086 +
4087 + $attachment_id = \ThinkRank\SEO\Site_Identity_Manager::icon_attachment_id($configured);
4088 +
4089 + if (!$attachment_id) {
4090 + $resolved = esc_url($configured);
4091 + } else {
4092 + // Mirrors core: at 512 and above the original is what is wanted, and
4093 + // asking for an intermediate size that large would only fall back to it.
4094 + $size_data = $size >= 512 ? 'full' : [$size, $size];
4095 + $url = wp_get_attachment_image_url($attachment_id, $size_data);
4096 + $resolved = $url ? esc_url($url) : esc_url($configured);
4097 + }
4098 +
4099 + $this->icon_urls[$cache_key] = $resolved;
4100 +
4101 + if (!$this->icon_urls_dirty) {
4102 + $this->icon_urls_dirty = true;
4103 + // Written once, after the response is assembled, rather than once
4104 + // per size: wp_site_icon() resolves four in a row.
4105 + add_action('shutdown', [$this, 'persist_icon_urls'], 5);
4106 + }
4107 +
4108 + return $resolved;
4109 + }
4110 +
4111 + /**
4112 + * The resolved-icon-URL map, loaded from its transient on first use.
4113 + *
4114 + * @return array<string, string>
4115 + */
4116 + private function icon_urls(): array {
4117 + if ($this->icon_urls === null) {
4118 + $stored = get_transient(\ThinkRank\SEO\Site_Identity_Manager::ICON_URL_TRANSIENT);
4119 + $this->icon_urls = is_array($stored) ? $stored : [];
4120 + }
4121 +
4122 + return $this->icon_urls;
4123 + }
4124 +
4125 + /**
4126 + * Persist newly resolved icon URLs.
4127 + *
4128 + * Public because it runs on `shutdown`. Invalidated wholesale whenever the
4129 + * site identity settings are saved, which is the only moment the icon
4130 + * choice — or the derivatives behind it — can change.
4131 + *
4132 + * @return void
4133 + */
4134 + public function persist_icon_urls(): void {
4135 + if (!$this->icon_urls_dirty || !is_array($this->icon_urls)) {
4136 + return;
4137 + }
4138 +
4139 + $this->icon_urls_dirty = false;
4140 + set_transient(
4141 + \ThinkRank\SEO\Site_Identity_Manager::ICON_URL_TRANSIENT,
4142 + $this->icon_urls,
4143 + DAY_IN_SECONDS
4144 + );
4145 + }
4146 +
4147 + /**
3004 4148 * Get breadcrumb items for current page
3005 4149 *
3006 4150 * @param array $settings Breadcrumb settings
3007 4151 * @return array Breadcrumb items
@@ -3029,9 +4173,9 @@
3029 4173 $categories = get_the_category($current_post_id);
3030 4174 if (!empty($categories)) {
3031 4175 $category = $categories[0];
3032 4176 $items[] = [
3033 - 'title' => $category->name,
4177 + 'title' => $this->get_breadcrumb_term_title($category, $settings),
3034 4178 'url' => get_category_link($category->term_id),
3035 4179 'position' => $position++
3036 4180 ];
3037 4181 }
@@ -3036,12 +4180,18 @@
3036 4180 ];
3037 4181 }
3038 4182 }
3039 4183
3040 - // Add current post
3041 - if (empty($settings['show_current_page']) || $settings['show_current_page']) {
4184 + // Add current post. `empty($x) || $x` is true for every possible
4185 + // value — an unset key, false, 0, '' and any truthy value alike —
4186 + // so the setting had no effect on the rendered breadcrumb or on
4187 + // the BreadcrumbList JSON-LD, while the admin preview honoured it
4188 + // and disagreed with live output (#398). Site_Identity_Manager
4189 + // already had the correct form: default to on, respect an
4190 + // explicit off.
4191 + if ($settings['show_current_page'] ?? true) {
3042 4192 $items[] = [
3043 - 'title' => get_the_title($current_post_id),
4193 + 'title' => $this->get_breadcrumb_post_title($current_post_id, $settings),
3044 4194 'url' => get_permalink($current_post_id),
3045 4195 'position' => $position,
3046 4196 'current' => true
3047 4197 ];
@@ -3058,9 +4208,9 @@
3058 4208 while ($parent_id) {
3059 4209 $parent = get_post($parent_id);
3060 4210 if ($parent) {
3061 4211 $parents[] = [
3062 - 'title' => get_the_title($parent->ID),
4212 + 'title' => $this->get_breadcrumb_post_title($parent->ID, $settings),
3063 4213 'url' => get_permalink($parent->ID),
3064 4214 'position' => 0 // Will be set later
3065 4215 ];
3066 4216 $parent_id = $parent->post_parent;
@@ -3078,11 +4228,11 @@
3078 4228 $items[] = $parent;
3079 4229 }
3080 4230
3081 4231 // Add current page
3082 - if (empty($settings['show_current_page']) || $settings['show_current_page']) {
4232 + if ($settings['show_current_page'] ?? true) {
3083 4233 $items[] = [
3084 - 'title' => get_the_title($current_post_id),
4234 + 'title' => $this->get_breadcrumb_post_title($current_post_id, $settings),
3085 4235 'url' => get_permalink($current_post_id),
3086 4236 'position' => $position,
3087 4237 'current' => true
3088 4238 ];
@@ -3098,9 +4248,9 @@
3098 4248 while ($parent_id) {
3099 4249 $parent = get_category($parent_id);
3100 4250 if ($parent && !is_wp_error($parent)) {
3101 4251 $parents[] = [
3102 - 'title' => $parent->name,
4252 + 'title' => $this->get_breadcrumb_term_title($parent, $settings),
3103 4253 'url' => get_category_link($parent->term_id),
3104 4254 'position' => 0 // Will be set later
3105 4255 ];
3106 4256 $parent_id = $parent->parent;
@@ -3118,11 +4268,11 @@
3118 4268 $items[] = $parent;
3119 4269 }
3120 4270
3121 4271 // Add current category
3122 - if (empty($settings['show_current_page']) || $settings['show_current_page']) {
4272 + if ($settings['show_current_page'] ?? true) {
3123 4273 $items[] = [
3124 - 'title' => $category->name,
4274 + 'title' => $this->get_breadcrumb_term_title($category, $settings),
3125 4275 'url' => get_category_link($category->term_id),
3126 4276 'position' => $position,
3127 4277 'current' => true
3128 4278 ];