PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 All 50 releases
← All changes | includes/frontend/class-seo-manager.php +771 -85 2.3.0 → 2.9.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);
@@ -338,35 +381,61 @@
338 381 $this->global_seo_schema->init();
339 382 }
340 383
341 384 /**
342 - * Initialize Google Analytics Tracking Manager
385 + * Initialize Image SEO Manager
343 386 *
344 387 * @return void
345 388 */
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';
389 + private function initialize_image_seo_manager(): void {
390 + if (!class_exists('ThinkRank\\SEO\\Image_SEO_Manager')) {
391 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-image-seo-manager.php';
349 392 }
350 393
351 - // Initialize Google Analytics Tracking Manager
352 - new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
394 + $this->image_seo_manager = new \ThinkRank\SEO\Image_SEO_Manager();
353 395 }
354 396
355 397 /**
356 - * Initialize Image SEO Manager
398 + * Initialize External Links Manager
357 399 *
400 + * @since 2.5.0
358 401 * @return void
359 402 */
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';
403 + private function initialize_external_links_manager(): void {
404 + if (!class_exists('ThinkRank\\SEO\\External_Links_Manager')) {
405 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-external-links-manager.php';
363 406 }
364 407
365 - $this->image_seo_manager = new \ThinkRank\SEO\Image_SEO_Manager();
408 + $this->external_links_manager = new \ThinkRank\SEO\External_Links_Manager();
366 409 }
367 410
368 411 /**
412 + * Filter rendered content to annotate external links
413 + *
414 + * @since 2.5.0
415 + * @param mixed $content Content to filter; passed through untouched when
416 + * it is not a string.
417 + * @return mixed Filtered content.
418 + */
419 + public function filter_external_links($content) {
420 + // No return type: a filter value another plugin hands through as null
421 + // or an object belongs to whoever set it, and coercing it to '' would
422 + // silently drop their content on the floor.
423 + if (!is_string($content) || $content === '' || !$this->external_links_manager) {
424 + return $content;
425 + }
426 +
427 + // Feeds carry the same markup to a reader we do not control; leave
428 + // them as authored rather than annotating for a context that has no
429 + // browser tab to open.
430 + if (is_feed()) {
431 + return $content;
432 + }
433 +
434 + return $this->external_links_manager->process_content($content);
435 + }
436 +
437 + /**
369 438 * Filter content to inject image SEO attributes
370 439 *
371 440 * @since 1.0.0
372 441 * @param string $content Content to filter
@@ -438,8 +507,40 @@
438 507 $this->load_site_identity_data();
439 508 }
440 509
441 510 /**
511 + * Drop the request's post identity once it has become a 404.
512 + *
513 + * `initialize_current_context()` runs on `wp`, but a request can be turned
514 + * into a 404 after that: `set_404()` on `template_redirect` is the ordinary
515 + * way to refuse a URL that did resolve to a real post, and both core and
516 + * plugins do it — ThinkRank Pro's Markdown for AI refuses an ineligible
517 + * `.md` URL that way. The snapshot still said `post`/`page` and still held
518 + * the post id and its metadata, so the error page shipped that post's meta
519 + * description, focus keywords and — where the social emitters got that far
520 + * — its og:description and twitter:description, all of which a request that
521 + * was a 404 from the start never prints (#655).
522 + *
523 + * Clearing the snapshot rather than special-casing each emitter is what
524 + * makes every consumer agree, including the ones that read
525 + * `$current_metadata` without ever asking what the context is.
526 + *
527 + * @since 2.3.1
528 + *
529 + * @return void
530 + */
531 + public function recheck_404_context(): void {
532 + if (!is_404() || '404' === $this->current_context) {
533 + return;
534 + }
535 +
536 + $this->current_context = '404';
537 + $this->current_post_id = null;
538 + $this->current_term_id = null;
539 + $this->current_metadata = [];
540 + }
541 +
542 + /**
442 543 * Detect current page context
443 544 *
444 545 * @return string Current context type
445 546 */
@@ -564,8 +665,14 @@
564 665 * @param string $title Original title
565 666 * @return string Modified title
566 667 */
567 668 public function override_document_title($title): string {
669 + // A content type with metas switched off keeps whatever title the theme
670 + // and WordPress produce (#660).
671 + if (!$this->metas_enabled()) {
672 + return $title;
673 + }
674 +
568 675 // First priority: Post-specific ThinkRank metadata
569 676 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
570 677 return self::with_page_suffix($this->current_metadata['title']);
571 678 }
@@ -592,9 +699,86 @@
592 699 *
593 700 * @param string $title Resolved title.
594 701 * @return string Title with the page indicator, when there is one.
595 702 */
703 + /**
704 + * The archive's subject, without the label WordPress prefixes it with.
705 + *
706 + * `get_the_archive_title()` returns "Month: September 2026", "Archives:
707 + * Recipes", "Category: Uncategorized" — the label is core's, aimed at an
708 + * archive heading on the page, and it reads badly in a browser tab, an
709 + * og:title or a search result. Category, tag and author contexts already
710 + * avoid it by using the raw name; the generic archive context did not, so
711 + * date, custom-post-type and custom-taxonomy archives carried it (#640).
712 + *
713 + * Removed through core's own `get_the_archive_title_prefix` filter rather
714 + * than by matching the prefix text, because that text is translated and
715 + * differs per archive type — a string comparison would work in English and
716 + * silently stop working everywhere else.
717 + *
718 + * A site that wants a prefix can put one in its title template, where it is
719 + * visible and editable, instead of inheriting one it cannot see.
720 + *
721 + * @since 2.7.0
722 + *
723 + * @return string Archive subject, with markup and the core prefix removed.
724 + */
725 + private static function archive_subject(): string {
726 + $drop_prefix = static function (): string {
727 + return '';
728 + };
729 +
730 + add_filter('get_the_archive_title_prefix', $drop_prefix, 99);
731 +
732 + $title = (string) get_the_archive_title();
733 +
734 + remove_filter('get_the_archive_title_prefix', $drop_prefix, 99);
735 +
736 + // The <span> core wraps the subject in survives the prefix filter.
737 + return trim(wp_strip_all_tags($title));
738 + }
739 +
740 + /**
741 + * Remove HTML from a title that is about to be emitted.
742 + *
743 + * A title carrying markup is broken twice over, in two different ways, and
744 + * both were reaching real pages: inside `<title>` the tags render literally,
745 + * because that element is RCDATA and never parses them; inside `og:title`
746 + * and `twitter:title` they are attribute-escaped, so the reader sees
747 + * `&lt;em&gt;` as visible text (#640).
748 + *
749 + * Applied at the point of emission rather than at each source, so it covers
750 + * every branch that can produce a title — post meta, Global SEO templates,
751 + * Site Identity templates — without each having to remember.
752 + *
753 + * Unconditional rather than a setting: there is no title for which markup is
754 + * the correct output. The filter is the escape hatch for anyone who
755 + * disagrees, and lets a site keep entities it deliberately encoded.
756 + *
757 + * @since 2.7.0
758 + *
759 + * @param string $title Title about to be emitted.
760 + * @return string Title with any markup removed.
761 + */
762 + public static function strip_title_tags(string $title): string {
763 + /**
764 + * Filter whether HTML is stripped from generated titles.
765 + *
766 + * @since 2.7.0
767 + *
768 + * @param bool $strip Whether to strip. Default true.
769 + * @param string $title The title being emitted.
770 + */
771 + if (!apply_filters('thinkrank_strip_title_tags', true, $title)) {
772 + return $title;
773 + }
774 +
775 + return trim(wp_strip_all_tags($title));
776 + }
777 +
596 778 public static function with_page_suffix(string $title): string {
779 + $title = self::strip_title_tags($title);
780 +
597 781 $page = self::current_page_number();
598 782
599 783 if ($page <= 1 || '' === $title) {
600 784 return $title;
@@ -619,8 +803,12 @@
619 803 * @param string $sep Title separator
620 804 * @return string Modified title
621 805 */
622 806 public function override_wp_title(string $title, string $sep = ''): string {
807 + if (!$this->metas_enabled()) {
808 + return $title;
809 + }
810 +
623 811 // First priority: Post-specific ThinkRank metadata
624 812 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
625 813 $site_name = get_bloginfo('name');
626 814 return self::with_page_suffix(
@@ -637,8 +825,25 @@
637 825 return $title;
638 826 }
639 827
640 828 /**
829 + * Whether ThinkRank owns the title and meta description for this request.
830 + *
831 + * Metas are on site-wide by default; the per-content-type matrix can switch
832 + * them off for one content type, in which case ThinkRank stops overriding
833 + * the document title and prints no meta description (#660).
834 + *
835 + * @since 2.5.0
836 + * @return bool
837 + */
838 + private function metas_enabled(): bool {
839 + return \ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
840 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_META,
841 + true
842 + );
843 + }
844 +
845 + /**
641 846 * Output meta description (HIGH PRIORITY)
642 847 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates > WordPress defaults
643 848 *
644 849 * Author archives are skipped entirely: Author_Archives_Manager owns that
@@ -653,8 +858,12 @@
653 858 if (is_author()) {
654 859 return;
655 860 }
656 861
862 + if (!$this->metas_enabled()) {
863 + return;
864 + }
865 +
657 866 $description = $this->get_meta_description();
658 867
659 868 if ($description) {
660 869 // Output main ThinkRank SEO header comment (only once)
@@ -660,11 +869,13 @@
660 869 // Output main ThinkRank SEO header comment (only once)
661 870 self::note_opening_comment();
662 871
663 872 // Ensure description is within optimal length (150-160 characters)
664 - if (strlen($description) > 160) {
665 - $description = wp_trim_words($description, 25, '...');
666 - }
873 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
874 + // CJK description tripped this limit at a third of its length, and
875 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
876 + // English, 25 characters in Thai (#687).
877 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
667 878
668 879 echo "<!-- ThinkRank SEO Meta Description -->\n";
669 880 echo '<meta name="description" content="' . esc_attr($description) . '" />' . "\n";
670 881 echo "<!-- /ThinkRank SEO Meta Description -->\n";
@@ -715,8 +926,34 @@
715 926 echo "<!-- /ThinkRank SEO Meta Tags -->\n";
716 927 }
717 928
718 929 /**
930 + * Build the basic robots directive list from a set of robots flags.
931 + *
932 + * Shared by the search/404 branch of get_robots_meta_content() so those
933 + * pages resolve their directives through the same rules as everything else
934 + * rather than a hardcoded literal.
935 + *
936 + * @since 2.5.0
937 + * @param array $settings Robots flags (index/noindex/nofollow/...).
938 + * @return string[] Directives.
939 + */
940 + private static function build_robots_directives(array $settings): array {
941 + $robots = [];
942 +
943 + $robots[] = !empty($settings['noindex']) ? 'noindex' : 'index';
944 + $robots[] = !empty($settings['nofollow']) ? 'nofollow' : 'follow';
945 +
946 + foreach (['noarchive', 'noimageindex', 'nosnippet'] as $directive) {
947 + if (!empty($settings[$directive])) {
948 + $robots[] = $directive;
949 + }
950 + }
951 +
952 + return $robots;
953 + }
954 +
955 + /**
719 956 * Get robots meta content based on context and settings
720 957 *
721 958 * @return string Robots meta content
722 959 */
@@ -722,13 +959,22 @@
722 959 */
723 960 private function get_robots_meta_content(): string {
724 961 $robots = [];
725 962
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.
963 + // 404 and search results are noindex/follow by default — the behaviour
964 + // that used to be hardcoded here. It is now settings-driven (#660): the
965 + // Content Type Matrix can give either its own robots directives, and an
966 + // install that never touched them resolves to exactly the old pair.
729 967 if (is_404() || is_search()) {
730 - $robots = apply_filters('thinkrank_robots_meta', ['noindex', 'follow']);
968 + $entity = is_404()
969 + ? \ThinkRank\SEO\Content_Type_Settings::ENTITY_404
970 + : \ThinkRank\SEO\Content_Type_Settings::ENTITY_SEARCH;
971 +
972 + $robots = self::build_robots_directives(
973 + \ThinkRank\SEO\Content_Type_Settings::resolve_robots_meta($entity)
974 + );
975 +
976 + $robots = apply_filters('thinkrank_robots_meta', $robots);
731 977 return implode(', ', array_unique($robots));
732 978 }
733 979
734 980 // 1. Get global robot meta settings (Base)
@@ -757,8 +1003,24 @@
757 1003 $current_settings = array_merge($current_settings, $global_seo_settings[$post_type]['robots_meta']);
758 1004 }
759 1005 }
760 1006
1007 + // 2b. Apply the per-entity directives for the non-singular content
1008 + // types the matrix covers — taxonomy archives plus author and date
1009 + // archives. Terms keep their own per-term override, applied further
1010 + // down so it still wins over the taxonomy-wide value (#660).
1011 + if (!is_singular()) {
1012 + $entity_key = \ThinkRank\SEO\Content_Type_Settings::current_entity_key();
1013 +
1014 + if ($entity_key !== null) {
1015 + $entity_settings = \ThinkRank\SEO\Content_Type_Settings::get_entity_settings($entity_key);
1016 +
1017 + if (!empty($entity_settings['robots_meta_enabled']) && is_array($entity_settings['robots_meta'] ?? null)) {
1018 + $current_settings = array_merge($current_settings, $entity_settings['robots_meta']);
1019 + }
1020 + }
1021 + }
1022 +
761 1023 // Determine Index/Noindex based on merged settings
762 1024 // Priority: if noindex is true, it overrides index
763 1025 if (!empty($current_settings['noindex'])) {
764 1026 $robots[] = 'noindex';
@@ -1125,9 +1387,9 @@
1125 1387 *
1126 1388 * @param array $og_tags Open Graph tags array
1127 1389 * @return void
1128 1390 */
1129 - private function output_social_og_tags(array $og_tags): void {
1391 + private function output_social_og_tags(array $og_tags, array $extra_images = []): void {
1130 1392 // Honor the thinkrank_og_type filter here too — this "Enhanced" path is
1131 1393 // the active OG emitter, so add-ons (e.g. Pro's WooCommerce module which
1132 1394 // sets 'product' on product pages) must be applied to it, not only to
1133 1395 // output_open_graph_tags().
@@ -1171,12 +1433,62 @@
1171 1433 echo '<meta property="' . esc_attr($property) . '" content="' . $this->esc_meta_value($property, $content) . '" />' . "\n";
1172 1434 }
1173 1435 }
1174 1436
1437 + // Alternatives, after the primary and everything belonging to it.
1438 + // Order is the whole point: a consumer reads og:image tags in document
1439 + // order and treats the first as primary, and a structured property
1440 + // attaches to the most recently declared image — so each alternative's
1441 + // companions have to follow its own URL, not be grouped at the end.
1442 + self::output_extra_og_images($extra_images);
1443 +
1175 1444 echo "<!-- /ThinkRank SEO Open Graph Tags -->\n";
1176 1445 }
1177 1446
1178 1447 /**
1448 + * Emit the secondary og:image tags a page offers.
1449 + *
1450 + * Shared by the enhanced and basic emitters so both describe an
1451 + * alternative image the same way (#636).
1452 + *
1453 + * @since 2.7.0
1454 + *
1455 + * @param array $images Each with url, and width/height/type/alt where known.
1456 + * @return void
1457 + */
1458 + private static function output_extra_og_images(array $images): void {
1459 + foreach ($images as $image) {
1460 + $url = isset($image['url']) ? (string) $image['url'] : '';
1461 +
1462 + if ('' === $url) {
1463 + continue;
1464 + }
1465 +
1466 + echo '<meta property="og:image" content="' . esc_url($url) . '" />' . "\n";
1467 +
1468 + if (strpos($url, 'https://') === 0) {
1469 + echo '<meta property="og:image:secure_url" content="' . esc_url($url) . '" />' . "\n";
1470 + }
1471 +
1472 + // Only what is actually known: a dimension guessed for a remote
1473 + // image is a number a consumer lays a card out with before it has
1474 + // fetched the file.
1475 + if (!empty($image['width']) && !empty($image['height'])) {
1476 + echo '<meta property="og:image:width" content="' . esc_attr((string) $image['width']) . '" />' . "\n";
1477 + echo '<meta property="og:image:height" content="' . esc_attr((string) $image['height']) . '" />' . "\n";
1478 + }
1479 +
1480 + if (!empty($image['type'])) {
1481 + echo '<meta property="og:image:type" content="' . esc_attr((string) $image['type']) . '" />' . "\n";
1482 + }
1483 +
1484 + if (!empty($image['alt'])) {
1485 + echo '<meta property="og:image:alt" content="' . esc_attr((string) $image['alt']) . '" />' . "\n";
1486 + }
1487 + }
1488 + }
1489 +
1490 + /**
1179 1491 * Output social media Twitter Card tags from Social Meta Manager
1180 1492 *
1181 1493 * @param array $twitter_tags Twitter Card tags array
1182 1494 * @return void
@@ -1241,8 +1553,16 @@
1241 1553 *
1242 1554 * @return void
1243 1555 */
1244 1556 public function output_platform_meta_tags(): void {
1557 + // Same reasoning as the Open Graph and Twitter emitters: an error page
1558 + // has no shareable identity, and passing '404' through as a social
1559 + // context asks the manager for settings that describe a page which does
1560 + // not exist. Guarding all three keeps them from disagreeing.
1561 + if ($this->current_context === '404') {
1562 + return;
1563 + }
1564 +
1245 1565 // Try Social Meta Manager for platform tags
1246 1566 if ($this->social_manager) {
1247 1567 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1248 1568 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
@@ -1294,8 +1614,17 @@
1294 1614 *
1295 1615 * @return void
1296 1616 */
1297 1617 public function output_open_graph_tags(): void {
1618 + // Per-content-type Open Graph switch. 'inherit' (the default) keeps the
1619 + // site-wide Social Media setting, which the emitters below read (#660).
1620 + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
1621 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_OPEN_GRAPH,
1622 + true
1623 + )) {
1624 + return;
1625 + }
1626 +
1298 1627 // An error page has no shareable identity. Emitting Open Graph here
1299 1628 // advertised the homepage as the og:url of a URL that does not exist.
1300 1629 if ($this->current_context === '404') {
1301 1630 return;
@@ -1322,9 +1651,12 @@
1322 1651 // The Social Meta Manager ran, so it owns Open Graph output. If OG is
1323 1652 // toggled off, emit nothing — do NOT fall through to the basic
1324 1653 // emitter (which would re-add a full OG block despite the toggle).
1325 1654 if (!empty($social_data['og_enabled'])) {
1326 - $this->output_social_og_tags($social_data['og_tags']);
1655 + $this->output_social_og_tags(
1656 + $social_data['og_tags'],
1657 + $social_data['og_extra_images'] ?? []
1658 + );
1327 1659 }
1328 1660 return;
1329 1661 }
1330 1662
@@ -1392,9 +1724,9 @@
1392 1724 // "There is no excerpt because this is a protected post." placeholder,
1393 1725 // so this is not a leak — but publishing that sentence as the social
1394 1726 // description is worse than publishing none (#363).
1395 1727 if (!$description && !$this->is_content_password_protected()) {
1396 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1728 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1397 1729 }
1398 1730
1399 1731 $url = is_singular() ? get_permalink() : home_url();
1400 1732 $site_name = $this->site_identity_data && !empty($this->site_identity_data['identity']['site_name'])
@@ -1422,11 +1754,11 @@
1422 1754 $og_type = apply_filters('thinkrank_og_type', $og_type);
1423 1755
1424 1756 echo "<!-- ThinkRank SEO Open Graph Meta Tags -->\n";
1425 1757 echo "<meta property=\"og:type\" content=\"" . esc_attr($og_type) . "\" />\n";
1426 - echo "<meta property=\"og:title\" content=\"" . esc_attr($title) . "\" />\n";
1758 + echo "<meta property=\"og:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1427 1759 echo "<meta property=\"og:description\" content=\"" . esc_attr($description) . "\" />\n";
1428 - echo "<meta property=\"og:url\" content=\"" . esc_url($url) . "\" />\n";
1760 + echo "<meta property=\"og:url\" content=\"" . esc_url(\ThinkRank\SEO\Url_Scheme::apply($url)) . "\" />\n";
1429 1761 echo "<meta property=\"og:site_name\" content=\"" . esc_attr($site_name) . "\" />\n";
1430 1762 /**
1431 1763 * Filter the og:locale value.
1432 1764 *
@@ -1443,14 +1775,17 @@
1443 1775 $og_locale = (string) apply_filters('thinkrank_og_locale', get_locale());
1444 1776 echo "<meta property=\"og:locale\" content=\"" . esc_attr($og_locale) . "\" />\n";
1445 1777
1446 1778 // Add OG image — per-post override > featured image
1779 + $primary_og_image = '';
1447 1780 if (is_singular() && $this->current_post_id) {
1448 1781 if (!empty($og_image_override)) {
1782 + $primary_og_image = (string) $og_image_override;
1449 1783 echo "<meta property=\"og:image\" content=\"" . esc_url($og_image_override) . "\" />\n";
1450 1784 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($og_image_override) . "\" />\n";
1451 1785 } elseif (has_post_thumbnail($this->current_post_id)) {
1452 1786 $image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
1787 + $primary_og_image = (string) $image_url;
1453 1788 echo "<meta property=\"og:image\" content=\"" . esc_url($image_url) . "\" />\n";
1454 1789 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($image_url) . "\" />\n";
1455 1790
1456 1791 // Get image dimensions and alt text
@@ -1479,8 +1814,30 @@
1479 1814 echo "<meta property=\"og:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n";
1480 1815 }
1481 1816 }
1482 1817
1818 + // Alternatives, same as the enhanced emitter above. This path only
1819 + // runs when the Social Meta Manager is unavailable, but the issue
1820 + // reported against it (#636) and a site that lands here should not
1821 + // silently lose a feature it switched on.
1822 + if (!empty($primary_og_image)) {
1823 + $social_settings = $this->social_manager
1824 + ? $this->social_manager->get_settings(
1825 + $this->current_context === 'homepage' ? 'site' : $this->current_context,
1826 + $this->current_post_id
1827 + )
1828 + : [];
1829 +
1830 + if (!empty($social_settings['og_multiple_images'])) {
1831 + self::output_extra_og_images(
1832 + \ThinkRank\SEO\Social_Images::additional(
1833 + (int) $this->current_post_id,
1834 + $primary_og_image
1835 + )
1836 + );
1837 + }
1838 + }
1839 +
1483 1840 // Add article specific tags for posts only
1484 1841 if ($og_type === 'article') {
1485 1842 echo '<meta property="article:published_time" content="' . esc_attr(get_the_date('c', $this->current_post_id)) . '" />' . "\n";
1486 1843 echo '<meta property="article:modified_time" content="' . esc_attr(get_the_modified_date('c', $this->current_post_id)) . '" />' . "\n";
@@ -1508,8 +1865,16 @@
1508 1865 *
1509 1866 * @return void
1510 1867 */
1511 1868 public function output_twitter_card_tags(): void {
1869 + // Per-content-type Twitter card switch; see output_open_graph_tags().
1870 + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
1871 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_TWITTER,
1872 + true
1873 + )) {
1874 + return;
1875 + }
1876 +
1512 1877 // Same reasoning as the Open Graph block: nothing on a 404 is shareable.
1513 1878 if ($this->current_context === '404') {
1514 1879 return;
1515 1880 }
@@ -1598,9 +1963,9 @@
1598 1963 // "There is no excerpt because this is a protected post." placeholder,
1599 1964 // so this is not a leak — but publishing that sentence as the social
1600 1965 // description is worse than publishing none (#363).
1601 1966 if (!$description && !$this->is_content_password_protected()) {
1602 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1967 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1603 1968 }
1604 1969
1605 1970 // Determine card type based on image availability
1606 1971 $card_type = 'summary';
@@ -1609,9 +1974,9 @@
1609 1974 }
1610 1975
1611 1976 echo "<!-- ThinkRank SEO Twitter Card Meta Tags -->\n";
1612 1977 echo '<meta name="twitter:card" content="' . esc_attr($card_type) . '" />' . "\n";
1613 - echo "<meta name=\"twitter:title\" content=\"" . esc_attr($title) . "\" />\n";
1978 + echo "<meta name=\"twitter:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1614 1979 echo "<meta name=\"twitter:description\" content=\"" . esc_attr($description) . "\" />\n";
1615 1980
1616 1981 // Add Twitter image with proper fallback priority
1617 1982 $twitter_image_url = $this->get_twitter_image_with_fallback();
@@ -1686,8 +2051,13 @@
1686 2051 if (empty($canonical_url)) {
1687 2052 return;
1688 2053 }
1689 2054
2055 + // After the filter, so a canonical an add-on supplied is normalized
2056 + // too — and a cross-domain one is left alone, since Url_Scheme only
2057 + // touches URLs on this site's own host.
2058 + $canonical_url = \ThinkRank\SEO\Url_Scheme::apply($canonical_url);
2059 +
1690 2060 echo "<!-- ThinkRank SEO Canonical URL -->\n";
1691 2061 echo "<link rel=\"canonical\" href=\"" . esc_url($canonical_url) . "\" />\n";
1692 2062 echo "<!-- /ThinkRank SEO Canonical URL -->\n";
1693 2063
@@ -1734,9 +2104,9 @@
1734 2104
1735 2105 if ($current > 1) {
1736 2106 printf(
1737 2107 "<link rel=\"prev\" href=\"%s\" />\n",
1738 - esc_url(self::with_pagination($base, $current - 1))
2108 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current - 1)))
1739 2109 );
1740 2110 }
1741 2111
1742 2112 if ($current < $total) {
@@ -1741,9 +2111,9 @@
1741 2111
1742 2112 if ($current < $total) {
1743 2113 printf(
1744 2114 "<link rel=\"next\" href=\"%s\" />\n",
1745 - esc_url(self::with_pagination($base, $current + 1))
2115 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current + 1)))
1746 2116 );
1747 2117 }
1748 2118 }
1749 2119
@@ -2070,9 +2440,9 @@
2070 2440 if (!empty($post->post_excerpt)) {
2071 2441 $placeholders['%excerpt%'] = $post->post_excerpt;
2072 2442 } elseif (!$this->is_content_password_protected($post->ID)) {
2073 2443 $placeholders['%excerpt%'] = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt(
2074 - (string) $post->post_content
2444 + \ThinkRank\SEO\Builder_Content::visible_content($post)
2075 2445 );
2076 2446 }
2077 2447 }
2078 2448
@@ -2243,9 +2613,9 @@
2243 2613 // Stripped: get_the_archive_title() wraps its subject in a
2244 2614 // <span>, and this placeholder feeds the document <title> as
2245 2615 // well as og:title and twitter:title — a date archive rendered
2246 2616 // as "Month: <span>August 2026</span> | Site".
2247 - $placeholders['%archive_title%'] = wp_strip_all_tags((string) get_the_archive_title());
2617 + $placeholders['%archive_title%'] = self::archive_subject();
2248 2618 break;
2249 2619
2250 2620 case 'homepage':
2251 2621 // The page template resolved for a static posts page needs the
@@ -2386,9 +2756,15 @@
2386 2756 // gated body published its first ~25 words in the page head, and the
2387 2757 // same value is reused for og:description and twitter:description, so
2388 2758 // one unguarded read leaked through three tags (#363).
2389 2759 if (is_singular() && $this->current_post_id && !$this->is_content_password_protected()) {
2390 - $post_content = get_post_field('post_content', $this->current_post_id);
2760 + // Not the raw column: a Bricks page discards `post_content`, so
2761 + // whatever is still stored there is invisible — and this one value
2762 + // becomes the meta, og: and twitter: descriptions (#651).
2763 + $described = get_post($this->current_post_id);
2764 + $post_content = $described instanceof \WP_Post
2765 + ? \ThinkRank\SEO\Builder_Content::visible_content($described)
2766 + : get_post_field('post_content', $this->current_post_id);
2391 2767 if ($post_content) {
2392 2768 $excerpt = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt((string) $post_content);
2393 2769 if (!empty($excerpt)) {
2394 2770 return $excerpt;
@@ -2434,11 +2810,13 @@
2434 2810 if ($description === '') {
2435 2811 return null;
2436 2812 }
2437 2813
2438 - if (strlen($description) > 160) {
2439 - $description = wp_trim_words($description, 25, '...');
2440 - }
2814 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2815 + // CJK description tripped this limit at a third of its length, and
2816 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2817 + // English, 25 characters in Thai (#687).
2818 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2441 2819
2442 2820 return $description;
2443 2821 }
2444 2822
@@ -2485,11 +2863,13 @@
2485 2863 $description = preg_replace('/\s+/', ' ', $description);
2486 2864 $description = trim($description);
2487 2865
2488 2866 // Ensure description doesn't exceed recommended length (160 characters)
2489 - if (strlen($description) > 160) {
2490 - $description = wp_trim_words($description, 25, '...');
2491 - }
2867 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2868 + // CJK description tripped this limit at a third of its length, and
2869 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2870 + // English, 25 characters in Thai (#687).
2871 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2492 2872
2493 2873 return $description;
2494 2874 }
2495 2875
@@ -2556,9 +2936,19 @@
2556 2936
2557 2937 $page_specific_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
2558 2938
2559 2939 if (!empty($page_specific_schemas)) {
2560 - // Apply filter for Pro to allow multiple schemas
2940 + // Every deployed schema is rendered, on every plan. How many a
2941 + // page carries is decided when schemas are activated in the
2942 + // editor, not trimmed here by plan (#673).
2943 +
2944 + /**
2945 + * Filter the page-specific schemas rendered on the current page.
2946 + *
2947 + * @param array $page_specific_schemas Deployed schemas keyed by schema type.
2948 + * @param string $context_type Context type (post, page, product, site).
2949 + * @param int $context_id Post ID.
2950 + */
2561 2951 $page_specific_schemas = apply_filters(
2562 2952 'thinkrank_page_schemas_to_render',
2563 2953 $page_specific_schemas,
2564 2954 $context_type,
@@ -2564,27 +2954,8 @@
2564 2954 $context_type,
2565 2955 $context_id
2566 2956 );
2567 2957
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 - }
2586 -
2587 2958 foreach ($page_specific_schemas as $schema_type => $schema_info) {
2588 2959 Schema_Graph::instance()->add_primary($schema_info['data'], (string) $schema_type, 'schema_manager');
2589 2960 }
2590 2961 $has_schema_manager_output = true;
@@ -2648,17 +3019,41 @@
2648 3019 if (!empty($description)) {
2649 3020 $schema['description'] = $description;
2650 3021 }
2651 3022
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 - ];
3023 + // Site Identity has accepted an alternate name since the setup wizard
3024 + // shipped, and the MCP ability describes it as "published as schema
3025 + // alternateName" — but no producer ever read it, so the promise was
3026 + // false and every imported Yoast/Rank Math value sat unused (#692).
3027 + $alternate_name = \ThinkRank\SEO\Site_Identity_Manager::alternate_name_for_schema($settings['alternate_name'] ?? null);
3028 + if (null !== $alternate_name) {
3029 + $schema['alternateName'] = $alternate_name;
3030 + }
2660 3031
3032 + // The sitelinks searchbox switch was honoured only for a deployed
3033 + // WebSite row; this live fallback added potentialAction unconditionally,
3034 + // so website_enable_search = 0 still shipped the SearchAction (#688).
3035 + // Absent means not configured, which stays enabled.
3036 + $search_enabled = true;
3037 + if ($this->schema_manager) {
3038 + $schema_settings = $this->schema_manager->get_settings('site', null);
3039 +
3040 + if (array_key_exists('website_enable_search', $schema_settings)) {
3041 + $search_enabled = !empty($schema_settings['website_enable_search']);
3042 + }
3043 + }
3044 +
3045 + if ($search_enabled) {
3046 + $schema['potentialAction'] = [
3047 + '@type' => 'SearchAction',
3048 + 'target' => [
3049 + '@type' => 'EntryPoint',
3050 + 'urlTemplate' => home_url('/?s={search_term_string}'),
3051 + ],
3052 + 'query-input' => 'required name=search_term_string',
3053 + ];
3054 + }
3055 +
2661 3056 return $schema;
2662 3057 }
2663 3058
2664 3059 /**
@@ -2869,8 +3264,22 @@
2869 3264 if (empty($settings['breadcrumbs_enabled'])) {
2870 3265 return;
2871 3266 }
2872 3267
3268 + // Schema Manager's own breadcrumb switch. Only Site Identity's
3269 + // breadcrumbs_enabled was consulted here, so enable_breadcrumbs_schema
3270 + // = 0 removed a deployed BreadcrumbList row and left this live one
3271 + // emitting the node anyway (#688). Absent means not configured, which
3272 + // stays enabled.
3273 + if ($this->schema_manager) {
3274 + $schema_settings = $this->schema_manager->get_settings('site', null);
3275 +
3276 + if (array_key_exists('enable_breadcrumbs_schema', $schema_settings)
3277 + && empty($schema_settings['enable_breadcrumbs_schema'])) {
3278 + return;
3279 + }
3280 + }
3281 +
2873 3282 $breadcrumbs = $this->generate_breadcrumbs($settings);
2874 3283
2875 3284 if (!empty($breadcrumbs['schema'])) {
2876 3285 Schema_Graph::instance()->add_supporting($breadcrumbs['schema'], 'BreadcrumbList');
@@ -3125,8 +3534,102 @@
3125 3534 * @since 1.32.0
3126 3535 *
3127 3536 * @return void
3128 3537 */
3538 + /**
3539 + * Serve a ThinkRank sitemap document for this request, when it is one.
3540 + *
3541 + * Only acts in dynamic delivery mode. In static mode a real file exists and
3542 + * the web server returns it without WordPress ever loading, so answering
3543 + * here as well would mean two sources for the same bytes.
3544 + *
3545 + * @since 2.9.0
3546 + *
3547 + * @return void
3548 + */
3549 + public function maybe_serve_sitemap(): void {
3550 + $filename = $this->requested_sitemap_filename();
3551 + if ('' === $filename) {
3552 + return;
3553 + }
3554 +
3555 + try {
3556 + // Read-only instance: passing false keeps it from registering a
3557 + // second copy of the auto-generation hooks.
3558 + $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3559 + $settings = $generator->get_settings('site');
3560 +
3561 + if (empty($settings['enabled'])) {
3562 + return;
3563 + }
3564 +
3565 + if ('dynamic' !== $generator->resolve_delivery_mode($settings)) {
3566 + return;
3567 + }
3568 +
3569 + if (!$generator->publishes_document_name($filename, $settings)) {
3570 + return;
3571 + }
3572 +
3573 + $xml = $generator->render_document($filename, $settings);
3574 + } catch (\Throwable $e) {
3575 + // A failed render must not replace the sitemap with a fatal. Leave
3576 + // the request alone so WordPress answers as it otherwise would.
3577 + return;
3578 + }
3579 +
3580 + if (!is_string($xml) || '' === trim($xml)) {
3581 + return;
3582 + }
3583 +
3584 + status_header(200);
3585 + header('Content-Type: application/xml; charset=UTF-8');
3586 + header('X-Robots-Tag: noindex, follow', true);
3587 +
3588 + // Built XML, escaped by the builders as they assemble it; escaping the
3589 + // document here would corrupt it.
3590 + echo $xml; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
3591 + exit;
3592 + }
3593 +
3594 + /**
3595 + * The sitemap file name this request is asking for, if it looks like one.
3596 + *
3597 + * Deliberately a cheap shape test. Whether the site actually publishes the
3598 + * name is settled by the caller against the generator, so that a request
3599 + * for someone else's sitemap is never answered here.
3600 + *
3601 + * @since 2.9.0
3602 + *
3603 + * @return string File name, or '' when this is not a sitemap request.
3604 + */
3605 + private function requested_sitemap_filename(): string {
3606 + if (empty($_SERVER['REQUEST_URI'])) {
3607 + return '';
3608 + }
3609 +
3610 + $path = wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), PHP_URL_PATH);
3611 + if (!is_string($path) || '' === $path) {
3612 + return '';
3613 + }
3614 +
3615 + // Strip the install's home path so subdirectory installs match too.
3616 + $home_path = (string) wp_parse_url(home_url('/'), PHP_URL_PATH);
3617 + if ('' !== $home_path && '/' !== $home_path && 0 === strpos($path, $home_path)) {
3618 + $path = substr($path, strlen($home_path));
3619 + }
3620 +
3621 + $candidate = strtolower(trim($path, '/'));
3622 +
3623 + // One path segment ending in .xml. Anything nested is not a file we
3624 + // publish to the web root.
3625 + if ('' === $candidate || strpos($candidate, '/') !== false) {
3626 + return '';
3627 + }
3628 +
3629 + return substr($candidate, -4) === '.xml' ? $candidate : '';
3630 + }
3631 +
3129 3632 public function maybe_serve_llms_txt(): void {
3130 3633 if (!$this->is_llms_txt_request()) {
3131 3634 return;
3132 3635 }
@@ -3327,11 +3830,22 @@
3327 3830 // second copy of the save_post/term auto-generation hooks.
3328 3831 $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3329 3832 $settings = $generator->get_settings('site');
3330 3833
3834 + // "Can ThinkRank actually answer its sitemap URL right now?" In
3835 + // static mode that means the file is on disk; in dynamic mode
3836 + // maybe_serve_sitemap() answers it, so there is nothing to look
3837 + // for. Keeping the file test as the only answer would have left
3838 + // core's sitemap in place on every dynamic site, which is the
3839 + // crawl conflict this suppression exists to prevent (#752).
3840 + // The #346 behaviour is unchanged: a static site with nothing
3841 + // published still falls through to core rather than 404ing.
3842 + $can_serve = 'dynamic' === $generator->resolve_delivery_mode($settings)
3843 + || $generator->primary_sitemap_file_exists($settings);
3844 +
3331 3845 $this->thinkrank_sitemap_enabled = !empty($settings['enabled'])
3332 3846 && !$this->publishes_at_core_sitemap_url($settings)
3333 - && $generator->primary_sitemap_file_exists($settings);
3847 + && $can_serve;
3334 3848
3335 3849 if ($this->thinkrank_sitemap_enabled) {
3336 3850 $this->thinkrank_sitemap_url = $generator->get_primary_sitemap_url($settings);
3337 3851 }
@@ -3421,15 +3935,17 @@
3421 3935 if (empty($settings['enabled'])) {
3422 3936 return (string) $url;
3423 3937 }
3424 3938
3939 + $size = (int) $size;
3940 +
3425 3941 // 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']);
3942 + if ($size === 180 && !empty($settings['apple_touch_icon_url'])) {
3943 + return $this->resolve_icon_url((string) $settings['apple_touch_icon_url'], $size);
3428 3944 }
3429 3945
3430 3946 if (!empty($settings['favicon_url'])) {
3431 - return esc_url($settings['favicon_url']);
3947 + return $this->resolve_icon_url((string) $settings['favicon_url'], $size);
3432 3948 }
3433 3949
3434 3950 return (string) $url;
3435 3951 }
@@ -3434,8 +3950,178 @@
3434 3950 return (string) $url;
3435 3951 }
3436 3952
3437 3953 /**
3954 + * Whether breadcrumb labels should prefer the SEO title.
3955 + *
3956 + * Off unless the site turns it on, so updating the plugin never rewrites an
3957 + * existing trail.
3958 + *
3959 + * @since 2.3.1
3960 + *
3961 + * @param array $settings Breadcrumb settings.
3962 + * @return bool
3963 + */
3964 + private function breadcrumbs_use_seo_title(array $settings): bool {
3965 + return !empty($settings['breadcrumb_use_seo_title']);
3966 + }
3967 +
3968 + /**
3969 + * Label for a post in the breadcrumb trail.
3970 + *
3971 + * With the toggle on, the post's own SEO title wins — the same
3972 + * `_thinkrank_seo_title` value (variable tags resolved) the document title
3973 + * uses — so the trail under a search snippet reads the same as the snippet
3974 + * itself. Anything empty falls back to the raw post title; the global title
3975 + * pattern is deliberately NOT part of the chain, since resolving it would
3976 + * append the site name to every crumb.
3977 + *
3978 + * @since 2.3.1
3979 + *
3980 + * @param int $post_id Post ID.
3981 + * @param array $settings Breadcrumb settings.
3982 + * @return string Breadcrumb label.
3983 + */
3984 + private function get_breadcrumb_post_title(int $post_id, array $settings): string {
3985 + $title = (string) get_the_title($post_id);
3986 +
3987 + if (!$this->breadcrumbs_use_seo_title($settings)) {
3988 + return $title;
3989 + }
3990 +
3991 + $seo_title = trim((string) get_post_meta($post_id, '_thinkrank_seo_title', true));
3992 +
3993 + if ('' === $seo_title) {
3994 + return $title;
3995 + }
3996 +
3997 + $resolved = trim(\ThinkRank\SEO\Pattern_Resolver::resolve_value($seo_title, $post_id));
3998 +
3999 + return '' !== $resolved ? $resolved : $title;
4000 + }
4001 +
4002 + /**
4003 + * Label for a term in the breadcrumb trail.
4004 + *
4005 + * Term counterpart to {@see self::get_breadcrumb_post_title()}, resolving
4006 + * the term's `_thinkrank_seo_title` against its own values.
4007 + *
4008 + * @since 2.3.1
4009 + *
4010 + * @param object $term Term object.
4011 + * @param array $settings Breadcrumb settings.
4012 + * @return string Breadcrumb label.
4013 + */
4014 + private function get_breadcrumb_term_title($term, array $settings): string {
4015 + $name = (string) ($term->name ?? '');
4016 +
4017 + if (!$this->breadcrumbs_use_seo_title($settings) || empty($term->term_id)) {
4018 + return $name;
4019 + }
4020 +
4021 + $seo_title = trim((string) get_term_meta((int) $term->term_id, '_thinkrank_seo_title', true));
4022 +
4023 + if ('' === $seo_title) {
4024 + return $name;
4025 + }
4026 +
4027 + $resolved = trim(\ThinkRank\SEO\Pattern_Resolver::resolve_term_value($seo_title, (int) $term->term_id));
4028 +
4029 + return '' !== $resolved ? $resolved : $name;
4030 + }
4031 +
4032 + /**
4033 + * Resolve a configured icon URL to the derivative that fits $size.
4034 + *
4035 + * wp_site_icon() calls get_site_icon_url() four times — 32, 192, 180 and
4036 + * 270 — and pairs the first two with a hardcoded sizes="" attribute. This
4037 + * filter used to answer all four with the same configured URL, so one
4038 + * upload was declared as every size at once: a 1536x1536 original served
4039 + * to paint a 32px tab icon, under a sizes="32x32" label that was simply
4040 + * untrue (#571).
4041 + *
4042 + * Resolution mirrors core's own get_site_icon_url(), including the
4043 + * >= 512 -> 'full' branch, so ThinkRank's override and the core pipeline
4044 + * pick the same file for the same request.
4045 + *
4046 + * An unresolvable URL (one hosted off-site) is returned unchanged. Nothing
4047 + * is knowable about its dimensions, and suppressing it instead would leave
4048 + * the page with no rel="icon" at all — a worse outcome than an approximate
4049 + * size hint.
4050 + *
4051 + * @param string $configured Configured icon URL.
4052 + * @param int $size Icon size core is asking for.
4053 + * @return string Icon URL for that size.
4054 + */
4055 + private function resolve_icon_url(string $configured, int $size): string {
4056 + $cache_key = md5($configured) . ':' . $size;
4057 + $cached = $this->icon_urls();
4058 +
4059 + if (isset($cached[$cache_key])) {
4060 + return $cached[$cache_key];
4061 + }
4062 +
4063 + $attachment_id = \ThinkRank\SEO\Site_Identity_Manager::icon_attachment_id($configured);
4064 +
4065 + if (!$attachment_id) {
4066 + $resolved = esc_url($configured);
4067 + } else {
4068 + // Mirrors core: at 512 and above the original is what is wanted, and
4069 + // asking for an intermediate size that large would only fall back to it.
4070 + $size_data = $size >= 512 ? 'full' : [$size, $size];
4071 + $url = wp_get_attachment_image_url($attachment_id, $size_data);
4072 + $resolved = $url ? esc_url($url) : esc_url($configured);
4073 + }
4074 +
4075 + $this->icon_urls[$cache_key] = $resolved;
4076 +
4077 + if (!$this->icon_urls_dirty) {
4078 + $this->icon_urls_dirty = true;
4079 + // Written once, after the response is assembled, rather than once
4080 + // per size: wp_site_icon() resolves four in a row.
4081 + add_action('shutdown', [$this, 'persist_icon_urls'], 5);
4082 + }
4083 +
4084 + return $resolved;
4085 + }
4086 +
4087 + /**
4088 + * The resolved-icon-URL map, loaded from its transient on first use.
4089 + *
4090 + * @return array<string, string>
4091 + */
4092 + private function icon_urls(): array {
4093 + if ($this->icon_urls === null) {
4094 + $stored = get_transient(\ThinkRank\SEO\Site_Identity_Manager::ICON_URL_TRANSIENT);
4095 + $this->icon_urls = is_array($stored) ? $stored : [];
4096 + }
4097 +
4098 + return $this->icon_urls;
4099 + }
4100 +
4101 + /**
4102 + * Persist newly resolved icon URLs.
4103 + *
4104 + * Public because it runs on `shutdown`. Invalidated wholesale whenever the
4105 + * site identity settings are saved, which is the only moment the icon
4106 + * choice — or the derivatives behind it — can change.
4107 + *
4108 + * @return void
4109 + */
4110 + public function persist_icon_urls(): void {
4111 + if (!$this->icon_urls_dirty || !is_array($this->icon_urls)) {
4112 + return;
4113 + }
4114 +
4115 + $this->icon_urls_dirty = false;
4116 + set_transient(
4117 + \ThinkRank\SEO\Site_Identity_Manager::ICON_URL_TRANSIENT,
4118 + $this->icon_urls,
4119 + DAY_IN_SECONDS
4120 + );
4121 + }
4122 +
4123 + /**
3438 4124 * Get breadcrumb items for current page
3439 4125 *
3440 4126 * @param array $settings Breadcrumb settings
3441 4127 * @return array Breadcrumb items
@@ -3463,9 +4149,9 @@
3463 4149 $categories = get_the_category($current_post_id);
3464 4150 if (!empty($categories)) {
3465 4151 $category = $categories[0];
3466 4152 $items[] = [
3467 - 'title' => $category->name,
4153 + 'title' => $this->get_breadcrumb_term_title($category, $settings),
3468 4154 'url' => get_category_link($category->term_id),
3469 4155 'position' => $position++
3470 4156 ];
3471 4157 }
@@ -3479,9 +4165,9 @@
3479 4165 // already had the correct form: default to on, respect an
3480 4166 // explicit off.
3481 4167 if ($settings['show_current_page'] ?? true) {
3482 4168 $items[] = [
3483 - 'title' => get_the_title($current_post_id),
4169 + 'title' => $this->get_breadcrumb_post_title($current_post_id, $settings),
3484 4170 'url' => get_permalink($current_post_id),
3485 4171 'position' => $position,
3486 4172 'current' => true
3487 4173 ];
@@ -3498,9 +4184,9 @@
3498 4184 while ($parent_id) {
3499 4185 $parent = get_post($parent_id);
3500 4186 if ($parent) {
3501 4187 $parents[] = [
3502 - 'title' => get_the_title($parent->ID),
4188 + 'title' => $this->get_breadcrumb_post_title($parent->ID, $settings),
3503 4189 'url' => get_permalink($parent->ID),
3504 4190 'position' => 0 // Will be set later
3505 4191 ];
3506 4192 $parent_id = $parent->post_parent;
@@ -3520,9 +4206,9 @@
3520 4206
3521 4207 // Add current page
3522 4208 if ($settings['show_current_page'] ?? true) {
3523 4209 $items[] = [
3524 - 'title' => get_the_title($current_post_id),
4210 + 'title' => $this->get_breadcrumb_post_title($current_post_id, $settings),
3525 4211 'url' => get_permalink($current_post_id),
3526 4212 'position' => $position,
3527 4213 'current' => true
3528 4214 ];
@@ -3538,9 +4224,9 @@
3538 4224 while ($parent_id) {
3539 4225 $parent = get_category($parent_id);
3540 4226 if ($parent && !is_wp_error($parent)) {
3541 4227 $parents[] = [
3542 - 'title' => $parent->name,
4228 + 'title' => $this->get_breadcrumb_term_title($parent, $settings),
3543 4229 'url' => get_category_link($parent->term_id),
3544 4230 'position' => 0 // Will be set later
3545 4231 ];
3546 4232 $parent_id = $parent->parent;
@@ -3560,9 +4246,9 @@
3560 4246
3561 4247 // Add current category
3562 4248 if ($settings['show_current_page'] ?? true) {
3563 4249 $items[] = [
3564 - 'title' => $category->name,
4250 + 'title' => $this->get_breadcrumb_term_title($category, $settings),
3565 4251 'url' => get_category_link($category->term_id),
3566 4252 'position' => $position,
3567 4253 'current' => true
3568 4254 ];