PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.12.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.12.0
2.12.0 2.11.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 All 53 releases
← All changes | includes/frontend/class-seo-manager.php +802 -88 2.3.0 → 2.12.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
@@ -240,8 +266,16 @@
240 266 // LLMs_Txt_Manager for the static file) guarantees an explicit UTF-8
241 267 // charset. Priority 8 keeps it ahead of redirect_canonical().
242 268 add_action('template_redirect', [$this, 'maybe_serve_llms_txt'], 8);
243 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 +
244 278 // Take WordPress core's own sitemap offline while ThinkRank's is active.
245 279 // Two sitemap indexes on one site is a crawl conflict: core keeps
246 280 // /wp-sitemap.xml served and injects its own "Sitemap:" line into
247 281 // robots.txt (WP_Sitemaps::add_robots, priority 0). Until now that line
@@ -273,8 +307,17 @@
273 307 // Serve the Site Identity favicon through core's site-icon pipeline so
274 308 // wp_site_icon() outputs it on the front-end (and previews pick it up)
275 309 add_filter('get_site_icon_url', [$this, 'filter_site_icon_url'], 10, 2);
276 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 +
277 320 // Process image SEO in content
278 321 add_filter('the_content', [$this, 'filter_content_images'], 99999);
279 322 add_filter('post_thumbnail_html', [$this, 'filter_content_images'], 11, 2);
280 323 add_filter('woocommerce_single_product_image_thumbnail_html', [$this, 'filter_content_images'], 11);
@@ -334,39 +377,73 @@
334 377 }
335 378
336 379 // Initialize Global SEO Schema Output and store reference
337 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 + );
338 389 $this->global_seo_schema->init();
339 390 }
340 391
341 392 /**
342 - * Initialize Google Analytics Tracking Manager
393 + * Initialize Image SEO Manager
343 394 *
344 395 * @return void
345 396 */
346 - private function initialize_google_analytics_tracking(): void {
347 - if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
348 - 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';
349 400 }
350 401
351 - // Initialize Google Analytics Tracking Manager
352 - new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
402 + $this->image_seo_manager = new \ThinkRank\SEO\Image_SEO_Manager();
353 403 }
354 404
355 405 /**
356 - * Initialize Image SEO Manager
406 + * Initialize External Links Manager
357 407 *
408 + * @since 2.5.0
358 409 * @return void
359 410 */
360 - private function initialize_image_seo_manager(): void {
361 - if (!class_exists('ThinkRank\\SEO\\Image_SEO_Manager')) {
362 - 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';
363 414 }
364 415
365 - $this->image_seo_manager = new \ThinkRank\SEO\Image_SEO_Manager();
416 + $this->external_links_manager = new \ThinkRank\SEO\External_Links_Manager();
366 417 }
367 418
368 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 + /**
369 446 * Filter content to inject image SEO attributes
370 447 *
371 448 * @since 1.0.0
372 449 * @param string $content Content to filter
@@ -438,8 +515,40 @@
438 515 $this->load_site_identity_data();
439 516 }
440 517
441 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 + /**
442 551 * Detect current page context
443 552 *
444 553 * @return string Current context type
445 554 */
@@ -564,8 +673,14 @@
564 673 * @param string $title Original title
565 674 * @return string Modified title
566 675 */
567 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 +
568 683 // First priority: Post-specific ThinkRank metadata
569 684 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
570 685 return self::with_page_suffix($this->current_metadata['title']);
571 686 }
@@ -592,9 +707,86 @@
592 707 *
593 708 * @param string $title Resolved title.
594 709 * @return string Title with the page indicator, when there is one.
595 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 +
596 786 public static function with_page_suffix(string $title): string {
787 + $title = self::strip_title_tags($title);
788 +
597 789 $page = self::current_page_number();
598 790
599 791 if ($page <= 1 || '' === $title) {
600 792 return $title;
@@ -619,8 +811,12 @@
619 811 * @param string $sep Title separator
620 812 * @return string Modified title
621 813 */
622 814 public function override_wp_title(string $title, string $sep = ''): string {
815 + if (!$this->metas_enabled()) {
816 + return $title;
817 + }
818 +
623 819 // First priority: Post-specific ThinkRank metadata
624 820 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
625 821 $site_name = get_bloginfo('name');
626 822 return self::with_page_suffix(
@@ -637,8 +833,25 @@
637 833 return $title;
638 834 }
639 835
640 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 + /**
641 854 * Output meta description (HIGH PRIORITY)
642 855 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates > WordPress defaults
643 856 *
644 857 * Author archives are skipped entirely: Author_Archives_Manager owns that
@@ -653,8 +866,12 @@
653 866 if (is_author()) {
654 867 return;
655 868 }
656 869
870 + if (!$this->metas_enabled()) {
871 + return;
872 + }
873 +
657 874 $description = $this->get_meta_description();
658 875
659 876 if ($description) {
660 877 // Output main ThinkRank SEO header comment (only once)
@@ -660,11 +877,13 @@
660 877 // Output main ThinkRank SEO header comment (only once)
661 878 self::note_opening_comment();
662 879
663 880 // Ensure description is within optimal length (150-160 characters)
664 - if (strlen($description) > 160) {
665 - $description = wp_trim_words($description, 25, '...');
666 - }
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);
667 886
668 887 echo "<!-- ThinkRank SEO Meta Description -->\n";
669 888 echo '<meta name="description" content="' . esc_attr($description) . '" />' . "\n";
670 889 echo "<!-- /ThinkRank SEO Meta Description -->\n";
@@ -715,8 +934,34 @@
715 934 echo "<!-- /ThinkRank SEO Meta Tags -->\n";
716 935 }
717 936
718 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 + /**
719 964 * Get robots meta content based on context and settings
720 965 *
721 966 * @return string Robots meta content
722 967 */
@@ -722,13 +967,22 @@
722 967 */
723 968 private function get_robots_meta_content(): string {
724 969 $robots = [];
725 970
726 - // 404 and search results must never be indexed, regardless of the
727 - // configured global/post-type directives. Links are still followed so
728 - // 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.
729 975 if (is_404() || is_search()) {
730 - $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);
731 985 return implode(', ', array_unique($robots));
732 986 }
733 987
734 988 // 1. Get global robot meta settings (Base)
@@ -757,8 +1011,24 @@
757 1011 $current_settings = array_merge($current_settings, $global_seo_settings[$post_type]['robots_meta']);
758 1012 }
759 1013 }
760 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 +
761 1031 // Determine Index/Noindex based on merged settings
762 1032 // Priority: if noindex is true, it overrides index
763 1033 if (!empty($current_settings['noindex'])) {
764 1034 $robots[] = 'noindex';
@@ -1125,9 +1395,9 @@
1125 1395 *
1126 1396 * @param array $og_tags Open Graph tags array
1127 1397 * @return void
1128 1398 */
1129 - private function output_social_og_tags(array $og_tags): void {
1399 + private function output_social_og_tags(array $og_tags, array $extra_images = []): void {
1130 1400 // Honor the thinkrank_og_type filter here too — this "Enhanced" path is
1131 1401 // the active OG emitter, so add-ons (e.g. Pro's WooCommerce module which
1132 1402 // sets 'product' on product pages) must be applied to it, not only to
1133 1403 // output_open_graph_tags().
@@ -1171,12 +1441,62 @@
1171 1441 echo '<meta property="' . esc_attr($property) . '" content="' . $this->esc_meta_value($property, $content) . '" />' . "\n";
1172 1442 }
1173 1443 }
1174 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 +
1175 1452 echo "<!-- /ThinkRank SEO Open Graph Tags -->\n";
1176 1453 }
1177 1454
1178 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 + /**
1179 1499 * Output social media Twitter Card tags from Social Meta Manager
1180 1500 *
1181 1501 * @param array $twitter_tags Twitter Card tags array
1182 1502 * @return void
@@ -1241,8 +1561,16 @@
1241 1561 *
1242 1562 * @return void
1243 1563 */
1244 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 +
1245 1573 // Try Social Meta Manager for platform tags
1246 1574 if ($this->social_manager) {
1247 1575 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1248 1576 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
@@ -1294,8 +1622,17 @@
1294 1622 *
1295 1623 * @return void
1296 1624 */
1297 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 +
1298 1635 // An error page has no shareable identity. Emitting Open Graph here
1299 1636 // advertised the homepage as the og:url of a URL that does not exist.
1300 1637 if ($this->current_context === '404') {
1301 1638 return;
@@ -1322,9 +1659,12 @@
1322 1659 // The Social Meta Manager ran, so it owns Open Graph output. If OG is
1323 1660 // toggled off, emit nothing — do NOT fall through to the basic
1324 1661 // emitter (which would re-add a full OG block despite the toggle).
1325 1662 if (!empty($social_data['og_enabled'])) {
1326 - $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 + );
1327 1667 }
1328 1668 return;
1329 1669 }
1330 1670
@@ -1392,9 +1732,9 @@
1392 1732 // "There is no excerpt because this is a protected post." placeholder,
1393 1733 // so this is not a leak — but publishing that sentence as the social
1394 1734 // description is worse than publishing none (#363).
1395 1735 if (!$description && !$this->is_content_password_protected()) {
1396 - $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');
1397 1737 }
1398 1738
1399 1739 $url = is_singular() ? get_permalink() : home_url();
1400 1740 $site_name = $this->site_identity_data && !empty($this->site_identity_data['identity']['site_name'])
@@ -1422,11 +1762,11 @@
1422 1762 $og_type = apply_filters('thinkrank_og_type', $og_type);
1423 1763
1424 1764 echo "<!-- ThinkRank SEO Open Graph Meta Tags -->\n";
1425 1765 echo "<meta property=\"og:type\" content=\"" . esc_attr($og_type) . "\" />\n";
1426 - 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";
1427 1767 echo "<meta property=\"og:description\" content=\"" . esc_attr($description) . "\" />\n";
1428 - 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";
1429 1769 echo "<meta property=\"og:site_name\" content=\"" . esc_attr($site_name) . "\" />\n";
1430 1770 /**
1431 1771 * Filter the og:locale value.
1432 1772 *
@@ -1443,14 +1783,17 @@
1443 1783 $og_locale = (string) apply_filters('thinkrank_og_locale', get_locale());
1444 1784 echo "<meta property=\"og:locale\" content=\"" . esc_attr($og_locale) . "\" />\n";
1445 1785
1446 1786 // Add OG image — per-post override > featured image
1787 + $primary_og_image = '';
1447 1788 if (is_singular() && $this->current_post_id) {
1448 1789 if (!empty($og_image_override)) {
1790 + $primary_og_image = (string) $og_image_override;
1449 1791 echo "<meta property=\"og:image\" content=\"" . esc_url($og_image_override) . "\" />\n";
1450 1792 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($og_image_override) . "\" />\n";
1451 1793 } elseif (has_post_thumbnail($this->current_post_id)) {
1452 1794 $image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
1795 + $primary_og_image = (string) $image_url;
1453 1796 echo "<meta property=\"og:image\" content=\"" . esc_url($image_url) . "\" />\n";
1454 1797 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($image_url) . "\" />\n";
1455 1798
1456 1799 // Get image dimensions and alt text
@@ -1456,12 +1799,16 @@
1456 1799 // Get image dimensions and alt text
1457 1800 $image_id = get_post_thumbnail_id($this->current_post_id);
1458 1801 $image_meta = wp_get_attachment_metadata($image_id);
1459 1802 if ($image_meta) {
1803 + // The `large` file being published, not the original it
1804 + // was generated from: the metadata's own width and height
1805 + // describe an image this tag does not point at (#847).
1806 + $image_file = \ThinkRank\SEO\Attachment_Lookup::describe((int) $image_id, (string) $image_url);
1460 1807 // SVGs (and other vector uploads) report 0x0 — emitting
1461 1808 // those as og:image dimensions is invalid, so skip them.
1462 - $og_width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0;
1463 - $og_height = isset($image_meta['height']) ? (int) $image_meta['height'] : 0;
1809 + $og_width = $image_file['width'];
1810 + $og_height = $image_file['height'];
1464 1811 if ($og_width > 0 && $og_height > 0) {
1465 1812 echo "<meta property=\"og:image:width\" content=\"" . esc_attr($og_width) . "\" />\n";
1466 1813 echo "<meta property=\"og:image:height\" content=\"" . esc_attr($og_height) . "\" />\n";
1467 1814 }
@@ -1466,9 +1813,9 @@
1466 1813 echo "<meta property=\"og:image:height\" content=\"" . esc_attr($og_height) . "\" />\n";
1467 1814 }
1468 1815 // Derive the real mime type instead of hardcoding image/jpeg,
1469 1816 // which mislabels PNG/WebP featured images.
1470 - $image_mime = get_post_mime_type($image_id);
1817 + $image_mime = $image_file['type'];
1471 1818 if ($image_mime) {
1472 1819 echo "<meta property=\"og:image:type\" content=\"" . esc_attr($image_mime) . "\" />\n";
1473 1820 }
1474 1821 }
@@ -1479,8 +1826,30 @@
1479 1826 echo "<meta property=\"og:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n";
1480 1827 }
1481 1828 }
1482 1829
1830 + // Alternatives, same as the enhanced emitter above. This path only
1831 + // runs when the Social Meta Manager is unavailable, but the issue
1832 + // reported against it (#636) and a site that lands here should not
1833 + // silently lose a feature it switched on.
1834 + if (!empty($primary_og_image)) {
1835 + $social_settings = $this->social_manager
1836 + ? $this->social_manager->get_settings(
1837 + $this->current_context === 'homepage' ? 'site' : $this->current_context,
1838 + $this->current_post_id
1839 + )
1840 + : [];
1841 +
1842 + if (!empty($social_settings['og_multiple_images'])) {
1843 + self::output_extra_og_images(
1844 + \ThinkRank\SEO\Social_Images::additional(
1845 + (int) $this->current_post_id,
1846 + $primary_og_image
1847 + )
1848 + );
1849 + }
1850 + }
1851 +
1483 1852 // Add article specific tags for posts only
1484 1853 if ($og_type === 'article') {
1485 1854 echo '<meta property="article:published_time" content="' . esc_attr(get_the_date('c', $this->current_post_id)) . '" />' . "\n";
1486 1855 echo '<meta property="article:modified_time" content="' . esc_attr(get_the_modified_date('c', $this->current_post_id)) . '" />' . "\n";
@@ -1508,8 +1877,16 @@
1508 1877 *
1509 1878 * @return void
1510 1879 */
1511 1880 public function output_twitter_card_tags(): void {
1881 + // Per-content-type Twitter card switch; see output_open_graph_tags().
1882 + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
1883 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_TWITTER,
1884 + true
1885 + )) {
1886 + return;
1887 + }
1888 +
1512 1889 // Same reasoning as the Open Graph block: nothing on a 404 is shareable.
1513 1890 if ($this->current_context === '404') {
1514 1891 return;
1515 1892 }
@@ -1598,9 +1975,9 @@
1598 1975 // "There is no excerpt because this is a protected post." placeholder,
1599 1976 // so this is not a leak — but publishing that sentence as the social
1600 1977 // description is worse than publishing none (#363).
1601 1978 if (!$description && !$this->is_content_password_protected()) {
1602 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1979 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1603 1980 }
1604 1981
1605 1982 // Determine card type based on image availability
1606 1983 $card_type = 'summary';
@@ -1609,9 +1986,9 @@
1609 1986 }
1610 1987
1611 1988 echo "<!-- ThinkRank SEO Twitter Card Meta Tags -->\n";
1612 1989 echo '<meta name="twitter:card" content="' . esc_attr($card_type) . '" />' . "\n";
1613 - echo "<meta name=\"twitter:title\" content=\"" . esc_attr($title) . "\" />\n";
1990 + echo "<meta name=\"twitter:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1614 1991 echo "<meta name=\"twitter:description\" content=\"" . esc_attr($description) . "\" />\n";
1615 1992
1616 1993 // Add Twitter image with proper fallback priority
1617 1994 $twitter_image_url = $this->get_twitter_image_with_fallback();
@@ -1686,8 +2063,13 @@
1686 2063 if (empty($canonical_url)) {
1687 2064 return;
1688 2065 }
1689 2066
2067 + // After the filter, so a canonical an add-on supplied is normalized
2068 + // too — and a cross-domain one is left alone, since Url_Scheme only
2069 + // touches URLs on this site's own host.
2070 + $canonical_url = \ThinkRank\SEO\Url_Scheme::apply($canonical_url);
2071 +
1690 2072 echo "<!-- ThinkRank SEO Canonical URL -->\n";
1691 2073 echo "<link rel=\"canonical\" href=\"" . esc_url($canonical_url) . "\" />\n";
1692 2074 echo "<!-- /ThinkRank SEO Canonical URL -->\n";
1693 2075
@@ -1734,9 +2116,9 @@
1734 2116
1735 2117 if ($current > 1) {
1736 2118 printf(
1737 2119 "<link rel=\"prev\" href=\"%s\" />\n",
1738 - esc_url(self::with_pagination($base, $current - 1))
2120 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current - 1)))
1739 2121 );
1740 2122 }
1741 2123
1742 2124 if ($current < $total) {
@@ -1741,9 +2123,9 @@
1741 2123
1742 2124 if ($current < $total) {
1743 2125 printf(
1744 2126 "<link rel=\"next\" href=\"%s\" />\n",
1745 - esc_url(self::with_pagination($base, $current + 1))
2127 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current + 1)))
1746 2128 );
1747 2129 }
1748 2130 }
1749 2131
@@ -2070,9 +2452,9 @@
2070 2452 if (!empty($post->post_excerpt)) {
2071 2453 $placeholders['%excerpt%'] = $post->post_excerpt;
2072 2454 } elseif (!$this->is_content_password_protected($post->ID)) {
2073 2455 $placeholders['%excerpt%'] = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt(
2074 - (string) $post->post_content
2456 + \ThinkRank\SEO\Builder_Content::visible_content($post)
2075 2457 );
2076 2458 }
2077 2459 }
2078 2460
@@ -2243,9 +2625,9 @@
2243 2625 // Stripped: get_the_archive_title() wraps its subject in a
2244 2626 // <span>, and this placeholder feeds the document <title> as
2245 2627 // well as og:title and twitter:title — a date archive rendered
2246 2628 // as "Month: <span>August 2026</span> | Site".
2247 - $placeholders['%archive_title%'] = wp_strip_all_tags((string) get_the_archive_title());
2629 + $placeholders['%archive_title%'] = self::archive_subject();
2248 2630 break;
2249 2631
2250 2632 case 'homepage':
2251 2633 // The page template resolved for a static posts page needs the
@@ -2386,9 +2768,15 @@
2386 2768 // gated body published its first ~25 words in the page head, and the
2387 2769 // same value is reused for og:description and twitter:description, so
2388 2770 // one unguarded read leaked through three tags (#363).
2389 2771 if (is_singular() && $this->current_post_id && !$this->is_content_password_protected()) {
2390 - $post_content = get_post_field('post_content', $this->current_post_id);
2772 + // Not the raw column: a Bricks page discards `post_content`, so
2773 + // whatever is still stored there is invisible — and this one value
2774 + // becomes the meta, og: and twitter: descriptions (#651).
2775 + $described = get_post($this->current_post_id);
2776 + $post_content = $described instanceof \WP_Post
2777 + ? \ThinkRank\SEO\Builder_Content::visible_content($described)
2778 + : get_post_field('post_content', $this->current_post_id);
2391 2779 if ($post_content) {
2392 2780 $excerpt = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt((string) $post_content);
2393 2781 if (!empty($excerpt)) {
2394 2782 return $excerpt;
@@ -2434,11 +2822,13 @@
2434 2822 if ($description === '') {
2435 2823 return null;
2436 2824 }
2437 2825
2438 - if (strlen($description) > 160) {
2439 - $description = wp_trim_words($description, 25, '...');
2440 - }
2826 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2827 + // CJK description tripped this limit at a third of its length, and
2828 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2829 + // English, 25 characters in Thai (#687).
2830 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2441 2831
2442 2832 return $description;
2443 2833 }
2444 2834
@@ -2485,11 +2875,13 @@
2485 2875 $description = preg_replace('/\s+/', ' ', $description);
2486 2876 $description = trim($description);
2487 2877
2488 2878 // Ensure description doesn't exceed recommended length (160 characters)
2489 - if (strlen($description) > 160) {
2490 - $description = wp_trim_words($description, 25, '...');
2491 - }
2879 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2880 + // CJK description tripped this limit at a third of its length, and
2881 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2882 + // English, 25 characters in Thai (#687).
2883 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2492 2884
2493 2885 return $description;
2494 2886 }
2495 2887
@@ -2556,9 +2948,19 @@
2556 2948
2557 2949 $page_specific_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
2558 2950
2559 2951 if (!empty($page_specific_schemas)) {
2560 - // Apply filter for Pro to allow multiple schemas
2952 + // Every deployed schema is rendered, on every plan. How many a
2953 + // page carries is decided when schemas are activated in the
2954 + // editor, not trimmed here by plan (#673).
2955 +
2956 + /**
2957 + * Filter the page-specific schemas rendered on the current page.
2958 + *
2959 + * @param array $page_specific_schemas Deployed schemas keyed by schema type.
2960 + * @param string $context_type Context type (post, page, product, site).
2961 + * @param int $context_id Post ID.
2962 + */
2561 2963 $page_specific_schemas = apply_filters(
2562 2964 'thinkrank_page_schemas_to_render',
2563 2965 $page_specific_schemas,
2564 2966 $context_type,
@@ -2564,29 +2966,22 @@
2564 2966 $context_type,
2565 2967 $context_id
2566 2968 );
2567 2969
2568 - // Free tier renders at most self::FREE_PAGE_SCHEMA_LIMIT
2569 - // page-specific schemas; Pro renders all of them.
2570 - //
2571 - // Both comments here used to say the free limit was 1 while the
2572 - // code allowed 2 (#405). The number the code enforces is what
2573 - // has shipped, so that is what stands — lowering it would take
2574 - // a schema away from every free site on upgrade — and it now
2575 - // lives in one named place instead of twice in prose and twice
2576 - // in a literal.
2577 - if (!\ThinkRank\Core\Plan_Config::is_pro()
2578 - && count($page_specific_schemas) > self::FREE_PAGE_SCHEMA_LIMIT) {
2579 - $page_specific_schemas = array_slice(
2580 - $page_specific_schemas,
2581 - 0,
2582 - self::FREE_PAGE_SCHEMA_LIMIT,
2583 - true
2584 - );
2585 - }
2970 + // A deployed node is a snapshot from Deploy time and outranks
2971 + // the automatic node, so page and article types would publish
2972 + // a frozen excerpt instead of the description the head
2973 + // resolves. Give them the live one, as the automatic node has.
2974 + $context_post = get_post($context_id);
2586 2975
2587 2976 foreach ($page_specific_schemas as $schema_type => $schema_info) {
2588 - Schema_Graph::instance()->add_primary($schema_info['data'], (string) $schema_type, 'schema_manager');
2977 + $node = $schema_info['data'];
2978 +
2979 + if ($this->global_seo_schema && $context_post instanceof \WP_Post) {
2980 + $node = $this->global_seo_schema->refresh_deployed_description($node, (string) $schema_type, $context_post);
2981 + }
2982 +
2983 + Schema_Graph::instance()->add_primary($node, (string) $schema_type, 'schema_manager');
2589 2984 }
2590 2985 $has_schema_manager_output = true;
2591 2986 }
2592 2987 }
@@ -2644,21 +3039,49 @@
2644 3039 'url' => home_url('/'),
2645 3040 ];
2646 3041
2647 3042 $description = !empty($settings['site_description']) ? $settings['site_description'] : get_bloginfo('description');
3043 + // The tagline is stored esc_html()'d by sanitize_option(), so a site
3044 + // called "Fish & Chips" published `&amp;` literally in its WebSite
3045 + // node; nothing decodes JSON-LD downstream.
3046 + $description = \ThinkRank\Core\Seo_Text::normalize_schema_text((string) $description);
2648 3047 if (!empty($description)) {
2649 3048 $schema['description'] = $description;
2650 3049 }
2651 3050
2652 - $schema['potentialAction'] = [
2653 - '@type' => 'SearchAction',
2654 - 'target' => [
2655 - '@type' => 'EntryPoint',
2656 - 'urlTemplate' => home_url('/?s={search_term_string}'),
2657 - ],
2658 - 'query-input' => 'required name=search_term_string',
2659 - ];
3051 + // Site Identity has accepted an alternate name since the setup wizard
3052 + // shipped, and the MCP ability describes it as "published as schema
3053 + // alternateName" — but no producer ever read it, so the promise was
3054 + // false and every imported Yoast/Rank Math value sat unused (#692).
3055 + $alternate_name = \ThinkRank\SEO\Site_Identity_Manager::alternate_name_for_schema($settings['alternate_name'] ?? null);
3056 + if (null !== $alternate_name) {
3057 + $schema['alternateName'] = $alternate_name;
3058 + }
2660 3059
3060 + // The sitelinks searchbox switch was honoured only for a deployed
3061 + // WebSite row; this live fallback added potentialAction unconditionally,
3062 + // so website_enable_search = 0 still shipped the SearchAction (#688).
3063 + // Absent means not configured, which stays enabled.
3064 + $search_enabled = true;
3065 + if ($this->schema_manager) {
3066 + $schema_settings = $this->schema_manager->get_settings('site', null);
3067 +
3068 + if (array_key_exists('website_enable_search', $schema_settings)) {
3069 + $search_enabled = !empty($schema_settings['website_enable_search']);
3070 + }
3071 + }
3072 +
3073 + if ($search_enabled) {
3074 + $schema['potentialAction'] = [
3075 + '@type' => 'SearchAction',
3076 + 'target' => [
3077 + '@type' => 'EntryPoint',
3078 + 'urlTemplate' => home_url('/?s={search_term_string}'),
3079 + ],
3080 + 'query-input' => 'required name=search_term_string',
3081 + ];
3082 + }
3083 +
2661 3084 return $schema;
2662 3085 }
2663 3086
2664 3087 /**
@@ -2869,8 +3292,22 @@
2869 3292 if (empty($settings['breadcrumbs_enabled'])) {
2870 3293 return;
2871 3294 }
2872 3295
3296 + // Schema Manager's own breadcrumb switch. Only Site Identity's
3297 + // breadcrumbs_enabled was consulted here, so enable_breadcrumbs_schema
3298 + // = 0 removed a deployed BreadcrumbList row and left this live one
3299 + // emitting the node anyway (#688). Absent means not configured, which
3300 + // stays enabled.
3301 + if ($this->schema_manager) {
3302 + $schema_settings = $this->schema_manager->get_settings('site', null);
3303 +
3304 + if (array_key_exists('enable_breadcrumbs_schema', $schema_settings)
3305 + && empty($schema_settings['enable_breadcrumbs_schema'])) {
3306 + return;
3307 + }
3308 + }
3309 +
2873 3310 $breadcrumbs = $this->generate_breadcrumbs($settings);
2874 3311
2875 3312 if (!empty($breadcrumbs['schema'])) {
2876 3313 Schema_Graph::instance()->add_supporting($breadcrumbs['schema'], 'BreadcrumbList');
@@ -3125,8 +3562,102 @@
3125 3562 * @since 1.32.0
3126 3563 *
3127 3564 * @return void
3128 3565 */
3566 + /**
3567 + * Serve a ThinkRank sitemap document for this request, when it is one.
3568 + *
3569 + * Only acts in dynamic delivery mode. In static mode a real file exists and
3570 + * the web server returns it without WordPress ever loading, so answering
3571 + * here as well would mean two sources for the same bytes.
3572 + *
3573 + * @since 2.9.0
3574 + *
3575 + * @return void
3576 + */
3577 + public function maybe_serve_sitemap(): void {
3578 + $filename = $this->requested_sitemap_filename();
3579 + if ('' === $filename) {
3580 + return;
3581 + }
3582 +
3583 + try {
3584 + // Read-only instance: passing false keeps it from registering a
3585 + // second copy of the auto-generation hooks.
3586 + $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3587 + $settings = $generator->get_settings('site');
3588 +
3589 + if (empty($settings['enabled'])) {
3590 + return;
3591 + }
3592 +
3593 + if ('dynamic' !== $generator->resolve_delivery_mode($settings)) {
3594 + return;
3595 + }
3596 +
3597 + if (!$generator->publishes_document_name($filename, $settings)) {
3598 + return;
3599 + }
3600 +
3601 + $xml = $generator->render_document($filename, $settings);
3602 + } catch (\Throwable $e) {
3603 + // A failed render must not replace the sitemap with a fatal. Leave
3604 + // the request alone so WordPress answers as it otherwise would.
3605 + return;
3606 + }
3607 +
3608 + if (!is_string($xml) || '' === trim($xml)) {
3609 + return;
3610 + }
3611 +
3612 + status_header(200);
3613 + header('Content-Type: application/xml; charset=UTF-8');
3614 + header('X-Robots-Tag: noindex, follow', true);
3615 +
3616 + // Built XML, escaped by the builders as they assemble it; escaping the
3617 + // document here would corrupt it.
3618 + echo $xml; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
3619 + exit;
3620 + }
3621 +
3622 + /**
3623 + * The sitemap file name this request is asking for, if it looks like one.
3624 + *
3625 + * Deliberately a cheap shape test. Whether the site actually publishes the
3626 + * name is settled by the caller against the generator, so that a request
3627 + * for someone else's sitemap is never answered here.
3628 + *
3629 + * @since 2.9.0
3630 + *
3631 + * @return string File name, or '' when this is not a sitemap request.
3632 + */
3633 + private function requested_sitemap_filename(): string {
3634 + if (empty($_SERVER['REQUEST_URI'])) {
3635 + return '';
3636 + }
3637 +
3638 + $path = wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), PHP_URL_PATH);
3639 + if (!is_string($path) || '' === $path) {
3640 + return '';
3641 + }
3642 +
3643 + // Strip the install's home path so subdirectory installs match too.
3644 + $home_path = (string) wp_parse_url(home_url('/'), PHP_URL_PATH);
3645 + if ('' !== $home_path && '/' !== $home_path && 0 === strpos($path, $home_path)) {
3646 + $path = substr($path, strlen($home_path));
3647 + }
3648 +
3649 + $candidate = strtolower(trim($path, '/'));
3650 +
3651 + // One path segment ending in .xml. Anything nested is not a file we
3652 + // publish to the web root.
3653 + if ('' === $candidate || strpos($candidate, '/') !== false) {
3654 + return '';
3655 + }
3656 +
3657 + return substr($candidate, -4) === '.xml' ? $candidate : '';
3658 + }
3659 +
3129 3660 public function maybe_serve_llms_txt(): void {
3130 3661 if (!$this->is_llms_txt_request()) {
3131 3662 return;
3132 3663 }
@@ -3327,11 +3858,22 @@
3327 3858 // second copy of the save_post/term auto-generation hooks.
3328 3859 $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3329 3860 $settings = $generator->get_settings('site');
3330 3861
3862 + // "Can ThinkRank actually answer its sitemap URL right now?" In
3863 + // static mode that means the file is on disk; in dynamic mode
3864 + // maybe_serve_sitemap() answers it, so there is nothing to look
3865 + // for. Keeping the file test as the only answer would have left
3866 + // core's sitemap in place on every dynamic site, which is the
3867 + // crawl conflict this suppression exists to prevent (#752).
3868 + // The #346 behaviour is unchanged: a static site with nothing
3869 + // published still falls through to core rather than 404ing.
3870 + $can_serve = 'dynamic' === $generator->resolve_delivery_mode($settings)
3871 + || $generator->primary_sitemap_file_exists($settings);
3872 +
3331 3873 $this->thinkrank_sitemap_enabled = !empty($settings['enabled'])
3332 3874 && !$this->publishes_at_core_sitemap_url($settings)
3333 - && $generator->primary_sitemap_file_exists($settings);
3875 + && $can_serve;
3334 3876
3335 3877 if ($this->thinkrank_sitemap_enabled) {
3336 3878 $this->thinkrank_sitemap_url = $generator->get_primary_sitemap_url($settings);
3337 3879 }
@@ -3421,15 +3963,17 @@
3421 3963 if (empty($settings['enabled'])) {
3422 3964 return (string) $url;
3423 3965 }
3424 3966
3967 + $size = (int) $size;
3968 +
3425 3969 // Apple touch icon has its own dedicated setting
3426 - if ((int) $size === 180 && !empty($settings['apple_touch_icon_url'])) {
3427 - return esc_url($settings['apple_touch_icon_url']);
3970 + if ($size === 180 && !empty($settings['apple_touch_icon_url'])) {
3971 + return $this->resolve_icon_url((string) $settings['apple_touch_icon_url'], $size);
3428 3972 }
3429 3973
3430 3974 if (!empty($settings['favicon_url'])) {
3431 - return esc_url($settings['favicon_url']);
3975 + return $this->resolve_icon_url((string) $settings['favicon_url'], $size);
3432 3976 }
3433 3977
3434 3978 return (string) $url;
3435 3979 }
@@ -3434,8 +3978,178 @@
3434 3978 return (string) $url;
3435 3979 }
3436 3980
3437 3981 /**
3982 + * Whether breadcrumb labels should prefer the SEO title.
3983 + *
3984 + * Off unless the site turns it on, so updating the plugin never rewrites an
3985 + * existing trail.
3986 + *
3987 + * @since 2.3.1
3988 + *
3989 + * @param array $settings Breadcrumb settings.
3990 + * @return bool
3991 + */
3992 + private function breadcrumbs_use_seo_title(array $settings): bool {
3993 + return !empty($settings['breadcrumb_use_seo_title']);
3994 + }
3995 +
3996 + /**
3997 + * Label for a post in the breadcrumb trail.
3998 + *
3999 + * With the toggle on, the post's own SEO title wins — the same
4000 + * `_thinkrank_seo_title` value (variable tags resolved) the document title
4001 + * uses — so the trail under a search snippet reads the same as the snippet
4002 + * itself. Anything empty falls back to the raw post title; the global title
4003 + * pattern is deliberately NOT part of the chain, since resolving it would
4004 + * append the site name to every crumb.
4005 + *
4006 + * @since 2.3.1
4007 + *
4008 + * @param int $post_id Post ID.
4009 + * @param array $settings Breadcrumb settings.
4010 + * @return string Breadcrumb label.
4011 + */
4012 + private function get_breadcrumb_post_title(int $post_id, array $settings): string {
4013 + $title = (string) get_the_title($post_id);
4014 +
4015 + if (!$this->breadcrumbs_use_seo_title($settings)) {
4016 + return $title;
4017 + }
4018 +
4019 + $seo_title = trim((string) get_post_meta($post_id, '_thinkrank_seo_title', true));
4020 +
4021 + if ('' === $seo_title) {
4022 + return $title;
4023 + }
4024 +
4025 + $resolved = trim(\ThinkRank\SEO\Pattern_Resolver::resolve_value($seo_title, $post_id));
4026 +
4027 + return '' !== $resolved ? $resolved : $title;
4028 + }
4029 +
4030 + /**
4031 + * Label for a term in the breadcrumb trail.
4032 + *
4033 + * Term counterpart to {@see self::get_breadcrumb_post_title()}, resolving
4034 + * the term's `_thinkrank_seo_title` against its own values.
4035 + *
4036 + * @since 2.3.1
4037 + *
4038 + * @param object $term Term object.
4039 + * @param array $settings Breadcrumb settings.
4040 + * @return string Breadcrumb label.
4041 + */
4042 + private function get_breadcrumb_term_title($term, array $settings): string {
4043 + $name = (string) ($term->name ?? '');
4044 +
4045 + if (!$this->breadcrumbs_use_seo_title($settings) || empty($term->term_id)) {
4046 + return $name;
4047 + }
4048 +
4049 + $seo_title = trim((string) get_term_meta((int) $term->term_id, '_thinkrank_seo_title', true));
4050 +
4051 + if ('' === $seo_title) {
4052 + return $name;
4053 + }
4054 +
4055 + $resolved = trim(\ThinkRank\SEO\Pattern_Resolver::resolve_term_value($seo_title, (int) $term->term_id));
4056 +
4057 + return '' !== $resolved ? $resolved : $name;
4058 + }
4059 +
4060 + /**
4061 + * Resolve a configured icon URL to the derivative that fits $size.
4062 + *
4063 + * wp_site_icon() calls get_site_icon_url() four times — 32, 192, 180 and
4064 + * 270 — and pairs the first two with a hardcoded sizes="" attribute. This
4065 + * filter used to answer all four with the same configured URL, so one
4066 + * upload was declared as every size at once: a 1536x1536 original served
4067 + * to paint a 32px tab icon, under a sizes="32x32" label that was simply
4068 + * untrue (#571).
4069 + *
4070 + * Resolution mirrors core's own get_site_icon_url(), including the
4071 + * >= 512 -> 'full' branch, so ThinkRank's override and the core pipeline
4072 + * pick the same file for the same request.
4073 + *
4074 + * An unresolvable URL (one hosted off-site) is returned unchanged. Nothing
4075 + * is knowable about its dimensions, and suppressing it instead would leave
4076 + * the page with no rel="icon" at all — a worse outcome than an approximate
4077 + * size hint.
4078 + *
4079 + * @param string $configured Configured icon URL.
4080 + * @param int $size Icon size core is asking for.
4081 + * @return string Icon URL for that size.
4082 + */
4083 + private function resolve_icon_url(string $configured, int $size): string {
4084 + $cache_key = md5($configured) . ':' . $size;
4085 + $cached = $this->icon_urls();
4086 +
4087 + if (isset($cached[$cache_key])) {
4088 + return $cached[$cache_key];
4089 + }
4090 +
4091 + $attachment_id = \ThinkRank\SEO\Site_Identity_Manager::icon_attachment_id($configured);
4092 +
4093 + if (!$attachment_id) {
4094 + $resolved = esc_url($configured);
4095 + } else {
4096 + // Mirrors core: at 512 and above the original is what is wanted, and
4097 + // asking for an intermediate size that large would only fall back to it.
4098 + $size_data = $size >= 512 ? 'full' : [$size, $size];
4099 + $url = wp_get_attachment_image_url($attachment_id, $size_data);
4100 + $resolved = $url ? esc_url($url) : esc_url($configured);
4101 + }
4102 +
4103 + $this->icon_urls[$cache_key] = $resolved;
4104 +
4105 + if (!$this->icon_urls_dirty) {
4106 + $this->icon_urls_dirty = true;
4107 + // Written once, after the response is assembled, rather than once
4108 + // per size: wp_site_icon() resolves four in a row.
4109 + add_action('shutdown', [$this, 'persist_icon_urls'], 5);
4110 + }
4111 +
4112 + return $resolved;
4113 + }
4114 +
4115 + /**
4116 + * The resolved-icon-URL map, loaded from its transient on first use.
4117 + *
4118 + * @return array<string, string>
4119 + */
4120 + private function icon_urls(): array {
4121 + if ($this->icon_urls === null) {
4122 + $stored = get_transient(\ThinkRank\SEO\Site_Identity_Manager::ICON_URL_TRANSIENT);
4123 + $this->icon_urls = is_array($stored) ? $stored : [];
4124 + }
4125 +
4126 + return $this->icon_urls;
4127 + }
4128 +
4129 + /**
4130 + * Persist newly resolved icon URLs.
4131 + *
4132 + * Public because it runs on `shutdown`. Invalidated wholesale whenever the
4133 + * site identity settings are saved, which is the only moment the icon
4134 + * choice — or the derivatives behind it — can change.
4135 + *
4136 + * @return void
4137 + */
4138 + public function persist_icon_urls(): void {
4139 + if (!$this->icon_urls_dirty || !is_array($this->icon_urls)) {
4140 + return;
4141 + }
4142 +
4143 + $this->icon_urls_dirty = false;
4144 + set_transient(
4145 + \ThinkRank\SEO\Site_Identity_Manager::ICON_URL_TRANSIENT,
4146 + $this->icon_urls,
4147 + DAY_IN_SECONDS
4148 + );
4149 + }
4150 +
4151 + /**
3438 4152 * Get breadcrumb items for current page
3439 4153 *
3440 4154 * @param array $settings Breadcrumb settings
3441 4155 * @return array Breadcrumb items
@@ -3463,9 +4177,9 @@
3463 4177 $categories = get_the_category($current_post_id);
3464 4178 if (!empty($categories)) {
3465 4179 $category = $categories[0];
3466 4180 $items[] = [
3467 - 'title' => $category->name,
4181 + 'title' => $this->get_breadcrumb_term_title($category, $settings),
3468 4182 'url' => get_category_link($category->term_id),
3469 4183 'position' => $position++
3470 4184 ];
3471 4185 }
@@ -3479,9 +4193,9 @@
3479 4193 // already had the correct form: default to on, respect an
3480 4194 // explicit off.
3481 4195 if ($settings['show_current_page'] ?? true) {
3482 4196 $items[] = [
3483 - 'title' => get_the_title($current_post_id),
4197 + 'title' => $this->get_breadcrumb_post_title($current_post_id, $settings),
3484 4198 'url' => get_permalink($current_post_id),
3485 4199 'position' => $position,
3486 4200 'current' => true
3487 4201 ];
@@ -3498,9 +4212,9 @@
3498 4212 while ($parent_id) {
3499 4213 $parent = get_post($parent_id);
3500 4214 if ($parent) {
3501 4215 $parents[] = [
3502 - 'title' => get_the_title($parent->ID),
4216 + 'title' => $this->get_breadcrumb_post_title($parent->ID, $settings),
3503 4217 'url' => get_permalink($parent->ID),
3504 4218 'position' => 0 // Will be set later
3505 4219 ];
3506 4220 $parent_id = $parent->post_parent;
@@ -3520,9 +4234,9 @@
3520 4234
3521 4235 // Add current page
3522 4236 if ($settings['show_current_page'] ?? true) {
3523 4237 $items[] = [
3524 - 'title' => get_the_title($current_post_id),
4238 + 'title' => $this->get_breadcrumb_post_title($current_post_id, $settings),
3525 4239 'url' => get_permalink($current_post_id),
3526 4240 'position' => $position,
3527 4241 'current' => true
3528 4242 ];
@@ -3538,9 +4252,9 @@
3538 4252 while ($parent_id) {
3539 4253 $parent = get_category($parent_id);
3540 4254 if ($parent && !is_wp_error($parent)) {
3541 4255 $parents[] = [
3542 - 'title' => $parent->name,
4256 + 'title' => $this->get_breadcrumb_term_title($parent, $settings),
3543 4257 'url' => get_category_link($parent->term_id),
3544 4258 'position' => 0 // Will be set later
3545 4259 ];
3546 4260 $parent_id = $parent->parent;
@@ -3560,9 +4274,9 @@
3560 4274
3561 4275 // Add current category
3562 4276 if ($settings['show_current_page'] ?? true) {
3563 4277 $items[] = [
3564 - 'title' => $category->name,
4278 + 'title' => $this->get_breadcrumb_term_title($category, $settings),
3565 4279 'url' => get_category_link($category->term_id),
3566 4280 'position' => $position,
3567 4281 'current' => true
3568 4282 ];