PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
← All changes | includes/frontend/class-seo-manager.php +805 -85 2.0.2 → 2.10.0 View file →
@@ -38,16 +38,8 @@
38 38 * Current post metadata
39 39 *
40 40 * @var array
41 41 */
42 - /**
43 - * Page-specific schemas the free tier renders on one page.
44 - *
45 - * @since 2.0.1
46 - * @var int
47 - */
48 - private const FREE_PAGE_SCHEMA_LIMIT = 2;
49 -
50 42 private array $current_metadata = [];
51 43
52 44 /**
53 45 * Term ID of the archive being rendered, when the request is a term archive.
@@ -64,8 +56,29 @@
64 56 */
65 57 private ?\ThinkRank\SEO\Site_Identity_Manager $site_identity_manager = null;
66 58
67 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 + /**
68 81 * Social Meta Manager instance
69 82 *
70 83 * @var \ThinkRank\SEO\Social_Meta_Manager|null
71 84 */
@@ -99,8 +112,16 @@
99 112 */
100 113 private ?\ThinkRank\SEO\Image_SEO_Manager $image_seo_manager = null;
101 114
102 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 + /**
103 124 * Current page context
104 125 *
105 126 * @var string
106 127 */
@@ -154,17 +175,22 @@
154 175
155 176 // Initialize Global SEO Schema Output
156 177 $this->initialize_global_seo_schema();
157 178
158 - // Initialize Google Analytics Tracking Manager
159 - $this->initialize_google_analytics_tracking();
160 -
161 179 // Initialize Image SEO Manager
162 180 $this->initialize_image_seo_manager();
163 181
182 + // Initialize External Links Manager (rel=nofollow / target=_blank)
183 + $this->initialize_external_links_manager();
184 +
164 185 // Initialize current post and context data first
165 186 add_action('wp', [$this, 'initialize_current_context']);
166 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 +
167 193 // Use HIGH PRIORITY hooks to override other SEO plugins
168 194 // Priority 1-5 ensures ThinkRank runs before other SEO plugins
169 195
170 196 // Override WordPress title with HIGH priority
@@ -195,8 +221,18 @@
195 221 // the page would emit two <link rel="canonical"> tags on singular views.
196 222 remove_action('wp_head', 'rel_canonical');
197 223 add_action('wp_head', [$this, 'output_canonical_url'], 6);
198 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 +
199 235 // Add Site Identity specific outputs
200 236 add_action('wp_head', [$this, 'output_site_schema_markup'], 7);
201 237 add_action('wp_head', [$this, 'output_breadcrumb_schema'], 8);
202 238 // Late enough that Global_SEO_Schema_Output (priority 15) has registered.
@@ -230,8 +266,16 @@
230 266 // LLMs_Txt_Manager for the static file) guarantees an explicit UTF-8
231 267 // charset. Priority 8 keeps it ahead of redirect_canonical().
232 268 add_action('template_redirect', [$this, 'maybe_serve_llms_txt'], 8);
233 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 +
234 278 // Take WordPress core's own sitemap offline while ThinkRank's is active.
235 279 // Two sitemap indexes on one site is a crawl conflict: core keeps
236 280 // /wp-sitemap.xml served and injects its own "Sitemap:" line into
237 281 // robots.txt (WP_Sitemaps::add_robots, priority 0). Until now that line
@@ -263,8 +307,17 @@
263 307 // Serve the Site Identity favicon through core's site-icon pipeline so
264 308 // wp_site_icon() outputs it on the front-end (and previews pick it up)
265 309 add_filter('get_site_icon_url', [$this, 'filter_site_icon_url'], 10, 2);
266 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 +
267 320 // Process image SEO in content
268 321 add_filter('the_content', [$this, 'filter_content_images'], 99999);
269 322 add_filter('post_thumbnail_html', [$this, 'filter_content_images'], 11, 2);
270 323 add_filter('woocommerce_single_product_image_thumbnail_html', [$this, 'filter_content_images'], 11);
@@ -324,39 +377,73 @@
324 377 }
325 378
326 379 // Initialize Global SEO Schema Output and store reference
327 380 $this->global_seo_schema = new Global_SEO_Schema_Output();
381 + // Let schema reuse the description this class already resolves, so the
382 + // JSON-LD and the meta/og/twitter tags cannot disagree about what the
383 + // page is (#766). Passed as a callback rather than a value: schema is
384 + // built during wp_head, by which point the request context this
385 + // resolution depends on is set, and it must not be captured earlier.
386 + $this->global_seo_schema->set_description_resolver(
387 + fn (): string => (string) $this->get_meta_description()
388 + );
328 389 $this->global_seo_schema->init();
329 390 }
330 391
331 392 /**
332 - * Initialize Google Analytics Tracking Manager
393 + * Initialize Image SEO Manager
333 394 *
334 395 * @return void
335 396 */
336 - private function initialize_google_analytics_tracking(): void {
337 - if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
338 - require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-google-analytics-tracking-manager.php';
397 + private function initialize_image_seo_manager(): void {
398 + if (!class_exists('ThinkRank\\SEO\\Image_SEO_Manager')) {
399 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-image-seo-manager.php';
339 400 }
340 401
341 - // Initialize Google Analytics Tracking Manager
342 - new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
402 + $this->image_seo_manager = new \ThinkRank\SEO\Image_SEO_Manager();
343 403 }
344 404
345 405 /**
346 - * Initialize Image SEO Manager
406 + * Initialize External Links Manager
347 407 *
408 + * @since 2.5.0
348 409 * @return void
349 410 */
350 - private function initialize_image_seo_manager(): void {
351 - if (!class_exists('ThinkRank\\SEO\\Image_SEO_Manager')) {
352 - require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-image-seo-manager.php';
411 + private function initialize_external_links_manager(): void {
412 + if (!class_exists('ThinkRank\\SEO\\External_Links_Manager')) {
413 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-external-links-manager.php';
353 414 }
354 415
355 - $this->image_seo_manager = new \ThinkRank\SEO\Image_SEO_Manager();
416 + $this->external_links_manager = new \ThinkRank\SEO\External_Links_Manager();
356 417 }
357 418
358 419 /**
420 + * Filter rendered content to annotate external links
421 + *
422 + * @since 2.5.0
423 + * @param mixed $content Content to filter; passed through untouched when
424 + * it is not a string.
425 + * @return mixed Filtered content.
426 + */
427 + public function filter_external_links($content) {
428 + // No return type: a filter value another plugin hands through as null
429 + // or an object belongs to whoever set it, and coercing it to '' would
430 + // silently drop their content on the floor.
431 + if (!is_string($content) || $content === '' || !$this->external_links_manager) {
432 + return $content;
433 + }
434 +
435 + // Feeds carry the same markup to a reader we do not control; leave
436 + // them as authored rather than annotating for a context that has no
437 + // browser tab to open.
438 + if (is_feed()) {
439 + return $content;
440 + }
441 +
442 + return $this->external_links_manager->process_content($content);
443 + }
444 +
445 + /**
359 446 * Filter content to inject image SEO attributes
360 447 *
361 448 * @since 1.0.0
362 449 * @param string $content Content to filter
@@ -428,8 +515,40 @@
428 515 $this->load_site_identity_data();
429 516 }
430 517
431 518 /**
519 + * Drop the request's post identity once it has become a 404.
520 + *
521 + * `initialize_current_context()` runs on `wp`, but a request can be turned
522 + * into a 404 after that: `set_404()` on `template_redirect` is the ordinary
523 + * way to refuse a URL that did resolve to a real post, and both core and
524 + * plugins do it — ThinkRank Pro's Markdown for AI refuses an ineligible
525 + * `.md` URL that way. The snapshot still said `post`/`page` and still held
526 + * the post id and its metadata, so the error page shipped that post's meta
527 + * description, focus keywords and — where the social emitters got that far
528 + * — its og:description and twitter:description, all of which a request that
529 + * was a 404 from the start never prints (#655).
530 + *
531 + * Clearing the snapshot rather than special-casing each emitter is what
532 + * makes every consumer agree, including the ones that read
533 + * `$current_metadata` without ever asking what the context is.
534 + *
535 + * @since 2.3.1
536 + *
537 + * @return void
538 + */
539 + public function recheck_404_context(): void {
540 + if (!is_404() || '404' === $this->current_context) {
541 + return;
542 + }
543 +
544 + $this->current_context = '404';
545 + $this->current_post_id = null;
546 + $this->current_term_id = null;
547 + $this->current_metadata = [];
548 + }
549 +
550 + /**
432 551 * Detect current page context
433 552 *
434 553 * @return string Current context type
435 554 */
@@ -554,8 +673,14 @@
554 673 * @param string $title Original title
555 674 * @return string Modified title
556 675 */
557 676 public function override_document_title($title): string {
677 + // A content type with metas switched off keeps whatever title the theme
678 + // and WordPress produce (#660).
679 + if (!$this->metas_enabled()) {
680 + return $title;
681 + }
682 +
558 683 // First priority: Post-specific ThinkRank metadata
559 684 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
560 685 return self::with_page_suffix($this->current_metadata['title']);
561 686 }
@@ -582,9 +707,86 @@
582 707 *
583 708 * @param string $title Resolved title.
584 709 * @return string Title with the page indicator, when there is one.
585 710 */
711 + /**
712 + * The archive's subject, without the label WordPress prefixes it with.
713 + *
714 + * `get_the_archive_title()` returns "Month: September 2026", "Archives:
715 + * Recipes", "Category: Uncategorized" — the label is core's, aimed at an
716 + * archive heading on the page, and it reads badly in a browser tab, an
717 + * og:title or a search result. Category, tag and author contexts already
718 + * avoid it by using the raw name; the generic archive context did not, so
719 + * date, custom-post-type and custom-taxonomy archives carried it (#640).
720 + *
721 + * Removed through core's own `get_the_archive_title_prefix` filter rather
722 + * than by matching the prefix text, because that text is translated and
723 + * differs per archive type — a string comparison would work in English and
724 + * silently stop working everywhere else.
725 + *
726 + * A site that wants a prefix can put one in its title template, where it is
727 + * visible and editable, instead of inheriting one it cannot see.
728 + *
729 + * @since 2.7.0
730 + *
731 + * @return string Archive subject, with markup and the core prefix removed.
732 + */
733 + private static function archive_subject(): string {
734 + $drop_prefix = static function (): string {
735 + return '';
736 + };
737 +
738 + add_filter('get_the_archive_title_prefix', $drop_prefix, 99);
739 +
740 + $title = (string) get_the_archive_title();
741 +
742 + remove_filter('get_the_archive_title_prefix', $drop_prefix, 99);
743 +
744 + // The <span> core wraps the subject in survives the prefix filter.
745 + return trim(wp_strip_all_tags($title));
746 + }
747 +
748 + /**
749 + * Remove HTML from a title that is about to be emitted.
750 + *
751 + * A title carrying markup is broken twice over, in two different ways, and
752 + * both were reaching real pages: inside `<title>` the tags render literally,
753 + * because that element is RCDATA and never parses them; inside `og:title`
754 + * and `twitter:title` they are attribute-escaped, so the reader sees
755 + * `&lt;em&gt;` as visible text (#640).
756 + *
757 + * Applied at the point of emission rather than at each source, so it covers
758 + * every branch that can produce a title — post meta, Global SEO templates,
759 + * Site Identity templates — without each having to remember.
760 + *
761 + * Unconditional rather than a setting: there is no title for which markup is
762 + * the correct output. The filter is the escape hatch for anyone who
763 + * disagrees, and lets a site keep entities it deliberately encoded.
764 + *
765 + * @since 2.7.0
766 + *
767 + * @param string $title Title about to be emitted.
768 + * @return string Title with any markup removed.
769 + */
770 + public static function strip_title_tags(string $title): string {
771 + /**
772 + * Filter whether HTML is stripped from generated titles.
773 + *
774 + * @since 2.7.0
775 + *
776 + * @param bool $strip Whether to strip. Default true.
777 + * @param string $title The title being emitted.
778 + */
779 + if (!apply_filters('thinkrank_strip_title_tags', true, $title)) {
780 + return $title;
781 + }
782 +
783 + return trim(wp_strip_all_tags($title));
784 + }
785 +
586 786 public static function with_page_suffix(string $title): string {
787 + $title = self::strip_title_tags($title);
788 +
587 789 $page = self::current_page_number();
588 790
589 791 if ($page <= 1 || '' === $title) {
590 792 return $title;
@@ -609,8 +811,12 @@
609 811 * @param string $sep Title separator
610 812 * @return string Modified title
611 813 */
612 814 public function override_wp_title(string $title, string $sep = ''): string {
815 + if (!$this->metas_enabled()) {
816 + return $title;
817 + }
818 +
613 819 // First priority: Post-specific ThinkRank metadata
614 820 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
615 821 $site_name = get_bloginfo('name');
616 822 return self::with_page_suffix(
@@ -627,8 +833,25 @@
627 833 return $title;
628 834 }
629 835
630 836 /**
837 + * Whether ThinkRank owns the title and meta description for this request.
838 + *
839 + * Metas are on site-wide by default; the per-content-type matrix can switch
840 + * them off for one content type, in which case ThinkRank stops overriding
841 + * the document title and prints no meta description (#660).
842 + *
843 + * @since 2.5.0
844 + * @return bool
845 + */
846 + private function metas_enabled(): bool {
847 + return \ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
848 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_META,
849 + true
850 + );
851 + }
852 +
853 + /**
631 854 * Output meta description (HIGH PRIORITY)
632 855 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates > WordPress defaults
633 856 *
634 857 * Author archives are skipped entirely: Author_Archives_Manager owns that
@@ -643,8 +866,12 @@
643 866 if (is_author()) {
644 867 return;
645 868 }
646 869
870 + if (!$this->metas_enabled()) {
871 + return;
872 + }
873 +
647 874 $description = $this->get_meta_description();
648 875
649 876 if ($description) {
650 877 // Output main ThinkRank SEO header comment (only once)
@@ -650,11 +877,13 @@
650 877 // Output main ThinkRank SEO header comment (only once)
651 878 self::note_opening_comment();
652 879
653 880 // Ensure description is within optimal length (150-160 characters)
654 - if (strlen($description) > 160) {
655 - $description = wp_trim_words($description, 25, '...');
656 - }
881 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
882 + // CJK description tripped this limit at a third of its length, and
883 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
884 + // English, 25 characters in Thai (#687).
885 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
657 886
658 887 echo "<!-- ThinkRank SEO Meta Description -->\n";
659 888 echo '<meta name="description" content="' . esc_attr($description) . '" />' . "\n";
660 889 echo "<!-- /ThinkRank SEO Meta Description -->\n";
@@ -705,8 +934,34 @@
705 934 echo "<!-- /ThinkRank SEO Meta Tags -->\n";
706 935 }
707 936
708 937 /**
938 + * Build the basic robots directive list from a set of robots flags.
939 + *
940 + * Shared by the search/404 branch of get_robots_meta_content() so those
941 + * pages resolve their directives through the same rules as everything else
942 + * rather than a hardcoded literal.
943 + *
944 + * @since 2.5.0
945 + * @param array $settings Robots flags (index/noindex/nofollow/...).
946 + * @return string[] Directives.
947 + */
948 + private static function build_robots_directives(array $settings): array {
949 + $robots = [];
950 +
951 + $robots[] = !empty($settings['noindex']) ? 'noindex' : 'index';
952 + $robots[] = !empty($settings['nofollow']) ? 'nofollow' : 'follow';
953 +
954 + foreach (['noarchive', 'noimageindex', 'nosnippet'] as $directive) {
955 + if (!empty($settings[$directive])) {
956 + $robots[] = $directive;
957 + }
958 + }
959 +
960 + return $robots;
961 + }
962 +
963 + /**
709 964 * Get robots meta content based on context and settings
710 965 *
711 966 * @return string Robots meta content
712 967 */
@@ -712,13 +967,22 @@
712 967 */
713 968 private function get_robots_meta_content(): string {
714 969 $robots = [];
715 970
716 - // 404 and search results must never be indexed, regardless of the
717 - // configured global/post-type directives. Links are still followed so
718 - // crawlers can discover the rest of the site.
971 + // 404 and search results are noindex/follow by default — the behaviour
972 + // that used to be hardcoded here. It is now settings-driven (#660): the
973 + // Content Type Matrix can give either its own robots directives, and an
974 + // install that never touched them resolves to exactly the old pair.
719 975 if (is_404() || is_search()) {
720 - $robots = apply_filters('thinkrank_robots_meta', ['noindex', 'follow']);
976 + $entity = is_404()
977 + ? \ThinkRank\SEO\Content_Type_Settings::ENTITY_404
978 + : \ThinkRank\SEO\Content_Type_Settings::ENTITY_SEARCH;
979 +
980 + $robots = self::build_robots_directives(
981 + \ThinkRank\SEO\Content_Type_Settings::resolve_robots_meta($entity)
982 + );
983 +
984 + $robots = apply_filters('thinkrank_robots_meta', $robots);
721 985 return implode(', ', array_unique($robots));
722 986 }
723 987
724 988 // 1. Get global robot meta settings (Base)
@@ -747,8 +1011,24 @@
747 1011 $current_settings = array_merge($current_settings, $global_seo_settings[$post_type]['robots_meta']);
748 1012 }
749 1013 }
750 1014
1015 + // 2b. Apply the per-entity directives for the non-singular content
1016 + // types the matrix covers — taxonomy archives plus author and date
1017 + // archives. Terms keep their own per-term override, applied further
1018 + // down so it still wins over the taxonomy-wide value (#660).
1019 + if (!is_singular()) {
1020 + $entity_key = \ThinkRank\SEO\Content_Type_Settings::current_entity_key();
1021 +
1022 + if ($entity_key !== null) {
1023 + $entity_settings = \ThinkRank\SEO\Content_Type_Settings::get_entity_settings($entity_key);
1024 +
1025 + if (!empty($entity_settings['robots_meta_enabled']) && is_array($entity_settings['robots_meta'] ?? null)) {
1026 + $current_settings = array_merge($current_settings, $entity_settings['robots_meta']);
1027 + }
1028 + }
1029 + }
1030 +
751 1031 // Determine Index/Noindex based on merged settings
752 1032 // Priority: if noindex is true, it overrides index
753 1033 if (!empty($current_settings['noindex'])) {
754 1034 $robots[] = 'noindex';
@@ -1115,9 +1395,9 @@
1115 1395 *
1116 1396 * @param array $og_tags Open Graph tags array
1117 1397 * @return void
1118 1398 */
1119 - private function output_social_og_tags(array $og_tags): void {
1399 + private function output_social_og_tags(array $og_tags, array $extra_images = []): void {
1120 1400 // Honor the thinkrank_og_type filter here too — this "Enhanced" path is
1121 1401 // the active OG emitter, so add-ons (e.g. Pro's WooCommerce module which
1122 1402 // sets 'product' on product pages) must be applied to it, not only to
1123 1403 // output_open_graph_tags().
@@ -1161,12 +1441,62 @@
1161 1441 echo '<meta property="' . esc_attr($property) . '" content="' . $this->esc_meta_value($property, $content) . '" />' . "\n";
1162 1442 }
1163 1443 }
1164 1444
1445 + // Alternatives, after the primary and everything belonging to it.
1446 + // Order is the whole point: a consumer reads og:image tags in document
1447 + // order and treats the first as primary, and a structured property
1448 + // attaches to the most recently declared image — so each alternative's
1449 + // companions have to follow its own URL, not be grouped at the end.
1450 + self::output_extra_og_images($extra_images);
1451 +
1165 1452 echo "<!-- /ThinkRank SEO Open Graph Tags -->\n";
1166 1453 }
1167 1454
1168 1455 /**
1456 + * Emit the secondary og:image tags a page offers.
1457 + *
1458 + * Shared by the enhanced and basic emitters so both describe an
1459 + * alternative image the same way (#636).
1460 + *
1461 + * @since 2.7.0
1462 + *
1463 + * @param array $images Each with url, and width/height/type/alt where known.
1464 + * @return void
1465 + */
1466 + private static function output_extra_og_images(array $images): void {
1467 + foreach ($images as $image) {
1468 + $url = isset($image['url']) ? (string) $image['url'] : '';
1469 +
1470 + if ('' === $url) {
1471 + continue;
1472 + }
1473 +
1474 + echo '<meta property="og:image" content="' . esc_url($url) . '" />' . "\n";
1475 +
1476 + if (strpos($url, 'https://') === 0) {
1477 + echo '<meta property="og:image:secure_url" content="' . esc_url($url) . '" />' . "\n";
1478 + }
1479 +
1480 + // Only what is actually known: a dimension guessed for a remote
1481 + // image is a number a consumer lays a card out with before it has
1482 + // fetched the file.
1483 + if (!empty($image['width']) && !empty($image['height'])) {
1484 + echo '<meta property="og:image:width" content="' . esc_attr((string) $image['width']) . '" />' . "\n";
1485 + echo '<meta property="og:image:height" content="' . esc_attr((string) $image['height']) . '" />' . "\n";
1486 + }
1487 +
1488 + if (!empty($image['type'])) {
1489 + echo '<meta property="og:image:type" content="' . esc_attr((string) $image['type']) . '" />' . "\n";
1490 + }
1491 +
1492 + if (!empty($image['alt'])) {
1493 + echo '<meta property="og:image:alt" content="' . esc_attr((string) $image['alt']) . '" />' . "\n";
1494 + }
1495 + }
1496 + }
1497 +
1498 + /**
1169 1499 * Output social media Twitter Card tags from Social Meta Manager
1170 1500 *
1171 1501 * @param array $twitter_tags Twitter Card tags array
1172 1502 * @return void
@@ -1231,8 +1561,16 @@
1231 1561 *
1232 1562 * @return void
1233 1563 */
1234 1564 public function output_platform_meta_tags(): void {
1565 + // Same reasoning as the Open Graph and Twitter emitters: an error page
1566 + // has no shareable identity, and passing '404' through as a social
1567 + // context asks the manager for settings that describe a page which does
1568 + // not exist. Guarding all three keeps them from disagreeing.
1569 + if ($this->current_context === '404') {
1570 + return;
1571 + }
1572 +
1235 1573 // Try Social Meta Manager for platform tags
1236 1574 if ($this->social_manager) {
1237 1575 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1238 1576 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
@@ -1284,8 +1622,17 @@
1284 1622 *
1285 1623 * @return void
1286 1624 */
1287 1625 public function output_open_graph_tags(): void {
1626 + // Per-content-type Open Graph switch. 'inherit' (the default) keeps the
1627 + // site-wide Social Media setting, which the emitters below read (#660).
1628 + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
1629 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_OPEN_GRAPH,
1630 + true
1631 + )) {
1632 + return;
1633 + }
1634 +
1288 1635 // An error page has no shareable identity. Emitting Open Graph here
1289 1636 // advertised the homepage as the og:url of a URL that does not exist.
1290 1637 if ($this->current_context === '404') {
1291 1638 return;
@@ -1312,9 +1659,12 @@
1312 1659 // The Social Meta Manager ran, so it owns Open Graph output. If OG is
1313 1660 // toggled off, emit nothing — do NOT fall through to the basic
1314 1661 // emitter (which would re-add a full OG block despite the toggle).
1315 1662 if (!empty($social_data['og_enabled'])) {
1316 - $this->output_social_og_tags($social_data['og_tags']);
1663 + $this->output_social_og_tags(
1664 + $social_data['og_tags'],
1665 + $social_data['og_extra_images'] ?? []
1666 + );
1317 1667 }
1318 1668 return;
1319 1669 }
1320 1670
@@ -1382,9 +1732,9 @@
1382 1732 // "There is no excerpt because this is a protected post." placeholder,
1383 1733 // so this is not a leak — but publishing that sentence as the social
1384 1734 // description is worse than publishing none (#363).
1385 1735 if (!$description && !$this->is_content_password_protected()) {
1386 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1736 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1387 1737 }
1388 1738
1389 1739 $url = is_singular() ? get_permalink() : home_url();
1390 1740 $site_name = $this->site_identity_data && !empty($this->site_identity_data['identity']['site_name'])
@@ -1412,11 +1762,11 @@
1412 1762 $og_type = apply_filters('thinkrank_og_type', $og_type);
1413 1763
1414 1764 echo "<!-- ThinkRank SEO Open Graph Meta Tags -->\n";
1415 1765 echo "<meta property=\"og:type\" content=\"" . esc_attr($og_type) . "\" />\n";
1416 - echo "<meta property=\"og:title\" content=\"" . esc_attr($title) . "\" />\n";
1766 + echo "<meta property=\"og:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1417 1767 echo "<meta property=\"og:description\" content=\"" . esc_attr($description) . "\" />\n";
1418 - echo "<meta property=\"og:url\" content=\"" . esc_url($url) . "\" />\n";
1768 + echo "<meta property=\"og:url\" content=\"" . esc_url(\ThinkRank\SEO\Url_Scheme::apply($url)) . "\" />\n";
1419 1769 echo "<meta property=\"og:site_name\" content=\"" . esc_attr($site_name) . "\" />\n";
1420 1770 /**
1421 1771 * Filter the og:locale value.
1422 1772 *
@@ -1433,14 +1783,17 @@
1433 1783 $og_locale = (string) apply_filters('thinkrank_og_locale', get_locale());
1434 1784 echo "<meta property=\"og:locale\" content=\"" . esc_attr($og_locale) . "\" />\n";
1435 1785
1436 1786 // Add OG image — per-post override > featured image
1787 + $primary_og_image = '';
1437 1788 if (is_singular() && $this->current_post_id) {
1438 1789 if (!empty($og_image_override)) {
1790 + $primary_og_image = (string) $og_image_override;
1439 1791 echo "<meta property=\"og:image\" content=\"" . esc_url($og_image_override) . "\" />\n";
1440 1792 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($og_image_override) . "\" />\n";
1441 1793 } elseif (has_post_thumbnail($this->current_post_id)) {
1442 1794 $image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
1795 + $primary_og_image = (string) $image_url;
1443 1796 echo "<meta property=\"og:image\" content=\"" . esc_url($image_url) . "\" />\n";
1444 1797 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($image_url) . "\" />\n";
1445 1798
1446 1799 // Get image dimensions and alt text
@@ -1469,8 +1822,30 @@
1469 1822 echo "<meta property=\"og:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n";
1470 1823 }
1471 1824 }
1472 1825
1826 + // Alternatives, same as the enhanced emitter above. This path only
1827 + // runs when the Social Meta Manager is unavailable, but the issue
1828 + // reported against it (#636) and a site that lands here should not
1829 + // silently lose a feature it switched on.
1830 + if (!empty($primary_og_image)) {
1831 + $social_settings = $this->social_manager
1832 + ? $this->social_manager->get_settings(
1833 + $this->current_context === 'homepage' ? 'site' : $this->current_context,
1834 + $this->current_post_id
1835 + )
1836 + : [];
1837 +
1838 + if (!empty($social_settings['og_multiple_images'])) {
1839 + self::output_extra_og_images(
1840 + \ThinkRank\SEO\Social_Images::additional(
1841 + (int) $this->current_post_id,
1842 + $primary_og_image
1843 + )
1844 + );
1845 + }
1846 + }
1847 +
1473 1848 // Add article specific tags for posts only
1474 1849 if ($og_type === 'article') {
1475 1850 echo '<meta property="article:published_time" content="' . esc_attr(get_the_date('c', $this->current_post_id)) . '" />' . "\n";
1476 1851 echo '<meta property="article:modified_time" content="' . esc_attr(get_the_modified_date('c', $this->current_post_id)) . '" />' . "\n";
@@ -1498,8 +1873,16 @@
1498 1873 *
1499 1874 * @return void
1500 1875 */
1501 1876 public function output_twitter_card_tags(): void {
1877 + // Per-content-type Twitter card switch; see output_open_graph_tags().
1878 + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
1879 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_TWITTER,
1880 + true
1881 + )) {
1882 + return;
1883 + }
1884 +
1502 1885 // Same reasoning as the Open Graph block: nothing on a 404 is shareable.
1503 1886 if ($this->current_context === '404') {
1504 1887 return;
1505 1888 }
@@ -1588,9 +1971,9 @@
1588 1971 // "There is no excerpt because this is a protected post." placeholder,
1589 1972 // so this is not a leak — but publishing that sentence as the social
1590 1973 // description is worse than publishing none (#363).
1591 1974 if (!$description && !$this->is_content_password_protected()) {
1592 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1975 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1593 1976 }
1594 1977
1595 1978 // Determine card type based on image availability
1596 1979 $card_type = 'summary';
@@ -1599,9 +1982,9 @@
1599 1982 }
1600 1983
1601 1984 echo "<!-- ThinkRank SEO Twitter Card Meta Tags -->\n";
1602 1985 echo '<meta name="twitter:card" content="' . esc_attr($card_type) . '" />' . "\n";
1603 - echo "<meta name=\"twitter:title\" content=\"" . esc_attr($title) . "\" />\n";
1986 + echo "<meta name=\"twitter:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1604 1987 echo "<meta name=\"twitter:description\" content=\"" . esc_attr($description) . "\" />\n";
1605 1988
1606 1989 // Add Twitter image with proper fallback priority
1607 1990 $twitter_image_url = $this->get_twitter_image_with_fallback();
@@ -1676,8 +2059,13 @@
1676 2059 if (empty($canonical_url)) {
1677 2060 return;
1678 2061 }
1679 2062
2063 + // After the filter, so a canonical an add-on supplied is normalized
2064 + // too — and a cross-domain one is left alone, since Url_Scheme only
2065 + // touches URLs on this site's own host.
2066 + $canonical_url = \ThinkRank\SEO\Url_Scheme::apply($canonical_url);
2067 +
1680 2068 echo "<!-- ThinkRank SEO Canonical URL -->\n";
1681 2069 echo "<link rel=\"canonical\" href=\"" . esc_url($canonical_url) . "\" />\n";
1682 2070 echo "<!-- /ThinkRank SEO Canonical URL -->\n";
1683 2071
@@ -1724,9 +2112,9 @@
1724 2112
1725 2113 if ($current > 1) {
1726 2114 printf(
1727 2115 "<link rel=\"prev\" href=\"%s\" />\n",
1728 - esc_url(self::with_pagination($base, $current - 1))
2116 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current - 1)))
1729 2117 );
1730 2118 }
1731 2119
1732 2120 if ($current < $total) {
@@ -1731,9 +2119,9 @@
1731 2119
1732 2120 if ($current < $total) {
1733 2121 printf(
1734 2122 "<link rel=\"next\" href=\"%s\" />\n",
1735 - esc_url(self::with_pagination($base, $current + 1))
2123 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current + 1)))
1736 2124 );
1737 2125 }
1738 2126 }
1739 2127
@@ -2060,9 +2448,9 @@
2060 2448 if (!empty($post->post_excerpt)) {
2061 2449 $placeholders['%excerpt%'] = $post->post_excerpt;
2062 2450 } elseif (!$this->is_content_password_protected($post->ID)) {
2063 2451 $placeholders['%excerpt%'] = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt(
2064 - (string) $post->post_content
2452 + \ThinkRank\SEO\Builder_Content::visible_content($post)
2065 2453 );
2066 2454 }
2067 2455 }
2068 2456
@@ -2233,9 +2621,9 @@
2233 2621 // Stripped: get_the_archive_title() wraps its subject in a
2234 2622 // <span>, and this placeholder feeds the document <title> as
2235 2623 // well as og:title and twitter:title — a date archive rendered
2236 2624 // as "Month: <span>August 2026</span> | Site".
2237 - $placeholders['%archive_title%'] = wp_strip_all_tags((string) get_the_archive_title());
2625 + $placeholders['%archive_title%'] = self::archive_subject();
2238 2626 break;
2239 2627
2240 2628 case 'homepage':
2241 2629 // The page template resolved for a static posts page needs the
@@ -2376,9 +2764,15 @@
2376 2764 // gated body published its first ~25 words in the page head, and the
2377 2765 // same value is reused for og:description and twitter:description, so
2378 2766 // one unguarded read leaked through three tags (#363).
2379 2767 if (is_singular() && $this->current_post_id && !$this->is_content_password_protected()) {
2380 - $post_content = get_post_field('post_content', $this->current_post_id);
2768 + // Not the raw column: a Bricks page discards `post_content`, so
2769 + // whatever is still stored there is invisible — and this one value
2770 + // becomes the meta, og: and twitter: descriptions (#651).
2771 + $described = get_post($this->current_post_id);
2772 + $post_content = $described instanceof \WP_Post
2773 + ? \ThinkRank\SEO\Builder_Content::visible_content($described)
2774 + : get_post_field('post_content', $this->current_post_id);
2381 2775 if ($post_content) {
2382 2776 $excerpt = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt((string) $post_content);
2383 2777 if (!empty($excerpt)) {
2384 2778 return $excerpt;
@@ -2424,11 +2818,13 @@
2424 2818 if ($description === '') {
2425 2819 return null;
2426 2820 }
2427 2821
2428 - if (strlen($description) > 160) {
2429 - $description = wp_trim_words($description, 25, '...');
2430 - }
2822 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2823 + // CJK description tripped this limit at a third of its length, and
2824 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2825 + // English, 25 characters in Thai (#687).
2826 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2431 2827
2432 2828 return $description;
2433 2829 }
2434 2830
@@ -2475,11 +2871,13 @@
2475 2871 $description = preg_replace('/\s+/', ' ', $description);
2476 2872 $description = trim($description);
2477 2873
2478 2874 // Ensure description doesn't exceed recommended length (160 characters)
2479 - if (strlen($description) > 160) {
2480 - $description = wp_trim_words($description, 25, '...');
2481 - }
2875 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2876 + // CJK description tripped this limit at a third of its length, and
2877 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2878 + // English, 25 characters in Thai (#687).
2879 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2482 2880
2483 2881 return $description;
2484 2882 }
2485 2883
@@ -2546,9 +2944,19 @@
2546 2944
2547 2945 $page_specific_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
2548 2946
2549 2947 if (!empty($page_specific_schemas)) {
2550 - // Apply filter for Pro to allow multiple schemas
2948 + // Every deployed schema is rendered, on every plan. How many a
2949 + // page carries is decided when schemas are activated in the
2950 + // editor, not trimmed here by plan (#673).
2951 +
2952 + /**
2953 + * Filter the page-specific schemas rendered on the current page.
2954 + *
2955 + * @param array $page_specific_schemas Deployed schemas keyed by schema type.
2956 + * @param string $context_type Context type (post, page, product, site).
2957 + * @param int $context_id Post ID.
2958 + */
2551 2959 $page_specific_schemas = apply_filters(
2552 2960 'thinkrank_page_schemas_to_render',
2553 2961 $page_specific_schemas,
2554 2962 $context_type,
@@ -2554,29 +2962,22 @@
2554 2962 $context_type,
2555 2963 $context_id
2556 2964 );
2557 2965
2558 - // Free tier renders at most self::FREE_PAGE_SCHEMA_LIMIT
2559 - // page-specific schemas; Pro renders all of them.
2560 - //
2561 - // Both comments here used to say the free limit was 1 while the
2562 - // code allowed 2 (#405). The number the code enforces is what
2563 - // has shipped, so that is what stands — lowering it would take
2564 - // a schema away from every free site on upgrade — and it now
2565 - // lives in one named place instead of twice in prose and twice
2566 - // in a literal.
2567 - if (!\ThinkRank\Core\Plan_Config::is_pro()
2568 - && count($page_specific_schemas) > self::FREE_PAGE_SCHEMA_LIMIT) {
2569 - $page_specific_schemas = array_slice(
2570 - $page_specific_schemas,
2571 - 0,
2572 - self::FREE_PAGE_SCHEMA_LIMIT,
2573 - true
2574 - );
2575 - }
2966 + // A deployed node is a snapshot from Deploy time and outranks
2967 + // the automatic node, so page and article types would publish
2968 + // a frozen excerpt instead of the description the head
2969 + // resolves. Give them the live one, as the automatic node has.
2970 + $context_post = get_post($context_id);
2576 2971
2577 2972 foreach ($page_specific_schemas as $schema_type => $schema_info) {
2578 - Schema_Graph::instance()->add_primary($schema_info['data'], (string) $schema_type, 'schema_manager');
2973 + $node = $schema_info['data'];
2974 +
2975 + if ($this->global_seo_schema && $context_post instanceof \WP_Post) {
2976 + $node = $this->global_seo_schema->refresh_deployed_description($node, (string) $schema_type, $context_post);
2977 + }
2978 +
2979 + Schema_Graph::instance()->add_primary($node, (string) $schema_type, 'schema_manager');
2579 2980 }
2580 2981 $has_schema_manager_output = true;
2581 2982 }
2582 2983 }
@@ -2634,21 +3035,49 @@
2634 3035 'url' => home_url('/'),
2635 3036 ];
2636 3037
2637 3038 $description = !empty($settings['site_description']) ? $settings['site_description'] : get_bloginfo('description');
3039 + // The tagline is stored esc_html()'d by sanitize_option(), so a site
3040 + // called "Fish & Chips" published `&amp;` literally in its WebSite
3041 + // node; nothing decodes JSON-LD downstream.
3042 + $description = \ThinkRank\Core\Seo_Text::normalize_schema_text((string) $description);
2638 3043 if (!empty($description)) {
2639 3044 $schema['description'] = $description;
2640 3045 }
2641 3046
2642 - $schema['potentialAction'] = [
2643 - '@type' => 'SearchAction',
2644 - 'target' => [
2645 - '@type' => 'EntryPoint',
2646 - 'urlTemplate' => home_url('/?s={search_term_string}'),
2647 - ],
2648 - 'query-input' => 'required name=search_term_string',
2649 - ];
3047 + // Site Identity has accepted an alternate name since the setup wizard
3048 + // shipped, and the MCP ability describes it as "published as schema
3049 + // alternateName" — but no producer ever read it, so the promise was
3050 + // false and every imported Yoast/Rank Math value sat unused (#692).
3051 + $alternate_name = \ThinkRank\SEO\Site_Identity_Manager::alternate_name_for_schema($settings['alternate_name'] ?? null);
3052 + if (null !== $alternate_name) {
3053 + $schema['alternateName'] = $alternate_name;
3054 + }
2650 3055
3056 + // The sitelinks searchbox switch was honoured only for a deployed
3057 + // WebSite row; this live fallback added potentialAction unconditionally,
3058 + // so website_enable_search = 0 still shipped the SearchAction (#688).
3059 + // Absent means not configured, which stays enabled.
3060 + $search_enabled = true;
3061 + if ($this->schema_manager) {
3062 + $schema_settings = $this->schema_manager->get_settings('site', null);
3063 +
3064 + if (array_key_exists('website_enable_search', $schema_settings)) {
3065 + $search_enabled = !empty($schema_settings['website_enable_search']);
3066 + }
3067 + }
3068 +
3069 + if ($search_enabled) {
3070 + $schema['potentialAction'] = [
3071 + '@type' => 'SearchAction',
3072 + 'target' => [
3073 + '@type' => 'EntryPoint',
3074 + 'urlTemplate' => home_url('/?s={search_term_string}'),
3075 + ],
3076 + 'query-input' => 'required name=search_term_string',
3077 + ];
3078 + }
3079 +
2651 3080 return $schema;
2652 3081 }
2653 3082
2654 3083 /**
@@ -2859,8 +3288,22 @@
2859 3288 if (empty($settings['breadcrumbs_enabled'])) {
2860 3289 return;
2861 3290 }
2862 3291
3292 + // Schema Manager's own breadcrumb switch. Only Site Identity's
3293 + // breadcrumbs_enabled was consulted here, so enable_breadcrumbs_schema
3294 + // = 0 removed a deployed BreadcrumbList row and left this live one
3295 + // emitting the node anyway (#688). Absent means not configured, which
3296 + // stays enabled.
3297 + if ($this->schema_manager) {
3298 + $schema_settings = $this->schema_manager->get_settings('site', null);
3299 +
3300 + if (array_key_exists('enable_breadcrumbs_schema', $schema_settings)
3301 + && empty($schema_settings['enable_breadcrumbs_schema'])) {
3302 + return;
3303 + }
3304 + }
3305 +
2863 3306 $breadcrumbs = $this->generate_breadcrumbs($settings);
2864 3307
2865 3308 if (!empty($breadcrumbs['schema'])) {
2866 3309 Schema_Graph::instance()->add_supporting($breadcrumbs['schema'], 'BreadcrumbList');
@@ -3115,8 +3558,102 @@
3115 3558 * @since 1.32.0
3116 3559 *
3117 3560 * @return void
3118 3561 */
3562 + /**
3563 + * Serve a ThinkRank sitemap document for this request, when it is one.
3564 + *
3565 + * Only acts in dynamic delivery mode. In static mode a real file exists and
3566 + * the web server returns it without WordPress ever loading, so answering
3567 + * here as well would mean two sources for the same bytes.
3568 + *
3569 + * @since 2.9.0
3570 + *
3571 + * @return void
3572 + */
3573 + public function maybe_serve_sitemap(): void {
3574 + $filename = $this->requested_sitemap_filename();
3575 + if ('' === $filename) {
3576 + return;
3577 + }
3578 +
3579 + try {
3580 + // Read-only instance: passing false keeps it from registering a
3581 + // second copy of the auto-generation hooks.
3582 + $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3583 + $settings = $generator->get_settings('site');
3584 +
3585 + if (empty($settings['enabled'])) {
3586 + return;
3587 + }
3588 +
3589 + if ('dynamic' !== $generator->resolve_delivery_mode($settings)) {
3590 + return;
3591 + }
3592 +
3593 + if (!$generator->publishes_document_name($filename, $settings)) {
3594 + return;
3595 + }
3596 +
3597 + $xml = $generator->render_document($filename, $settings);
3598 + } catch (\Throwable $e) {
3599 + // A failed render must not replace the sitemap with a fatal. Leave
3600 + // the request alone so WordPress answers as it otherwise would.
3601 + return;
3602 + }
3603 +
3604 + if (!is_string($xml) || '' === trim($xml)) {
3605 + return;
3606 + }
3607 +
3608 + status_header(200);
3609 + header('Content-Type: application/xml; charset=UTF-8');
3610 + header('X-Robots-Tag: noindex, follow', true);
3611 +
3612 + // Built XML, escaped by the builders as they assemble it; escaping the
3613 + // document here would corrupt it.
3614 + echo $xml; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
3615 + exit;
3616 + }
3617 +
3618 + /**
3619 + * The sitemap file name this request is asking for, if it looks like one.
3620 + *
3621 + * Deliberately a cheap shape test. Whether the site actually publishes the
3622 + * name is settled by the caller against the generator, so that a request
3623 + * for someone else's sitemap is never answered here.
3624 + *
3625 + * @since 2.9.0
3626 + *
3627 + * @return string File name, or '' when this is not a sitemap request.
3628 + */
3629 + private function requested_sitemap_filename(): string {
3630 + if (empty($_SERVER['REQUEST_URI'])) {
3631 + return '';
3632 + }
3633 +
3634 + $path = wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), PHP_URL_PATH);
3635 + if (!is_string($path) || '' === $path) {
3636 + return '';
3637 + }
3638 +
3639 + // Strip the install's home path so subdirectory installs match too.
3640 + $home_path = (string) wp_parse_url(home_url('/'), PHP_URL_PATH);
3641 + if ('' !== $home_path && '/' !== $home_path && 0 === strpos($path, $home_path)) {
3642 + $path = substr($path, strlen($home_path));
3643 + }
3644 +
3645 + $candidate = strtolower(trim($path, '/'));
3646 +
3647 + // One path segment ending in .xml. Anything nested is not a file we
3648 + // publish to the web root.
3649 + if ('' === $candidate || strpos($candidate, '/') !== false) {
3650 + return '';
3651 + }
3652 +
3653 + return substr($candidate, -4) === '.xml' ? $candidate : '';
3654 + }
3655 +
3119 3656 public function maybe_serve_llms_txt(): void {
3120 3657 if (!$this->is_llms_txt_request()) {
3121 3658 return;
3122 3659 }
@@ -3317,11 +3854,22 @@
3317 3854 // second copy of the save_post/term auto-generation hooks.
3318 3855 $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3319 3856 $settings = $generator->get_settings('site');
3320 3857
3858 + // "Can ThinkRank actually answer its sitemap URL right now?" In
3859 + // static mode that means the file is on disk; in dynamic mode
3860 + // maybe_serve_sitemap() answers it, so there is nothing to look
3861 + // for. Keeping the file test as the only answer would have left
3862 + // core's sitemap in place on every dynamic site, which is the
3863 + // crawl conflict this suppression exists to prevent (#752).
3864 + // The #346 behaviour is unchanged: a static site with nothing
3865 + // published still falls through to core rather than 404ing.
3866 + $can_serve = 'dynamic' === $generator->resolve_delivery_mode($settings)
3867 + || $generator->primary_sitemap_file_exists($settings);
3868 +
3321 3869 $this->thinkrank_sitemap_enabled = !empty($settings['enabled'])
3322 3870 && !$this->publishes_at_core_sitemap_url($settings)
3323 - && $generator->primary_sitemap_file_exists($settings);
3871 + && $can_serve;
3324 3872
3325 3873 if ($this->thinkrank_sitemap_enabled) {
3326 3874 $this->thinkrank_sitemap_url = $generator->get_primary_sitemap_url($settings);
3327 3875 }
@@ -3411,15 +3959,17 @@
3411 3959 if (empty($settings['enabled'])) {
3412 3960 return (string) $url;
3413 3961 }
3414 3962
3963 + $size = (int) $size;
3964 +
3415 3965 // Apple touch icon has its own dedicated setting
3416 - if ((int) $size === 180 && !empty($settings['apple_touch_icon_url'])) {
3417 - return esc_url($settings['apple_touch_icon_url']);
3966 + if ($size === 180 && !empty($settings['apple_touch_icon_url'])) {
3967 + return $this->resolve_icon_url((string) $settings['apple_touch_icon_url'], $size);
3418 3968 }
3419 3969
3420 3970 if (!empty($settings['favicon_url'])) {
3421 - return esc_url($settings['favicon_url']);
3971 + return $this->resolve_icon_url((string) $settings['favicon_url'], $size);
3422 3972 }
3423 3973
3424 3974 return (string) $url;
3425 3975 }
@@ -3424,8 +3974,178 @@
3424 3974 return (string) $url;
3425 3975 }
3426 3976
3427 3977 /**
3978 + * Whether breadcrumb labels should prefer the SEO title.
3979 + *
3980 + * Off unless the site turns it on, so updating the plugin never rewrites an
3981 + * existing trail.
3982 + *
3983 + * @since 2.3.1
3984 + *
3985 + * @param array $settings Breadcrumb settings.
3986 + * @return bool
3987 + */
3988 + private function breadcrumbs_use_seo_title(array $settings): bool {
3989 + return !empty($settings['breadcrumb_use_seo_title']);
3990 + }
3991 +
3992 + /**
3993 + * Label for a post in the breadcrumb trail.
3994 + *
3995 + * With the toggle on, the post's own SEO title wins — the same
3996 + * `_thinkrank_seo_title` value (variable tags resolved) the document title
3997 + * uses — so the trail under a search snippet reads the same as the snippet
3998 + * itself. Anything empty falls back to the raw post title; the global title
3999 + * pattern is deliberately NOT part of the chain, since resolving it would
4000 + * append the site name to every crumb.
4001 + *
4002 + * @since 2.3.1
4003 + *
4004 + * @param int $post_id Post ID.
4005 + * @param array $settings Breadcrumb settings.
4006 + * @return string Breadcrumb label.
4007 + */
4008 + private function get_breadcrumb_post_title(int $post_id, array $settings): string {
4009 + $title = (string) get_the_title($post_id);
4010 +
4011 + if (!$this->breadcrumbs_use_seo_title($settings)) {
4012 + return $title;
4013 + }
4014 +
4015 + $seo_title = trim((string) get_post_meta($post_id, '_thinkrank_seo_title', true));
4016 +
4017 + if ('' === $seo_title) {
4018 + return $title;
4019 + }
4020 +
4021 + $resolved = trim(\ThinkRank\SEO\Pattern_Resolver::resolve_value($seo_title, $post_id));
4022 +
4023 + return '' !== $resolved ? $resolved : $title;
4024 + }
4025 +
4026 + /**
4027 + * Label for a term in the breadcrumb trail.
4028 + *
4029 + * Term counterpart to {@see self::get_breadcrumb_post_title()}, resolving
4030 + * the term's `_thinkrank_seo_title` against its own values.
4031 + *
4032 + * @since 2.3.1
4033 + *
4034 + * @param object $term Term object.
4035 + * @param array $settings Breadcrumb settings.
4036 + * @return string Breadcrumb label.
4037 + */
4038 + private function get_breadcrumb_term_title($term, array $settings): string {
4039 + $name = (string) ($term->name ?? '');
4040 +
4041 + if (!$this->breadcrumbs_use_seo_title($settings) || empty($term->term_id)) {
4042 + return $name;
4043 + }
4044 +
4045 + $seo_title = trim((string) get_term_meta((int) $term->term_id, '_thinkrank_seo_title', true));
4046 +
4047 + if ('' === $seo_title) {
4048 + return $name;
4049 + }
4050 +
4051 + $resolved = trim(\ThinkRank\SEO\Pattern_Resolver::resolve_term_value($seo_title, (int) $term->term_id));
4052 +
4053 + return '' !== $resolved ? $resolved : $name;
4054 + }
4055 +
4056 + /**
4057 + * Resolve a configured icon URL to the derivative that fits $size.
4058 + *
4059 + * wp_site_icon() calls get_site_icon_url() four times — 32, 192, 180 and
4060 + * 270 — and pairs the first two with a hardcoded sizes="" attribute. This
4061 + * filter used to answer all four with the same configured URL, so one
4062 + * upload was declared as every size at once: a 1536x1536 original served
4063 + * to paint a 32px tab icon, under a sizes="32x32" label that was simply
4064 + * untrue (#571).
4065 + *
4066 + * Resolution mirrors core's own get_site_icon_url(), including the
4067 + * >= 512 -> 'full' branch, so ThinkRank's override and the core pipeline
4068 + * pick the same file for the same request.
4069 + *
4070 + * An unresolvable URL (one hosted off-site) is returned unchanged. Nothing
4071 + * is knowable about its dimensions, and suppressing it instead would leave
4072 + * the page with no rel="icon" at all — a worse outcome than an approximate
4073 + * size hint.
4074 + *
4075 + * @param string $configured Configured icon URL.
4076 + * @param int $size Icon size core is asking for.
4077 + * @return string Icon URL for that size.
4078 + */
4079 + private function resolve_icon_url(string $configured, int $size): string {
4080 + $cache_key = md5($configured) . ':' . $size;
4081 + $cached = $this->icon_urls();
4082 +
4083 + if (isset($cached[$cache_key])) {
4084 + return $cached[$cache_key];
4085 + }
4086 +
4087 + $attachment_id = \ThinkRank\SEO\Site_Identity_Manager::icon_attachment_id($configured);
4088 +
4089 + if (!$attachment_id) {
4090 + $resolved = esc_url($configured);
4091 + } else {
4092 + // Mirrors core: at 512 and above the original is what is wanted, and
4093 + // asking for an intermediate size that large would only fall back to it.
4094 + $size_data = $size >= 512 ? 'full' : [$size, $size];
4095 + $url = wp_get_attachment_image_url($attachment_id, $size_data);
4096 + $resolved = $url ? esc_url($url) : esc_url($configured);
4097 + }
4098 +
4099 + $this->icon_urls[$cache_key] = $resolved;
4100 +
4101 + if (!$this->icon_urls_dirty) {
4102 + $this->icon_urls_dirty = true;
4103 + // Written once, after the response is assembled, rather than once
4104 + // per size: wp_site_icon() resolves four in a row.
4105 + add_action('shutdown', [$this, 'persist_icon_urls'], 5);
4106 + }
4107 +
4108 + return $resolved;
4109 + }
4110 +
4111 + /**
4112 + * The resolved-icon-URL map, loaded from its transient on first use.
4113 + *
4114 + * @return array<string, string>
4115 + */
4116 + private function icon_urls(): array {
4117 + if ($this->icon_urls === null) {
4118 + $stored = get_transient(\ThinkRank\SEO\Site_Identity_Manager::ICON_URL_TRANSIENT);
4119 + $this->icon_urls = is_array($stored) ? $stored : [];
4120 + }
4121 +
4122 + return $this->icon_urls;
4123 + }
4124 +
4125 + /**
4126 + * Persist newly resolved icon URLs.
4127 + *
4128 + * Public because it runs on `shutdown`. Invalidated wholesale whenever the
4129 + * site identity settings are saved, which is the only moment the icon
4130 + * choice — or the derivatives behind it — can change.
4131 + *
4132 + * @return void
4133 + */
4134 + public function persist_icon_urls(): void {
4135 + if (!$this->icon_urls_dirty || !is_array($this->icon_urls)) {
4136 + return;
4137 + }
4138 +
4139 + $this->icon_urls_dirty = false;
4140 + set_transient(
4141 + \ThinkRank\SEO\Site_Identity_Manager::ICON_URL_TRANSIENT,
4142 + $this->icon_urls,
4143 + DAY_IN_SECONDS
4144 + );
4145 + }
4146 +
4147 + /**
3428 4148 * Get breadcrumb items for current page
3429 4149 *
3430 4150 * @param array $settings Breadcrumb settings
3431 4151 * @return array Breadcrumb items
@@ -3453,9 +4173,9 @@
3453 4173 $categories = get_the_category($current_post_id);
3454 4174 if (!empty($categories)) {
3455 4175 $category = $categories[0];
3456 4176 $items[] = [
3457 - 'title' => $category->name,
4177 + 'title' => $this->get_breadcrumb_term_title($category, $settings),
3458 4178 'url' => get_category_link($category->term_id),
3459 4179 'position' => $position++
3460 4180 ];
3461 4181 }
@@ -3469,9 +4189,9 @@
3469 4189 // already had the correct form: default to on, respect an
3470 4190 // explicit off.
3471 4191 if ($settings['show_current_page'] ?? true) {
3472 4192 $items[] = [
3473 - 'title' => get_the_title($current_post_id),
4193 + 'title' => $this->get_breadcrumb_post_title($current_post_id, $settings),
3474 4194 'url' => get_permalink($current_post_id),
3475 4195 'position' => $position,
3476 4196 'current' => true
3477 4197 ];
@@ -3488,9 +4208,9 @@
3488 4208 while ($parent_id) {
3489 4209 $parent = get_post($parent_id);
3490 4210 if ($parent) {
3491 4211 $parents[] = [
3492 - 'title' => get_the_title($parent->ID),
4212 + 'title' => $this->get_breadcrumb_post_title($parent->ID, $settings),
3493 4213 'url' => get_permalink($parent->ID),
3494 4214 'position' => 0 // Will be set later
3495 4215 ];
3496 4216 $parent_id = $parent->post_parent;
@@ -3510,9 +4230,9 @@
3510 4230
3511 4231 // Add current page
3512 4232 if ($settings['show_current_page'] ?? true) {
3513 4233 $items[] = [
3514 - 'title' => get_the_title($current_post_id),
4234 + 'title' => $this->get_breadcrumb_post_title($current_post_id, $settings),
3515 4235 'url' => get_permalink($current_post_id),
3516 4236 'position' => $position,
3517 4237 'current' => true
3518 4238 ];
@@ -3528,9 +4248,9 @@
3528 4248 while ($parent_id) {
3529 4249 $parent = get_category($parent_id);
3530 4250 if ($parent && !is_wp_error($parent)) {
3531 4251 $parents[] = [
3532 - 'title' => $parent->name,
4252 + 'title' => $this->get_breadcrumb_term_title($parent, $settings),
3533 4253 'url' => get_category_link($parent->term_id),
3534 4254 'position' => 0 // Will be set later
3535 4255 ];
3536 4256 $parent_id = $parent->parent;
@@ -3550,9 +4270,9 @@
3550 4270
3551 4271 // Add current category
3552 4272 if ($settings['show_current_page'] ?? true) {
3553 4273 $items[] = [
3554 - 'title' => $category->name,
4274 + 'title' => $this->get_breadcrumb_term_title($category, $settings),
3555 4275 'url' => get_category_link($category->term_id),
3556 4276 'position' => $position,
3557 4277 'current' => true
3558 4278 ];