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 +1663 -146 1.30.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,39 @@
90 127 */
91 128 private string $current_context = 'site';
92 129
93 130 /**
131 + * Whether the opening "Search Engine Optimization by ThinkRank" comment has
132 + * already been printed for this request.
133 + *
134 + * Shared across the request rather than kept as a local `static` inside the
135 + * emitter, because the closing comment is printed from a different method
136 + * (and the opening one can also come from Author_Archives_Manager). Without
137 + * that, output_closing_comment() decided on its own always-false local
138 + * static and emitted an orphan `<!-- /ThinkRank SEO -->` on every page whose
139 + * meta description was empty.
140 + *
141 + * @since 2.0.1
142 + * @var bool
143 + */
144 + private static bool $opening_comment_output = false;
145 +
146 + /**
147 + * Memoised "should core's sitemap be disabled" flag. Null until resolved.
148 + *
149 + * @var bool|null
150 + */
151 + private ?bool $thinkrank_sitemap_enabled = null;
152 +
153 + /**
154 + * Memoised public URL of the sitemap ThinkRank publishes. Empty until
155 + * should_disable_core_sitemap() has resolved, and while it resolves false.
156 + *
157 + * @var string
158 + */
159 + private string $thinkrank_sitemap_url = '';
160 +
161 + /**
94 162 * Initialize SEO manager
95 163 *
96 164 * @return void
97 165 */
@@ -107,17 +175,22 @@
107 175
108 176 // Initialize Global SEO Schema Output
109 177 $this->initialize_global_seo_schema();
110 178
111 - // Initialize Google Analytics Tracking Manager
112 - $this->initialize_google_analytics_tracking();
113 -
114 179 // Initialize Image SEO Manager
115 180 $this->initialize_image_seo_manager();
116 181
182 + // Initialize External Links Manager (rel=nofollow / target=_blank)
183 + $this->initialize_external_links_manager();
184 +
117 185 // Initialize current post and context data first
118 186 add_action('wp', [$this, 'initialize_current_context']);
119 187
188 + // ...then let it be corrected if the request turns into a 404 later.
189 + // Late, so every set_404() on this hook has already run; still well
190 + // before wp_head, which the template fires.
191 + add_action('template_redirect', [$this, 'recheck_404_context'], 999);
192 +
120 193 // Use HIGH PRIORITY hooks to override other SEO plugins
121 194 // Priority 1-5 ensures ThinkRank runs before other SEO plugins
122 195
123 196 // Override WordPress title with HIGH priority
@@ -148,11 +221,27 @@
148 221 // the page would emit two <link rel="canonical"> tags on singular views.
149 222 remove_action('wp_head', 'rel_canonical');
150 223 add_action('wp_head', [$this, 'output_canonical_url'], 6);
151 224
225 + // Silence the Bricks theme's own SEO + Open Graph output so a Bricks
226 + // site doesn't ship two of every tag. Bricks is a THEME, so it loads
227 + // after plugins: at this point BRICKS_VERSION is not yet defined and a
228 + // `defined()` guard here would always be false. Registering the filters
229 + // unconditionally is correct and free — the hooks only ever fire from
230 + // inside Bricks itself (#257). This mirrors the core rel_canonical and
231 + // wp_robots removals above: one producer per tag.
232 + add_filter('bricks/frontend/disable_seo', '__return_true');
233 + add_filter('bricks/frontend/disable_opengraph', '__return_true');
234 +
152 235 // Add Site Identity specific outputs
153 236 add_action('wp_head', [$this, 'output_site_schema_markup'], 7);
154 237 add_action('wp_head', [$this, 'output_breadcrumb_schema'], 8);
238 + // Late enough that Global_SEO_Schema_Output (priority 15) has registered.
239 + add_action('wp_head', [$this, 'output_schema_graph'], 20);
240 + // Tell the graph it has a renderer, so a body producer asking whether
241 + // its FAQ was absorbed can trigger collection itself when a block theme
242 + // renders the post content ahead of wp_head.
243 + Schema_Graph::instance()->schedule_render();
155 244
156 245 // Add closing comment (runs last)
157 246 add_action('wp_head', [$this, 'output_closing_comment'], 99);
158 247
@@ -169,8 +258,46 @@
169 258
170 259 // Add robots.txt filter hook
171 260 add_filter('robots_txt', [$this, 'filter_robots_txt'], 10, 2);
172 261
262 + // Serve /llms.txt from PHP when the request reaches WordPress. A
263 + // published llms.txt is a physical file, so the web server normally
264 + // answers it — with `text/plain` and no charset, which renders UTF-8
265 + // content as mojibake. This route (plus the .htaccess block written by
266 + // LLMs_Txt_Manager for the static file) guarantees an explicit UTF-8
267 + // charset. Priority 8 keeps it ahead of redirect_canonical().
268 + add_action('template_redirect', [$this, 'maybe_serve_llms_txt'], 8);
269 +
270 + // Serve the sitemap from PHP on sites whose web root cannot be written.
271 + // ThinkRank publishes sitemaps as real files, so where that is possible
272 + // the web server answers first and this never runs; where it is not,
273 + // this is the only thing that answers at all, and without it the
274 + // feature was simply unavailable (#752). Same priority 8, and for the
275 + // same reason: ahead of redirect_canonical().
276 + add_action('template_redirect', [$this, 'maybe_serve_sitemap'], 8);
277 +
278 + // Take WordPress core's own sitemap offline while ThinkRank's is active.
279 + // Two sitemap indexes on one site is a crawl conflict: core keeps
280 + // /wp-sitemap.xml served and injects its own "Sitemap:" line into
281 + // robots.txt (WP_Sitemaps::add_robots, priority 0). Until now that line
282 + // only disappeared as a side effect of filter_robots_txt() replacing the
283 + // whole filter output, which does not happen when robots.txt management
284 + // is off, when Site Identity is disabled, or when another SEO plugin
285 + // claims the filter first — and it never took /wp-sitemap.xml itself
286 + // offline, so crawlers could still find and follow the duplicate index.
287 + add_filter('wp_sitemaps_enabled', [$this, 'filter_wp_sitemaps_enabled']);
288 +
289 + // …and point the URLs core owned at our sitemap, rather than letting
290 + // them dead-end. Disabling core's sitemap does not unhook the two core
291 + // paths that route /sitemap.xml: WP_Rewrite::rewrite_rules() adds the
292 + // `sitemap\.xml` rule unconditionally, and redirect_canonical() 301s any
293 + // request carrying the `sitemap` query var to /wp-sitemap.xml without
294 + // consulting wp_sitemaps_enabled — which then 404s. Runs before both
295 + // redirect_canonical() and WP_Sitemaps::render_sitemaps() (priority 10),
296 + // and after a Pro redirect rule (priority 1) so a user-defined redirect
297 + // for these URLs still wins.
298 + add_action('template_redirect', [$this, 'redirect_core_sitemap_requests'], 9);
299 +
173 300 // Keep an existing physical robots.txt in step with WordPress's
174 301 // "Discourage search engines" toggle (blog_public). A physical file
175 302 // bypasses core's robots_txt filter, so flipping blog_public after the
176 303 // file was written would otherwise leave the previous crawl policy served
@@ -180,8 +307,17 @@
180 307 // Serve the Site Identity favicon through core's site-icon pipeline so
181 308 // wp_site_icon() outputs it on the front-end (and previews pick it up)
182 309 add_filter('get_site_icon_url', [$this, 'filter_site_icon_url'], 10, 2);
183 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 +
184 320 // Process image SEO in content
185 321 add_filter('the_content', [$this, 'filter_content_images'], 99999);
186 322 add_filter('post_thumbnail_html', [$this, 'filter_content_images'], 11, 2);
187 323 add_filter('woocommerce_single_product_image_thumbnail_html', [$this, 'filter_content_images'], 11);
@@ -245,35 +381,61 @@
245 381 $this->global_seo_schema->init();
246 382 }
247 383
248 384 /**
249 - * Initialize Google Analytics Tracking Manager
385 + * Initialize Image SEO Manager
250 386 *
251 387 * @return void
252 388 */
253 - private function initialize_google_analytics_tracking(): void {
254 - if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
255 - require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-google-analytics-tracking-manager.php';
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';
256 392 }
257 393
258 - // Initialize Google Analytics Tracking Manager
259 - new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
394 + $this->image_seo_manager = new \ThinkRank\SEO\Image_SEO_Manager();
260 395 }
261 396
262 397 /**
263 - * Initialize Image SEO Manager
398 + * Initialize External Links Manager
264 399 *
400 + * @since 2.5.0
265 401 * @return void
266 402 */
267 - private function initialize_image_seo_manager(): void {
268 - if (!class_exists('ThinkRank\\SEO\\Image_SEO_Manager')) {
269 - require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-image-seo-manager.php';
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';
270 406 }
271 407
272 - $this->image_seo_manager = new \ThinkRank\SEO\Image_SEO_Manager();
408 + $this->external_links_manager = new \ThinkRank\SEO\External_Links_Manager();
273 409 }
274 410
275 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 + /**
276 438 * Filter content to inject image SEO attributes
277 439 *
278 440 * @since 1.0.0
279 441 * @param string $content Content to filter
@@ -325,18 +487,74 @@
325 487 $this->current_metadata = $this->get_post_seo_metadata($post_id);
326 488 }
327 489 }
328 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 +
329 506 // Load site identity data
330 507 $this->load_site_identity_data();
331 508 }
332 509
333 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 + /**
334 543 * Detect current page context
335 544 *
336 545 * @return string Current context type
337 546 */
338 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 +
339 557 if (is_home() || is_front_page()) {
340 558 return 'homepage';
341 559 } elseif (is_single()) {
342 560 return 'post';
@@ -392,8 +610,37 @@
392 610 ];
393 611 }
394 612
395 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 + /**
396 643 * Resolve the effective SEO title for the current request.
397 644 *
398 645 * Same priority chain as override_document_title() — post-specific
399 646 * ThinkRank metadata (resolved _thinkrank_seo_title) > Global SEO
@@ -418,17 +665,23 @@
418 665 * @param string $title Original title
419 666 * @return string Modified title
420 667 */
421 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 +
422 675 // First priority: Post-specific ThinkRank metadata
423 676 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
424 - return $this->current_metadata['title'];
677 + return self::with_page_suffix($this->current_metadata['title']);
425 678 }
426 679
427 680 // Second priority: Global SEO templates, Third priority: Site Identity templates
428 681 $generated_title = $this->generate_context_title();
429 682 if ($generated_title) {
430 - return $generated_title;
683 + return self::with_page_suffix($generated_title);
431 684 }
432 685
433 686 return $title;
434 687 }
@@ -433,8 +686,117 @@
433 686 return $title;
434 687 }
435 688
436 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 + /**
437 799 * Override WordPress wp_title (HIGH PRIORITY)
438 800 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates
439 801 *
440 802 * @param string $title Original title
@@ -441,18 +803,24 @@
441 803 * @param string $sep Title separator
442 804 * @return string Modified title
443 805 */
444 806 public function override_wp_title(string $title, string $sep = ''): string {
807 + if (!$this->metas_enabled()) {
808 + return $title;
809 + }
810 +
445 811 // First priority: Post-specific ThinkRank metadata
446 812 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
447 813 $site_name = get_bloginfo('name');
448 - 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 + );
449 817 }
450 818
451 819 // Second priority: Global SEO templates, Third priority: Site Identity templates
452 820 $generated_title = $this->generate_context_title();
453 821 if ($generated_title) {
454 - return $generated_title;
822 + return self::with_page_suffix($generated_title);
455 823 }
456 824
457 825 return $title;
458 826 }
@@ -457,8 +825,25 @@
457 825 return $title;
458 826 }
459 827
460 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 + /**
461 846 * Output meta description (HIGH PRIORITY)
462 847 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates > WordPress defaults
463 848 *
464 849 * Author archives are skipped entirely: Author_Archives_Manager owns that
@@ -473,22 +858,24 @@
473 858 if (is_author()) {
474 859 return;
475 860 }
476 861
862 + if (!$this->metas_enabled()) {
863 + return;
864 + }
865 +
477 866 $description = $this->get_meta_description();
478 867
479 868 if ($description) {
480 869 // Output main ThinkRank SEO header comment (only once)
481 - static $header_output = false;
482 - if (!$header_output) {
483 - echo "<!-- Search Engine Optimization by ThinkRank - https://thinkrank.ai/ -->\n";
484 - $header_output = true;
485 - }
870 + self::note_opening_comment();
486 871
487 872 // Ensure description is within optimal length (150-160 characters)
488 - if (strlen($description) > 160) {
489 - $description = wp_trim_words($description, 25, '...');
490 - }
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);
491 878
492 879 echo "<!-- ThinkRank SEO Meta Description -->\n";
493 880 echo '<meta name="description" content="' . esc_attr($description) . '" />' . "\n";
494 881 echo "<!-- /ThinkRank SEO Meta Description -->\n";
@@ -529,13 +916,41 @@
529 916
530 917 // Output generator meta tag
531 918 echo '<meta name="generator" content="ThinkRank ' . esc_attr(THINKRANK_VERSION) . '" />' . "\n";
532 919
533 - // Output viewport meta tag if not already present
534 - if (!has_action('wp_head', 'wp_site_icon') || !wp_is_mobile()) {
535 - 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 + }
536 950 }
537 - echo "<!-- /ThinkRank SEO Meta Tags -->\n";
951 +
952 + return $robots;
538 953 }
539 954
540 955 /**
541 956 * Get robots meta content based on context and settings
@@ -544,13 +959,22 @@
544 959 */
545 960 private function get_robots_meta_content(): string {
546 961 $robots = [];
547 962
548 - // 404 and search results must never be indexed, regardless of the
549 - // configured global/post-type directives. Links are still followed so
550 - // 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.
551 967 if (is_404() || is_search()) {
552 - $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);
553 977 return implode(', ', array_unique($robots));
554 978 }
555 979
556 980 // 1. Get global robot meta settings (Base)
@@ -579,8 +1003,24 @@
579 1003 $current_settings = array_merge($current_settings, $global_seo_settings[$post_type]['robots_meta']);
580 1004 }
581 1005 }
582 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 +
583 1023 // Determine Index/Noindex based on merged settings
584 1024 // Priority: if noindex is true, it overrides index
585 1025 if (!empty($current_settings['noindex'])) {
586 1026 $robots[] = 'noindex';
@@ -644,12 +1084,30 @@
644 1084 $robots = $this->apply_post_robots_override(get_the_ID(), $robots, $current_settings);
645 1085 }
646 1086
647 1087 // Check for archive pages (search is handled by the early return above)
648 - 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()) {
649 1094 // Allow indexing of category/tag archives but be more conservative
650 1095 if (is_paged()) {
651 - $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 + }
652 1110 }
653 1111
654 1112 // Honor the global date-archive noindex toggle (written by the
655 1113 // Rank Math/Yoast settings importer). Author archives are handled
@@ -658,8 +1116,27 @@
658 1116 $robots = ['noindex', 'follow'];
659 1117 }
660 1118 }
661 1119
1120 + // 4. Term meta override for taxonomy archives (Overrides everything).
1121 + //
1122 + // Terms had no branch here at all — not a wrong key or a skipped
1123 + // conditional, the lookup simply did not exist — so a category, tag or
1124 + // custom-taxonomy archive saved with noindex still rendered the global
1125 + // default. The stored value read back correctly through the abilities
1126 + // API, which made the setting look applied when it never reached output.
1127 + //
1128 + // Deliberately placed *after* the archive block so it is a real
1129 + // override, matching how a per-post override is final for singular
1130 + // views. Running it earlier would let is_paged() overwrite a term's
1131 + // explicit directives on page 2 of its own archive.
1132 + if (is_category() || is_tag() || is_tax()) {
1133 + $queried = get_queried_object();
1134 + if ($queried instanceof \WP_Term) {
1135 + $robots = $this->apply_term_robots_override($queried->term_id, $robots, $current_settings);
1136 + }
1137 + }
1138 +
662 1139 // Apply filters for customization
663 1140 $robots = apply_filters('thinkrank_robots_meta', $robots);
664 1141
665 1142 // Remove duplicates and implode
@@ -678,20 +1155,69 @@
678 1155 * @param array $current_settings Effective robots flags (global + post type)
679 1156 * @return array Updated robots directive list
680 1157 */
681 1158 private function apply_post_robots_override(int $post_id, array $robots, array $current_settings): array {
682 - if (!(bool) get_post_meta($post_id, '_thinkrank_robots_meta_enabled', true)) {
1159 + return $this->apply_meta_robots_override(
1160 + (bool) get_post_meta($post_id, '_thinkrank_robots_meta_enabled', true),
1161 + (string) get_post_meta($post_id, '_thinkrank_robots_meta', true),
1162 + (string) get_post_meta($post_id, '_thinkrank_advanced_robots_meta', true),
1163 + $robots,
1164 + $current_settings
1165 + );
1166 + }
1167 +
1168 + /**
1169 + * Apply per-term robots overrides on top of the cascaded directives.
1170 + *
1171 + * The term-meta twin of apply_post_robots_override(). Terms store the same
1172 + * three keys with the same shapes — written by the update-term-seo ability
1173 + * and by the Rank Math / Yoast / AIOSEO / SEOPress importer — so the two
1174 + * paths share one engine rather than a second copy that can drift.
1175 + *
1176 + * @since 1.31.0
1177 + *
1178 + * @param int $term_id Term being rendered
1179 + * @param array $robots Directives accumulated so far
1180 + * @param array $current_settings Effective robots flags (global + post type)
1181 + * @return array Updated robots directive list
1182 + */
1183 + private function apply_term_robots_override(int $term_id, array $robots, array $current_settings): array {
1184 + return $this->apply_meta_robots_override(
1185 + (bool) get_term_meta($term_id, '_thinkrank_robots_meta_enabled', true),
1186 + (string) get_term_meta($term_id, '_thinkrank_robots_meta', true),
1187 + (string) get_term_meta($term_id, '_thinkrank_advanced_robots_meta', true),
1188 + $robots,
1189 + $current_settings
1190 + );
1191 + }
1192 +
1193 + /**
1194 + * Rebuild the robots directives from a stored override, whatever holds it.
1195 + *
1196 + * Kept free of get_post_meta()/get_term_meta() so posts and terms cannot
1197 + * diverge: term support was missing entirely because the only override
1198 + * logic lived behind a post-meta read.
1199 + *
1200 + * @since 1.31.0
1201 + *
1202 + * @param bool $enabled Whether the override is switched on
1203 + * @param string $raw_robots JSON robots flags
1204 + * @param string $raw_advanced JSON advanced directives
1205 + * @param array $robots Directives accumulated so far
1206 + * @param array $current_settings Effective robots flags (global + post type)
1207 + * @return array Updated robots directive list
1208 + */
1209 + private function apply_meta_robots_override(bool $enabled, string $raw_robots, string $raw_advanced, array $robots, array $current_settings): array {
1210 + if (!$enabled) {
683 1211 return $robots;
684 1212 }
685 1213
686 - $raw_robots = get_post_meta($post_id, '_thinkrank_robots_meta', true);
687 - $post_robots = is_string($raw_robots) && $raw_robots !== '' ? json_decode($raw_robots, true) : null;
1214 + $post_robots = $raw_robots !== '' ? json_decode($raw_robots, true) : null;
688 1215 if (!is_array($post_robots)) {
689 1216 return $robots;
690 1217 }
691 1218
692 - $raw_advanced = get_post_meta($post_id, '_thinkrank_advanced_robots_meta', true);
693 - $post_advanced = is_string($raw_advanced) && $raw_advanced !== '' ? json_decode($raw_advanced, true) : null;
1219 + $post_advanced = $raw_advanced !== '' ? json_decode($raw_advanced, true) : null;
694 1220
695 1221 $effective = array_merge($current_settings, array_intersect_key($post_robots, array_flip([
696 1222 'index', 'noindex', 'nofollow', 'noarchive', 'noimageindex', 'nosnippet',
697 1223 ])));
@@ -861,9 +1387,9 @@
861 1387 *
862 1388 * @param array $og_tags Open Graph tags array
863 1389 * @return void
864 1390 */
865 - private function output_social_og_tags(array $og_tags): void {
1391 + private function output_social_og_tags(array $og_tags, array $extra_images = []): void {
866 1392 // Honor the thinkrank_og_type filter here too — this "Enhanced" path is
867 1393 // the active OG emitter, so add-ons (e.g. Pro's WooCommerce module which
868 1394 // sets 'product' on product pages) must be applied to it, not only to
869 1395 // output_open_graph_tags().
@@ -907,12 +1433,62 @@
907 1433 echo '<meta property="' . esc_attr($property) . '" content="' . $this->esc_meta_value($property, $content) . '" />' . "\n";
908 1434 }
909 1435 }
910 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 +
911 1444 echo "<!-- /ThinkRank SEO Open Graph Tags -->\n";
912 1445 }
913 1446
914 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 + /**
915 1491 * Output social media Twitter Card tags from Social Meta Manager
916 1492 *
917 1493 * @param array $twitter_tags Twitter Card tags array
918 1494 * @return void
@@ -977,8 +1553,16 @@
977 1553 *
978 1554 * @return void
979 1555 */
980 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 +
981 1565 // Try Social Meta Manager for platform tags
982 1566 if ($this->social_manager) {
983 1567 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
984 1568 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
@@ -1030,8 +1614,23 @@
1030 1614 *
1031 1615 * @return void
1032 1616 */
1033 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 +
1034 1633 // Priority 1: Try Social Meta Manager (Social Media tab settings)
1035 1634 if ($this->social_manager) {
1036 1635 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1037 1636 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
@@ -1052,9 +1651,12 @@
1052 1651 // The Social Meta Manager ran, so it owns Open Graph output. If OG is
1053 1652 // toggled off, emit nothing — do NOT fall through to the basic
1054 1653 // emitter (which would re-add a full OG block despite the toggle).
1055 1654 if (!empty($social_data['og_enabled'])) {
1056 - $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 + );
1057 1659 }
1058 1660 return;
1059 1661 }
1060 1662
@@ -1082,8 +1684,21 @@
1082 1684 (string) get_post_meta($this->current_post_id, '_thinkrank_og_description', true),
1083 1685 $this->current_post_id
1084 1686 );
1085 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);
1086 1701 }
1087 1702
1088 1703 // Get title using priority system: OG override > post-specific > Global SEO > Site Identity > default
1089 1704 $title = '';
@@ -1104,10 +1719,14 @@
1104 1719 $description = $og_description_override;
1105 1720 } else {
1106 1721 $description = $this->get_meta_description();
1107 1722 }
1108 - if (!$description) {
1109 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1723 + // Skipped for a protected post: core answers get_the_excerpt() with its
1724 + // "There is no excerpt because this is a protected post." placeholder,
1725 + // so this is not a leak — but publishing that sentence as the social
1726 + // description is worse than publishing none (#363).
1727 + if (!$description && !$this->is_content_password_protected()) {
1728 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1110 1729 }
1111 1730
1112 1731 $url = is_singular() ? get_permalink() : home_url();
1113 1732 $site_name = $this->site_identity_data && !empty($this->site_identity_data['identity']['site_name'])
@@ -1135,11 +1754,11 @@
1135 1754 $og_type = apply_filters('thinkrank_og_type', $og_type);
1136 1755
1137 1756 echo "<!-- ThinkRank SEO Open Graph Meta Tags -->\n";
1138 1757 echo "<meta property=\"og:type\" content=\"" . esc_attr($og_type) . "\" />\n";
1139 - 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";
1140 1759 echo "<meta property=\"og:description\" content=\"" . esc_attr($description) . "\" />\n";
1141 - 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";
1142 1761 echo "<meta property=\"og:site_name\" content=\"" . esc_attr($site_name) . "\" />\n";
1143 1762 /**
1144 1763 * Filter the og:locale value.
1145 1764 *
@@ -1156,14 +1775,17 @@
1156 1775 $og_locale = (string) apply_filters('thinkrank_og_locale', get_locale());
1157 1776 echo "<meta property=\"og:locale\" content=\"" . esc_attr($og_locale) . "\" />\n";
1158 1777
1159 1778 // Add OG image — per-post override > featured image
1779 + $primary_og_image = '';
1160 1780 if (is_singular() && $this->current_post_id) {
1161 1781 if (!empty($og_image_override)) {
1782 + $primary_og_image = (string) $og_image_override;
1162 1783 echo "<meta property=\"og:image\" content=\"" . esc_url($og_image_override) . "\" />\n";
1163 1784 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($og_image_override) . "\" />\n";
1164 1785 } elseif (has_post_thumbnail($this->current_post_id)) {
1165 1786 $image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
1787 + $primary_og_image = (string) $image_url;
1166 1788 echo "<meta property=\"og:image\" content=\"" . esc_url($image_url) . "\" />\n";
1167 1789 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($image_url) . "\" />\n";
1168 1790
1169 1791 // Get image dimensions and alt text
@@ -1192,8 +1814,30 @@
1192 1814 echo "<meta property=\"og:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n";
1193 1815 }
1194 1816 }
1195 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 +
1196 1840 // Add article specific tags for posts only
1197 1841 if ($og_type === 'article') {
1198 1842 echo '<meta property="article:published_time" content="' . esc_attr(get_the_date('c', $this->current_post_id)) . '" />' . "\n";
1199 1843 echo '<meta property="article:modified_time" content="' . esc_attr(get_the_modified_date('c', $this->current_post_id)) . '" />' . "\n";
@@ -1221,8 +1865,21 @@
1221 1865 *
1222 1866 * @return void
1223 1867 */
1224 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 +
1225 1882 // Priority 1: Try Social Meta Manager (Social Media tab settings)
1226 1883 if ($this->social_manager) {
1227 1884 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1228 1885 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
@@ -1269,8 +1926,14 @@
1269 1926 $twitter_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_twitter_title', true), $pid);
1270 1927 $twitter_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_twitter_description', true), $pid);
1271 1928 $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_og_title', true), $pid);
1272 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);
1273 1936 }
1274 1937
1275 1938 // Title cascade: Twitter override > OG override > Global SEO > Site Identity > default
1276 1939 $title = '';
@@ -1295,10 +1958,14 @@
1295 1958 $description = $og_description_override;
1296 1959 } else {
1297 1960 $description = $this->get_meta_description();
1298 1961 }
1299 - if (!$description) {
1300 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1962 + // Skipped for a protected post: core answers get_the_excerpt() with its
1963 + // "There is no excerpt because this is a protected post." placeholder,
1964 + // so this is not a leak — but publishing that sentence as the social
1965 + // description is worse than publishing none (#363).
1966 + if (!$description && !$this->is_content_password_protected()) {
1967 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1301 1968 }
1302 1969
1303 1970 // Determine card type based on image availability
1304 1971 $card_type = 'summary';
@@ -1307,9 +1974,9 @@
1307 1974 }
1308 1975
1309 1976 echo "<!-- ThinkRank SEO Twitter Card Meta Tags -->\n";
1310 1977 echo '<meta name="twitter:card" content="' . esc_attr($card_type) . '" />' . "\n";
1311 - 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";
1312 1979 echo "<meta name=\"twitter:description\" content=\"" . esc_attr($description) . "\" />\n";
1313 1980
1314 1981 // Add Twitter image with proper fallback priority
1315 1982 $twitter_image_url = $this->get_twitter_image_with_fallback();
@@ -1359,11 +2026,18 @@
1359 2026 }
1360 2027
1361 2028 if (empty($canonical_url)) {
1362 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);
1363 2037 }
1364 2038 } else {
1365 - $canonical_url = $this->get_non_singular_canonical_url();
2039 + $canonical_url = self::get_non_singular_canonical_url();
1366 2040 }
1367 2041
1368 2042 /**
1369 2043 * Filter the canonical URL before output.
@@ -1377,14 +2051,115 @@
1377 2051 if (empty($canonical_url)) {
1378 2052 return;
1379 2053 }
1380 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 +
1381 2060 echo "<!-- ThinkRank SEO Canonical URL -->\n";
1382 2061 echo "<link rel=\"canonical\" href=\"" . esc_url($canonical_url) . "\" />\n";
1383 2062 echo "<!-- /ThinkRank SEO Canonical URL -->\n";
2063 +
2064 + $this->output_pagination_links();
1384 2065 }
1385 2066
1386 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 + /**
1387 2162 * Build the canonical URL for non-singular contexts.
1388 2163 *
1389 2164 * Covers the blog home, post type / taxonomy / author / date archives.
1390 2165 * Search results and 404 pages get no canonical (they are noindexed).
@@ -1392,9 +2167,9 @@
1392 2167 * self-referential rather than pointing at page 1.
1393 2168 *
1394 2169 * @return string Canonical URL or '' when none applies
1395 2170 */
1396 - private function get_non_singular_canonical_url(): string {
2171 + public static function get_non_singular_canonical_url(): string {
1397 2172 if (is_404() || is_search()) {
1398 2173 return '';
1399 2174 }
1400 2175
@@ -1425,17 +2200,89 @@
1425 2200 return '';
1426 2201 }
1427 2202
1428 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 {
1429 2276 $paged = (int) get_query_var('paged');
2277 +
1430 2278 if ($paged > 1) {
1431 - global $wp_rewrite;
1432 - $canonical_url = $wp_rewrite->using_permalinks()
1433 - ? trailingslashit($canonical_url) . user_trailingslashit($wp_rewrite->pagination_base . '/' . $paged, 'paged')
1434 - : add_query_arg('paged', $paged, $canonical_url);
2279 + return $paged;
1435 2280 }
1436 2281
1437 - return $canonical_url;
2282 + $page = (int) get_query_var('page');
2283 +
2284 + return $page > 1 ? $page : 1;
1438 2285 }
1439 2286
1440 2287
1441 2288 /**
@@ -1443,12 +2290,13 @@
1443 2290 *
1444 2291 * @return bool True if has ThinkRank metadata
1445 2292 */
1446 2293 private function has_thinkrank_metadata(): bool {
1447 - if (!is_singular()) {
1448 - return false;
1449 - }
1450 -
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).
1451 2299 return !empty($this->current_metadata['title']) || !empty($this->current_metadata['description']);
1452 2300 }
1453 2301
1454 2302 /**
@@ -1582,12 +2430,21 @@
1582 2430
1583 2431 // Get excerpt
1584 2432 $post = get_post($this->current_post_id);
1585 2433 if ($post) {
1586 - $excerpt = !empty($post->post_excerpt)
1587 - ? $post->post_excerpt
1588 - : wp_trim_words(wp_strip_all_tags($post->post_content), 25, '...');
1589 - $placeholders['%excerpt%'] = $excerpt;
2434 + // An authored post_excerpt is written for public consumption, so
2435 + // it stays. Falling back to the body does not: for a protected
2436 + // post that derivation leaks the gated content through any
2437 + // template containing %excerpt%, and this branch runs BEFORE the
2438 + // derive-from-content priority below, so guarding only that one
2439 + // would leave this path open (#363).
2440 + if (!empty($post->post_excerpt)) {
2441 + $placeholders['%excerpt%'] = $post->post_excerpt;
2442 + } elseif (!$this->is_content_password_protected($post->ID)) {
2443 + $placeholders['%excerpt%'] = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt(
2444 + \ThinkRank\SEO\Builder_Content::visible_content($post)
2445 + );
2446 + }
1590 2447 }
1591 2448
1592 2449 // Get author
1593 2450 $author_id = get_post_field('post_author', $this->current_post_id);
@@ -1647,8 +2504,17 @@
1647 2504 $settings = $this->site_identity_manager->get_settings('site');
1648 2505
1649 2506 switch ($this->current_context) {
1650 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 +
1651 2517 return $settings['homepage_title'] ?? null;
1652 2518 case 'post':
1653 2519 return $settings['post_title'] ?? null;
1654 2520 case 'page':
@@ -1679,12 +2545,18 @@
1679 2545 $settings = $this->site_identity_manager->get_settings('site');
1680 2546 $separator = $this->get_title_separator($settings['title_separator'] ?? 'pipe');
1681 2547
1682 2548 $placeholders = [
1683 - '%site_title%' => $settings['site_name'] ?? get_bloginfo('name'),
1684 - '%site_name%' => $settings['site_name'] ?? get_bloginfo('name'),
1685 - '%site_description%' => $settings['site_description'] ?? get_bloginfo('description'),
1686 - '%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')),
1687 2559 '%separator%' => ' ' . $separator . ' ',
1688 2560 '%sep%' => ' ' . $separator . ' ',
1689 2561 '%date%' => gmdate('F Y'),
1690 2562 ];
@@ -1737,10 +2609,26 @@
1737 2609 $placeholders['%search_term%'] = get_search_query();
1738 2610 break;
1739 2611
1740 2612 case 'archive':
1741 - $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();
1742 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;
1743 2631 }
1744 2632
1745 2633 return $placeholders;
1746 2634 }
@@ -1745,8 +2633,19 @@
1745 2633 return $placeholders;
1746 2634 }
1747 2635
1748 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 + /**
1749 2648 * Process title template with placeholders
1750 2649 *
1751 2650 * @param string $template Template string
1752 2651 * @param array $placeholders Placeholder values
@@ -1783,8 +2682,42 @@
1783 2682 return \ThinkRank\SEO\Site_Identity_Manager::$title_separators[$separator_type]['symbol'] ?? \ThinkRank\SEO\Site_Identity_Manager::$title_separators['pipe']['symbol'];
1784 2683 }
1785 2684
1786 2685 /**
2686 + * Whether a post's body must not be read for a public surface.
2687 + *
2688 + * Deriving metadata from `post_content` publishes that content to everyone
2689 + * who requests the URL — and to every crawler and link-preview unfurler
2690 + * that reads og:description — while the page itself still shows only the
2691 + * password form, so the leak is invisible to the site owner (#363).
2692 + *
2693 + * This is the one thing every content reader should call before touching
2694 + * `post_content` for output. It mirrors core: a visitor who has already
2695 + * entered the correct password sees the body anyway, so nothing is hidden
2696 + * from them here either.
2697 + *
2698 + * @param int|null $post_id Optional. Post ID. Defaults to the current post.
2699 + * @return bool True when the body is password-gated for this visitor.
2700 + */
2701 + private function is_content_password_protected(?int $post_id = null): bool {
2702 + $post_id = $post_id ?? $this->current_post_id;
2703 +
2704 + if (!$post_id) {
2705 + return false;
2706 + }
2707 +
2708 + $post = get_post($post_id);
2709 +
2710 + if (!$post) {
2711 + return false;
2712 + }
2713 +
2714 + // Guarded for the same reason the schema path guards it: this class is
2715 + // also exercised outside a full front-end request.
2716 + return function_exists('post_password_required') && post_password_required($post);
2717 + }
2718 +
2719 + /**
1787 2720 * Get meta description with fallback system
1788 2721 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates > WordPress defaults
1789 2722 *
1790 2723 * @return string|null Meta description or null if none available
@@ -1817,13 +2750,23 @@
1817 2750 return $default_description;
1818 2751 }
1819 2752 }
1820 2753
1821 - // Fourth priority: Generate from content for posts/pages
1822 - if (is_singular() && $this->current_post_id) {
1823 - $post_content = get_post_field('post_content', $this->current_post_id);
2754 + // Fourth priority: Generate from content for posts/pages.
2755 + // Never for a password-protected post — deriving the description from a
2756 + // gated body published its first ~25 words in the page head, and the
2757 + // same value is reused for og:description and twitter:description, so
2758 + // one unguarded read leaked through three tags (#363).
2759 + if (is_singular() && $this->current_post_id && !$this->is_content_password_protected()) {
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);
1824 2767 if ($post_content) {
1825 - $excerpt = wp_trim_words(wp_strip_all_tags($post_content), 25, '...');
2768 + $excerpt = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt((string) $post_content);
1826 2769 if (!empty($excerpt)) {
1827 2770 return $excerpt;
1828 2771 }
1829 2772 }
@@ -1867,11 +2810,13 @@
1867 2810 if ($description === '') {
1868 2811 return null;
1869 2812 }
1870 2813
1871 - if (strlen($description) > 160) {
1872 - $description = wp_trim_words($description, 25, '...');
1873 - }
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);
1874 2819
1875 2820 return $description;
1876 2821 }
1877 2822
@@ -1918,11 +2863,13 @@
1918 2863 $description = preg_replace('/\s+/', ' ', $description);
1919 2864 $description = trim($description);
1920 2865
1921 2866 // Ensure description doesn't exceed recommended length (160 characters)
1922 - if (strlen($description) > 160) {
1923 - $description = wp_trim_words($description, 25, '...');
1924 - }
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);
1925 2872
1926 2873 return $description;
1927 2874 }
1928 2875
@@ -1936,8 +2883,19 @@
1936 2883 public function output_site_schema_markup(): void {
1937 2884 $has_schema_manager_output = false;
1938 2885 $has_website_schema = false;
1939 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 +
1940 2898 // PRIORITY 1: Always output site-wide schemas (Organization, Website, LocalBusiness, Person)
1941 2899 if ($this->schema_manager) {
1942 2900 $site_wide_schemas = $this->schema_manager->get_deployed_schemas('site', null);
1943 2901
@@ -1942,9 +2900,9 @@
1942 2900 $site_wide_schemas = $this->schema_manager->get_deployed_schemas('site', null);
1943 2901
1944 2902 if (!empty($site_wide_schemas)) {
1945 2903 foreach ($site_wide_schemas as $schema_type => $schema_info) {
1946 - $this->output_schema_markup($schema_info['data'], $schema_type, 'Schema Manager');
2904 + Schema_Graph::instance()->add_supporting($schema_info['data'], (string) $schema_type);
1947 2905 }
1948 2906 $has_schema_manager_output = true;
1949 2907 $has_website_schema = isset($site_wide_schemas['WebSite']);
1950 2908 }
@@ -1965,9 +2923,9 @@
1965 2923 */
1966 2924 $website_schema = apply_filters('thinkrank_website_schema', $website_schema);
1967 2925
1968 2926 if (!empty($website_schema)) {
1969 - $this->output_schema_markup($website_schema, 'WebSite', 'Site Identity');
2927 + Schema_Graph::instance()->add_supporting($website_schema, 'WebSite');
1970 2928 }
1971 2929 }
1972 2930
1973 2931 // PRIORITY 2: Also output page-specific schemas (Article, HowTo, FAQ, etc.) on individual posts/pages
@@ -1978,10 +2936,19 @@
1978 2936
1979 2937 $page_specific_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
1980 2938
1981 2939 if (!empty($page_specific_schemas)) {
1982 - // Apply filter for Pro to allow multiple schemas
1983 - // 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 + */
1984 2951 $page_specific_schemas = apply_filters(
1985 2952 'thinkrank_page_schemas_to_render',
1986 2953 $page_specific_schemas,
1987 2954 $context_type,
@@ -1987,21 +2954,25 @@
1987 2954 $context_type,
1988 2955 $context_id
1989 2956 );
1990 2957
1991 - // If still multiple schemas and not Pro, limit to 1 (enforcing free limit)
1992 - $is_pro = \ThinkRank\Core\Plan_Config::is_pro();
1993 - if (!$is_pro && count($page_specific_schemas) > 2) {
1994 - $page_specific_schemas = array_slice($page_specific_schemas, 0, 2, true);
1995 - }
1996 -
1997 2958 foreach ($page_specific_schemas as $schema_type => $schema_info) {
1998 - $this->output_schema_markup($schema_info['data'], $schema_type, 'Schema Manager');
2959 + Schema_Graph::instance()->add_primary($schema_info['data'], (string) $schema_type, 'schema_manager');
1999 2960 }
2000 2961 $has_schema_manager_output = true;
2001 2962 }
2002 2963 }
2003 2964
2965 + // Absorb FAQ content from the post body (FAQ block / Elementor widget)
2966 + // so it merges into the graph's single FAQPage instead of each producer
2967 + // emitting its own competing one.
2968 + if (is_singular()) {
2969 + $queried_post = get_post();
2970 + if ($queried_post instanceof \WP_Post) {
2971 + Schema_Graph::instance()->collect_post_faq($queried_post);
2972 + }
2973 + }
2974 +
2004 2975 // Skip Site Identity fallback if any Schema Manager schemas were output
2005 2976 if ($has_schema_manager_output) {
2006 2977 return;
2007 2978 }
@@ -2020,9 +2991,9 @@
2020 2991
2021 2992 $schema = $this->generate_organization_schema($settings);
2022 2993
2023 2994 if ($schema) {
2024 - $this->output_schema_markup($schema, 'Organization', 'Site Identity');
2995 + Schema_Graph::instance()->add_supporting($schema, 'Organization');
2025 2996 }
2026 2997 }
2027 2998
2028 2999 /**
@@ -2048,38 +3019,42 @@
2048 3019 if (!empty($description)) {
2049 3020 $schema['description'] = $description;
2050 3021 }
2051 3022
2052 - $schema['potentialAction'] = [
2053 - '@type' => 'SearchAction',
2054 - 'target' => [
2055 - '@type' => 'EntryPoint',
2056 - 'urlTemplate' => home_url('/?s={search_term_string}'),
2057 - ],
2058 - 'query-input' => 'required name=search_term_string',
2059 - ];
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 + }
2060 3031
2061 - return $schema;
2062 - }
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);
2063 3039
2064 - /**
2065 - * Output schema markup with consistent formatting
2066 - *
2067 - * @param array $schema_data Schema data
2068 - * @param string $schema_type Schema type name
2069 - * @param string $source Source of schema (Schema Manager, Site Identity, etc.)
2070 - * @return void
2071 - */
2072 - private function output_schema_markup(array $schema_data, string $schema_type, string $source): void {
2073 - if (empty($schema_data)) {
2074 - return;
3040 + if (array_key_exists('website_enable_search', $schema_settings)) {
3041 + $search_enabled = !empty($schema_settings['website_enable_search']);
3042 + }
2075 3043 }
2076 3044
2077 - echo '<!-- ThinkRank ' . esc_html($source) . ': ' . esc_html($schema_type) . ' Schema -->' . "\n";
2078 - echo '<script type="application/ld+json">' . "\n";
2079 - echo wp_json_encode($schema_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . "\n";
2080 - echo '</script>' . "\n";
2081 - echo '<!-- /ThinkRank ' . esc_html($source) . ': ' . esc_html($schema_type) . ' Schema -->' . "\n";
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 +
3056 + return $schema;
2082 3057 }
2083 3058
2084 3059 /**
2085 3060 * Generate organization schema markup
@@ -2275,8 +3250,15 @@
2275 3250 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
2276 3251 return;
2277 3252 }
2278 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 +
2279 3261 $settings = $this->site_identity_manager->get_settings('site');
2280 3262
2281 3263 // Only output if breadcrumbs are enabled
2282 3264 if (empty($settings['breadcrumbs_enabled'])) {
@@ -2282,42 +3264,74 @@
2282 3264 if (empty($settings['breadcrumbs_enabled'])) {
2283 3265 return;
2284 3266 }
2285 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 +
2286 3282 $breadcrumbs = $this->generate_breadcrumbs($settings);
2287 3283
2288 3284 if (!empty($breadcrumbs['schema'])) {
2289 - echo "<!-- ThinkRank SEO Breadcrumb Schema Markup -->\n";
2290 - echo '<script type="application/ld+json">' . "\n";
2291 - echo wp_json_encode($breadcrumbs['schema'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . "\n";
2292 - echo '</script>' . "\n";
2293 - echo "<!-- /ThinkRank SEO Breadcrumb Schema Markup -->\n";
3285 + Schema_Graph::instance()->add_supporting($breadcrumbs['schema'], 'BreadcrumbList');
2294 3286 }
2295 3287 }
2296 3288
2297 3289 /**
3290 + * Emit everything ThinkRank collected for this request as one linked @graph.
3291 + *
3292 + * Runs after every producer has registered (site schema 7, breadcrumbs 8,
3293 + * Global SEO 15), so the graph can arbitrate between them.
3294 + *
3295 + * @since 1.32.0
3296 + * @return void
3297 + */
3298 + public function output_schema_graph(): void {
3299 + Schema_Graph::instance()->render();
3300 + }
3301 +
3302 + /**
2298 3303 * Output closing comment for ThinkRank SEO
2299 3304 *
2300 3305 * @return void
2301 3306 */
2302 3307 public function output_closing_comment(): void {
2303 - // Only output if we've output any SEO content
2304 - static $header_output = false;
2305 - 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) {
2306 3313 echo "<!-- /ThinkRank SEO -->\n";
2307 3314 }
2308 3315 }
2309 3316
2310 3317 /**
2311 - * Check if any SEO content has been output
3318 + * Print the opening ThinkRank comment, once per request.
2312 3319 *
2313 - * @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
2314 3326 */
2315 - private function has_seo_output(): bool {
2316 - // Check if we have meta description or any other SEO data
2317 - return !empty($this->get_meta_description()) ||
2318 - $this->has_thinkrank_metadata() ||
2319 - ($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;
2320 3334 }
2321 3335
2322 3336 /**
2323 3337 * Display breadcrumbs HTML
@@ -2511,8 +3525,151 @@
2511 3525 return $breadcrumbs;
2512 3526 }
2513 3527
2514 3528 /**
3529 + * Serve /llms.txt through PHP so the response declares UTF-8.
3530 + *
3531 + * Cheap guard first: every other front-end request leaves without loading
3532 + * the manager.
3533 + *
3534 + * @since 1.32.0
3535 + *
3536 + * @return void
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 +
3632 + public function maybe_serve_llms_txt(): void {
3633 + if (!$this->is_llms_txt_request()) {
3634 + return;
3635 + }
3636 +
3637 + if (!class_exists('ThinkRank\\SEO\\LLMs_Txt_Manager')) {
3638 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-llms-txt-manager.php';
3639 + }
3640 +
3641 + $manager = new \ThinkRank\SEO\LLMs_Txt_Manager();
3642 + $manager->serve_llms_txt();
3643 + }
3644 +
3645 + /**
3646 + * Whether the current request is for /llms.txt.
3647 + *
3648 + * @since 1.32.0
3649 + *
3650 + * @return bool
3651 + */
3652 + private function is_llms_txt_request(): bool {
3653 + if (empty($_SERVER['REQUEST_URI'])) {
3654 + return false;
3655 + }
3656 +
3657 + $path = wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), PHP_URL_PATH);
3658 + if (!is_string($path) || '' === $path) {
3659 + return false;
3660 + }
3661 +
3662 + // Strip the install's home path so subdirectory installs match too.
3663 + $home_path = (string) wp_parse_url(home_url('/'), PHP_URL_PATH);
3664 + if ('' !== $home_path && '/' !== $home_path && 0 === strpos($path, $home_path)) {
3665 + $path = substr($path, strlen($home_path));
3666 + }
3667 +
3668 + return 'llms.txt' === strtolower(trim($path, '/'));
3669 + }
3670 +
3671 + /**
2515 3672 * Filter WordPress robots.txt output
2516 3673 *
2517 3674 * @param string $output The default robots.txt output
2518 3675 * @param string $is_public Whether the site is public
@@ -2545,8 +3702,190 @@
2545 3702 return $output;
2546 3703 }
2547 3704
2548 3705 /**
3706 + * Disable WordPress core's sitemap while ThinkRank's sitemap is enabled.
3707 + *
3708 + * Prevents the site from publishing two competing sitemap indexes. Core's
3709 + * /wp-sitemap.xml is taken offline (it 404s) and, as a consequence, core
3710 + * stops adding its own "Sitemap:" directive to robots.txt — including on the
3711 + * paths where ThinkRank does not own the robots.txt output.
3712 + *
3713 + * Only ever turns core's sitemap *off*: when ThinkRank's sitemap is disabled
3714 + * the incoming value is returned untouched, so core (or another plugin
3715 + * filtering this) keeps whatever behaviour it already had.
3716 + *
3717 + * @since 1.31.0
3718 + *
3719 + * @param bool $enabled Whether core's sitemap functionality is enabled.
3720 + * @return bool Filtered value.
3721 + */
3722 + public function filter_wp_sitemaps_enabled($enabled): bool {
3723 + return $this->should_disable_core_sitemap() ? false : (bool) $enabled;
3724 + }
3725 +
3726 + /**
3727 + * Redirect the sitemap URLs core owns to the sitemap ThinkRank publishes.
3728 + *
3729 + * Only the *index* route is redirected. Core's per-type children
3730 + * (/wp-sitemap-posts-post-1.xml and friends) are genuinely gone once core is
3731 + * switched off, and a 404 is the honest answer for those; the index is the
3732 + * one URL crawlers and humans actually guess, and the one core's own
3733 + * /sitemap.xml rule funnels into.
3734 + *
3735 + * @since 1.31.0
3736 + *
3737 + * @return void
3738 + */
3739 + public function redirect_core_sitemap_requests(): void {
3740 + if ('index' !== get_query_var('sitemap')) {
3741 + return;
3742 + }
3743 +
3744 + $request_uri = isset($_SERVER['REQUEST_URI'])
3745 + ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI']))
3746 + : '';
3747 +
3748 + $target = $this->resolve_core_sitemap_redirect(
3749 + (string) wp_parse_url($request_uri, PHP_URL_PATH)
3750 + );
3751 +
3752 + if ('' === $target) {
3753 + return;
3754 + }
3755 +
3756 + wp_safe_redirect($target, 301, 'ThinkRank');
3757 + exit;
3758 + }
3759 +
3760 + /**
3761 + * Where a request for one of core's sitemap URLs should be sent, if anywhere.
3762 + *
3763 + * Split out from the hook so the rules are testable without dispatching a
3764 + * request — the caller above is the only part that cannot be (it exits).
3765 + *
3766 + * @since 1.31.0
3767 + *
3768 + * @param string $requested_path Path of the incoming request.
3769 + * @return string Absolute URL to redirect to, or '' to leave the request alone.
3770 + */
3771 + private function resolve_core_sitemap_redirect(string $requested_path): string {
3772 + // Nothing to redirect to unless we have actually taken core offline,
3773 + // which already implies our own sitemap file is on disk.
3774 + if (!$this->should_disable_core_sitemap()) {
3775 + return '';
3776 + }
3777 +
3778 + $target = $this->thinkrank_sitemap_url;
3779 + if ('' === $target) {
3780 + return '';
3781 + }
3782 +
3783 + // Never redirect a URL to itself. A site publishing at /sitemap.xml
3784 + // normally has the web server serve that file before WordPress sees the
3785 + // request, but on a setup where the request does reach PHP this is the
3786 + // difference between a redirect and a loop.
3787 + $destination = (string) wp_parse_url($target, PHP_URL_PATH);
3788 +
3789 + if ('' !== $requested_path && untrailingslashit($requested_path) === untrailingslashit($destination)) {
3790 + return '';
3791 + }
3792 +
3793 + return $target;
3794 + }
3795 +
3796 + /**
3797 + * Whether core's sitemap should be switched off for this site.
3798 + *
3799 + * True when ThinkRank publishes its own sitemap — except in two cases where
3800 + * taking core offline would leave a URL answering nothing:
3801 + *
3802 + * 1. The site is configured to publish *at core's own URL* (the "WordPress
3803 + * Core" preset). Once the static file exists the web server serves it
3804 + * ahead of WordPress anyway, so core can be left alone.
3805 + * 2. ThinkRank's own sitemap file is not on disk yet. Sitemaps here are
3806 + * static files with no dynamic route (see save_sitemap_to_file()), so
3807 + * while the file is missing core's /sitemap.xml -> /wp-sitemap.xml
3808 + * redirect is the only thing answering that URL; suppressing core would
3809 + * turn a recoverable "enabled but not generated" state into a hard 404
3810 + * for crawlers. Core is taken offline as soon as our file appears, so the
3811 + * duplicate-index conflict this filter exists to prevent cannot occur —
3812 + * two indexes are only ever reachable if both are actually published.
3813 + *
3814 + * Once our file does exist, the URLs core stops answering are handed to
3815 + * redirect_core_sitemap_requests() rather than left to 404 — which is why
3816 + * this also resolves the destination.
3817 + *
3818 + * Resolved lazily and memoised: this is consulted from an `init`-time filter
3819 + * on every request, and the underlying settings read is object-cached. The
3820 + * memoisation also keeps the file_exists() call to one per request.
3821 + *
3822 + * @since 1.31.0
3823 + *
3824 + * @return bool True when WordPress core's sitemap should be disabled.
3825 + */
3826 + private function should_disable_core_sitemap(): bool {
3827 + if ($this->thinkrank_sitemap_enabled === null) {
3828 + try {
3829 + // Read-only instance — passing false keeps it from registering a
3830 + // second copy of the save_post/term auto-generation hooks.
3831 + $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3832 + $settings = $generator->get_settings('site');
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 +
3845 + $this->thinkrank_sitemap_enabled = !empty($settings['enabled'])
3846 + && !$this->publishes_at_core_sitemap_url($settings)
3847 + && $can_serve;
3848 +
3849 + if ($this->thinkrank_sitemap_enabled) {
3850 + $this->thinkrank_sitemap_url = $generator->get_primary_sitemap_url($settings);
3851 + }
3852 + } catch (\Exception $e) {
3853 + // Settings unreadable — leave core's sitemap alone rather than
3854 + // removing a working sitemap on the strength of a failed read.
3855 + $this->thinkrank_sitemap_enabled = false;
3856 + $this->thinkrank_sitemap_url = '';
3857 + }
3858 + }
3859 +
3860 + return $this->thinkrank_sitemap_enabled;
3861 + }
3862 +
3863 + /**
3864 + * Whether any configured sitemap URL is WordPress core's own wp-sitemap.xml.
3865 + *
3866 + * @since 1.31.0
3867 + *
3868 + * @param array $settings Sitemap settings.
3869 + * @return bool True when the site publishes at core's sitemap URL.
3870 + */
3871 + private function publishes_at_core_sitemap_url(array $settings): bool {
3872 + foreach ((array) ($settings['sitemap_urls'] ?? []) as $sitemap) {
3873 + if (!is_array($sitemap)) {
3874 + continue;
3875 + }
3876 +
3877 + $path = (string) wp_parse_url((string) ($sitemap['url'] ?? ''), PHP_URL_PATH);
3878 +
3879 + if (ltrim($path, '/') === 'wp-sitemap.xml') {
3880 + return true;
3881 + }
3882 + }
3883 +
3884 + return false;
3885 + }
3886 +
3887 + /**
2549 3888 * Re-sync the physical robots.txt when WordPress's "Discourage search
2550 3889 * engines" setting (blog_public) changes.
2551 3890 *
2552 3891 * Only acts when ThinkRank robots management is enabled AND a physical
@@ -2596,15 +3935,17 @@
2596 3935 if (empty($settings['enabled'])) {
2597 3936 return (string) $url;
2598 3937 }
2599 3938
3939 + $size = (int) $size;
3940 +
2600 3941 // Apple touch icon has its own dedicated setting
2601 - if ((int) $size === 180 && !empty($settings['apple_touch_icon_url'])) {
2602 - 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);
2603 3944 }
2604 3945
2605 3946 if (!empty($settings['favicon_url'])) {
2606 - return esc_url($settings['favicon_url']);
3947 + return $this->resolve_icon_url((string) $settings['favicon_url'], $size);
2607 3948 }
2608 3949
2609 3950 return (string) $url;
2610 3951 }
@@ -2609,8 +3950,178 @@
2609 3950 return (string) $url;
2610 3951 }
2611 3952
2612 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 + /**
2613 4124 * Get breadcrumb items for current page
2614 4125 *
2615 4126 * @param array $settings Breadcrumb settings
2616 4127 * @return array Breadcrumb items
@@ -2638,9 +4149,9 @@
2638 4149 $categories = get_the_category($current_post_id);
2639 4150 if (!empty($categories)) {
2640 4151 $category = $categories[0];
2641 4152 $items[] = [
2642 - 'title' => $category->name,
4153 + 'title' => $this->get_breadcrumb_term_title($category, $settings),
2643 4154 'url' => get_category_link($category->term_id),
2644 4155 'position' => $position++
2645 4156 ];
2646 4157 }
@@ -2645,12 +4156,18 @@
2645 4156 ];
2646 4157 }
2647 4158 }
2648 4159
2649 - // Add current post
2650 - 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) {
2651 4168 $items[] = [
2652 - 'title' => get_the_title($current_post_id),
4169 + 'title' => $this->get_breadcrumb_post_title($current_post_id, $settings),
2653 4170 'url' => get_permalink($current_post_id),
2654 4171 'position' => $position,
2655 4172 'current' => true
2656 4173 ];
@@ -2667,9 +4184,9 @@
2667 4184 while ($parent_id) {
2668 4185 $parent = get_post($parent_id);
2669 4186 if ($parent) {
2670 4187 $parents[] = [
2671 - 'title' => get_the_title($parent->ID),
4188 + 'title' => $this->get_breadcrumb_post_title($parent->ID, $settings),
2672 4189 'url' => get_permalink($parent->ID),
2673 4190 'position' => 0 // Will be set later
2674 4191 ];
2675 4192 $parent_id = $parent->post_parent;
@@ -2687,11 +4204,11 @@
2687 4204 $items[] = $parent;
2688 4205 }
2689 4206
2690 4207 // Add current page
2691 - if (empty($settings['show_current_page']) || $settings['show_current_page']) {
4208 + if ($settings['show_current_page'] ?? true) {
2692 4209 $items[] = [
2693 - 'title' => get_the_title($current_post_id),
4210 + 'title' => $this->get_breadcrumb_post_title($current_post_id, $settings),
2694 4211 'url' => get_permalink($current_post_id),
2695 4212 'position' => $position,
2696 4213 'current' => true
2697 4214 ];
@@ -2707,9 +4224,9 @@
2707 4224 while ($parent_id) {
2708 4225 $parent = get_category($parent_id);
2709 4226 if ($parent && !is_wp_error($parent)) {
2710 4227 $parents[] = [
2711 - 'title' => $parent->name,
4228 + 'title' => $this->get_breadcrumb_term_title($parent, $settings),
2712 4229 'url' => get_category_link($parent->term_id),
2713 4230 'position' => 0 // Will be set later
2714 4231 ];
2715 4232 $parent_id = $parent->parent;
@@ -2727,11 +4244,11 @@
2727 4244 $items[] = $parent;
2728 4245 }
2729 4246
2730 4247 // Add current category
2731 - if (empty($settings['show_current_page']) || $settings['show_current_page']) {
4248 + if ($settings['show_current_page'] ?? true) {
2732 4249 $items[] = [
2733 - 'title' => $category->name,
4250 + 'title' => $this->get_breadcrumb_term_title($category, $settings),
2734 4251 'url' => get_category_link($category->term_id),
2735 4252 'position' => $position,
2736 4253 'current' => true
2737 4254 ];