PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 1.1.0 1.10.0 All 48 releases
← All changes | includes/frontend/class-seo-manager.php +667 -84 2.2.02.7.0 View file →
@@ -38,16 +38,8 @@
38 38 * Current post metadata
39 39 *
40 40 * @var array
41 41 */
42 - /**
43 - * Page-specific schemas the free tier renders on one page.
44 - *
45 - * @since 2.0.1
46 - * @var int
47 - */
48 - private const FREE_PAGE_SCHEMA_LIMIT = 2;
49 -
50 42 private array $current_metadata = [];
51 43
52 44 /**
53 45 * Term ID of the archive being rendered, when the request is a term archive.
@@ -64,8 +56,29 @@
64 56 */
65 57 private ?\ThinkRank\SEO\Site_Identity_Manager $site_identity_manager = null;
66 58
67 59 /**
60 + * Resolved icon URLs, keyed by "<md5 of configured URL>:<size>".
61 + *
62 + * wp_site_icon() renders four tags per page and each one resolves the same
63 + * setting, so without this the lookup is four rounds of
64 + * attachment_url_to_postid() — an uncached postmeta query apiece — for one
65 + * answer. Loaded from, and persisted to, a transient: this filter runs in
66 + * wp_head on every FRONT-END request, and the mapping only changes when the
67 + * icon setting does.
68 + *
69 + * @var array<string, string>|null Null until loaded.
70 + */
71 + private ?array $icon_urls = null;
72 +
73 + /**
74 + * Whether $icon_urls gained an entry that is not in the transient yet.
75 + *
76 + * @var bool
77 + */
78 + private bool $icon_urls_dirty = false;
79 +
80 + /**
68 81 * Social Meta Manager instance
69 82 *
70 83 * @var \ThinkRank\SEO\Social_Meta_Manager|null
71 84 */
@@ -99,8 +112,16 @@
99 112 */
100 113 private ?\ThinkRank\SEO\Image_SEO_Manager $image_seo_manager = null;
101 114
102 115 /**
116 + * External Links Manager instance
117 + *
118 + * @since 2.5.0
119 + * @var \ThinkRank\SEO\External_Links_Manager|null
120 + */
121 + private ?\ThinkRank\SEO\External_Links_Manager $external_links_manager = null;
122 +
123 + /**
103 124 * Current page context
104 125 *
105 126 * @var string
106 127 */
@@ -154,17 +175,22 @@
154 175
155 176 // Initialize Global SEO Schema Output
156 177 $this->initialize_global_seo_schema();
157 178
158 - // Initialize Google Analytics Tracking Manager
159 - $this->initialize_google_analytics_tracking();
160 -
161 179 // Initialize Image SEO Manager
162 180 $this->initialize_image_seo_manager();
163 181
182 + // Initialize External Links Manager (rel=nofollow / target=_blank)
183 + $this->initialize_external_links_manager();
184 +
164 185 // Initialize current post and context data first
165 186 add_action('wp', [$this, 'initialize_current_context']);
166 187
188 + // ...then let it be corrected if the request turns into a 404 later.
189 + // Late, so every set_404() on this hook has already run; still well
190 + // before wp_head, which the template fires.
191 + add_action('template_redirect', [$this, 'recheck_404_context'], 999);
192 +
167 193 // Use HIGH PRIORITY hooks to override other SEO plugins
168 194 // Priority 1-5 ensures ThinkRank runs before other SEO plugins
169 195
170 196 // Override WordPress title with HIGH priority
@@ -195,8 +221,18 @@
195 221 // the page would emit two <link rel="canonical"> tags on singular views.
196 222 remove_action('wp_head', 'rel_canonical');
197 223 add_action('wp_head', [$this, 'output_canonical_url'], 6);
198 224
225 + // Silence the Bricks theme's own SEO + Open Graph output so a Bricks
226 + // site doesn't ship two of every tag. Bricks is a THEME, so it loads
227 + // after plugins: at this point BRICKS_VERSION is not yet defined and a
228 + // `defined()` guard here would always be false. Registering the filters
229 + // unconditionally is correct and free — the hooks only ever fire from
230 + // inside Bricks itself (#257). This mirrors the core rel_canonical and
231 + // wp_robots removals above: one producer per tag.
232 + add_filter('bricks/frontend/disable_seo', '__return_true');
233 + add_filter('bricks/frontend/disable_opengraph', '__return_true');
234 +
199 235 // Add Site Identity specific outputs
200 236 add_action('wp_head', [$this, 'output_site_schema_markup'], 7);
201 237 add_action('wp_head', [$this, 'output_breadcrumb_schema'], 8);
202 238 // Late enough that Global_SEO_Schema_Output (priority 15) has registered.
@@ -263,8 +299,17 @@
263 299 // Serve the Site Identity favicon through core's site-icon pipeline so
264 300 // wp_site_icon() outputs it on the front-end (and previews pick it up)
265 301 add_filter('get_site_icon_url', [$this, 'filter_site_icon_url'], 10, 2);
266 302
303 + // Rewrite outbound anchors (rel=nofollow / target=_blank). Runs at
304 + // the very end of the_content, after core's formatting AND after the
305 + // image filter above, so it sees the markup the visitor will get. The
306 + // stored post_content is never touched — turning the settings off
307 + // restores the author's markup exactly.
308 + add_filter('the_content', [$this, 'filter_external_links'], 100000);
309 + add_filter('the_excerpt', [$this, 'filter_external_links'], 100000);
310 + add_filter('widget_text_content', [$this, 'filter_external_links'], 100000);
311 +
267 312 // Process image SEO in content
268 313 add_filter('the_content', [$this, 'filter_content_images'], 99999);
269 314 add_filter('post_thumbnail_html', [$this, 'filter_content_images'], 11, 2);
270 315 add_filter('woocommerce_single_product_image_thumbnail_html', [$this, 'filter_content_images'], 11);
@@ -328,35 +373,61 @@
328 373 $this->global_seo_schema->init();
329 374 }
330 375
331 376 /**
332 - * Initialize Google Analytics Tracking Manager
377 + * Initialize Image SEO Manager
333 378 *
334 379 * @return void
335 380 */
336 - private function initialize_google_analytics_tracking(): void {
337 - if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
338 - require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-google-analytics-tracking-manager.php';
381 + private function initialize_image_seo_manager(): void {
382 + if (!class_exists('ThinkRank\\SEO\\Image_SEO_Manager')) {
383 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-image-seo-manager.php';
339 384 }
340 385
341 - // Initialize Google Analytics Tracking Manager
342 - new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
386 + $this->image_seo_manager = new \ThinkRank\SEO\Image_SEO_Manager();
343 387 }
344 388
345 389 /**
346 - * Initialize Image SEO Manager
390 + * Initialize External Links Manager
347 391 *
392 + * @since 2.5.0
348 393 * @return void
349 394 */
350 - private function initialize_image_seo_manager(): void {
351 - if (!class_exists('ThinkRank\\SEO\\Image_SEO_Manager')) {
352 - require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-image-seo-manager.php';
395 + private function initialize_external_links_manager(): void {
396 + if (!class_exists('ThinkRank\\SEO\\External_Links_Manager')) {
397 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-external-links-manager.php';
353 398 }
354 399
355 - $this->image_seo_manager = new \ThinkRank\SEO\Image_SEO_Manager();
400 + $this->external_links_manager = new \ThinkRank\SEO\External_Links_Manager();
356 401 }
357 402
358 403 /**
404 + * Filter rendered content to annotate external links
405 + *
406 + * @since 2.5.0
407 + * @param mixed $content Content to filter; passed through untouched when
408 + * it is not a string.
409 + * @return mixed Filtered content.
410 + */
411 + public function filter_external_links($content) {
412 + // No return type: a filter value another plugin hands through as null
413 + // or an object belongs to whoever set it, and coercing it to '' would
414 + // silently drop their content on the floor.
415 + if (!is_string($content) || $content === '' || !$this->external_links_manager) {
416 + return $content;
417 + }
418 +
419 + // Feeds carry the same markup to a reader we do not control; leave
420 + // them as authored rather than annotating for a context that has no
421 + // browser tab to open.
422 + if (is_feed()) {
423 + return $content;
424 + }
425 +
426 + return $this->external_links_manager->process_content($content);
427 + }
428 +
429 + /**
359 430 * Filter content to inject image SEO attributes
360 431 *
361 432 * @since 1.0.0
362 433 * @param string $content Content to filter
@@ -428,8 +499,40 @@
428 499 $this->load_site_identity_data();
429 500 }
430 501
431 502 /**
503 + * Drop the request's post identity once it has become a 404.
504 + *
505 + * `initialize_current_context()` runs on `wp`, but a request can be turned
506 + * into a 404 after that: `set_404()` on `template_redirect` is the ordinary
507 + * way to refuse a URL that did resolve to a real post, and both core and
508 + * plugins do it — ThinkRank Pro's Markdown for AI refuses an ineligible
509 + * `.md` URL that way. The snapshot still said `post`/`page` and still held
510 + * the post id and its metadata, so the error page shipped that post's meta
511 + * description, focus keywords and — where the social emitters got that far
512 + * — its og:description and twitter:description, all of which a request that
513 + * was a 404 from the start never prints (#655).
514 + *
515 + * Clearing the snapshot rather than special-casing each emitter is what
516 + * makes every consumer agree, including the ones that read
517 + * `$current_metadata` without ever asking what the context is.
518 + *
519 + * @since 2.3.1
520 + *
521 + * @return void
522 + */
523 + public function recheck_404_context(): void {
524 + if (!is_404() || '404' === $this->current_context) {
525 + return;
526 + }
527 +
528 + $this->current_context = '404';
529 + $this->current_post_id = null;
530 + $this->current_term_id = null;
531 + $this->current_metadata = [];
532 + }
533 +
534 + /**
432 535 * Detect current page context
433 536 *
434 537 * @return string Current context type
435 538 */
@@ -554,8 +657,14 @@
554 657 * @param string $title Original title
555 658 * @return string Modified title
556 659 */
557 660 public function override_document_title($title): string {
661 + // A content type with metas switched off keeps whatever title the theme
662 + // and WordPress produce (#660).
663 + if (!$this->metas_enabled()) {
664 + return $title;
665 + }
666 +
558 667 // First priority: Post-specific ThinkRank metadata
559 668 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
560 669 return self::with_page_suffix($this->current_metadata['title']);
561 670 }
@@ -582,9 +691,86 @@
582 691 *
583 692 * @param string $title Resolved title.
584 693 * @return string Title with the page indicator, when there is one.
585 694 */
695 + /**
696 + * The archive's subject, without the label WordPress prefixes it with.
697 + *
698 + * `get_the_archive_title()` returns "Month: September 2026", "Archives:
699 + * Recipes", "Category: Uncategorized" — the label is core's, aimed at an
700 + * archive heading on the page, and it reads badly in a browser tab, an
701 + * og:title or a search result. Category, tag and author contexts already
702 + * avoid it by using the raw name; the generic archive context did not, so
703 + * date, custom-post-type and custom-taxonomy archives carried it (#640).
704 + *
705 + * Removed through core's own `get_the_archive_title_prefix` filter rather
706 + * than by matching the prefix text, because that text is translated and
707 + * differs per archive type — a string comparison would work in English and
708 + * silently stop working everywhere else.
709 + *
710 + * A site that wants a prefix can put one in its title template, where it is
711 + * visible and editable, instead of inheriting one it cannot see.
712 + *
713 + * @since 2.7.0
714 + *
715 + * @return string Archive subject, with markup and the core prefix removed.
716 + */
717 + private static function archive_subject(): string {
718 + $drop_prefix = static function (): string {
719 + return '';
720 + };
721 +
722 + add_filter('get_the_archive_title_prefix', $drop_prefix, 99);
723 +
724 + $title = (string) get_the_archive_title();
725 +
726 + remove_filter('get_the_archive_title_prefix', $drop_prefix, 99);
727 +
728 + // The <span> core wraps the subject in survives the prefix filter.
729 + return trim(wp_strip_all_tags($title));
730 + }
731 +
732 + /**
733 + * Remove HTML from a title that is about to be emitted.
734 + *
735 + * A title carrying markup is broken twice over, in two different ways, and
736 + * both were reaching real pages: inside `<title>` the tags render literally,
737 + * because that element is RCDATA and never parses them; inside `og:title`
738 + * and `twitter:title` they are attribute-escaped, so the reader sees
739 + * `&lt;em&gt;` as visible text (#640).
740 + *
741 + * Applied at the point of emission rather than at each source, so it covers
742 + * every branch that can produce a title — post meta, Global SEO templates,
743 + * Site Identity templates — without each having to remember.
744 + *
745 + * Unconditional rather than a setting: there is no title for which markup is
746 + * the correct output. The filter is the escape hatch for anyone who
747 + * disagrees, and lets a site keep entities it deliberately encoded.
748 + *
749 + * @since 2.7.0
750 + *
751 + * @param string $title Title about to be emitted.
752 + * @return string Title with any markup removed.
753 + */
754 + public static function strip_title_tags(string $title): string {
755 + /**
756 + * Filter whether HTML is stripped from generated titles.
757 + *
758 + * @since 2.7.0
759 + *
760 + * @param bool $strip Whether to strip. Default true.
761 + * @param string $title The title being emitted.
762 + */
763 + if (!apply_filters('thinkrank_strip_title_tags', true, $title)) {
764 + return $title;
765 + }
766 +
767 + return trim(wp_strip_all_tags($title));
768 + }
769 +
586 770 public static function with_page_suffix(string $title): string {
771 + $title = self::strip_title_tags($title);
772 +
587 773 $page = self::current_page_number();
588 774
589 775 if ($page <= 1 || '' === $title) {
590 776 return $title;
@@ -609,8 +795,12 @@
609 795 * @param string $sep Title separator
610 796 * @return string Modified title
611 797 */
612 798 public function override_wp_title(string $title, string $sep = ''): string {
799 + if (!$this->metas_enabled()) {
800 + return $title;
801 + }
802 +
613 803 // First priority: Post-specific ThinkRank metadata
614 804 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
615 805 $site_name = get_bloginfo('name');
616 806 return self::with_page_suffix(
@@ -627,8 +817,25 @@
627 817 return $title;
628 818 }
629 819
630 820 /**
821 + * Whether ThinkRank owns the title and meta description for this request.
822 + *
823 + * Metas are on site-wide by default; the per-content-type matrix can switch
824 + * them off for one content type, in which case ThinkRank stops overriding
825 + * the document title and prints no meta description (#660).
826 + *
827 + * @since 2.5.0
828 + * @return bool
829 + */
830 + private function metas_enabled(): bool {
831 + return \ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
832 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_META,
833 + true
834 + );
835 + }
836 +
837 + /**
631 838 * Output meta description (HIGH PRIORITY)
632 839 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates > WordPress defaults
633 840 *
634 841 * Author archives are skipped entirely: Author_Archives_Manager owns that
@@ -643,8 +850,12 @@
643 850 if (is_author()) {
644 851 return;
645 852 }
646 853
854 + if (!$this->metas_enabled()) {
855 + return;
856 + }
857 +
647 858 $description = $this->get_meta_description();
648 859
649 860 if ($description) {
650 861 // Output main ThinkRank SEO header comment (only once)
@@ -650,11 +861,13 @@
650 861 // Output main ThinkRank SEO header comment (only once)
651 862 self::note_opening_comment();
652 863
653 864 // Ensure description is within optimal length (150-160 characters)
654 - if (strlen($description) > 160) {
655 - $description = wp_trim_words($description, 25, '...');
656 - }
865 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
866 + // CJK description tripped this limit at a third of its length, and
867 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
868 + // English, 25 characters in Thai (#687).
869 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
657 870
658 871 echo "<!-- ThinkRank SEO Meta Description -->\n";
659 872 echo '<meta name="description" content="' . esc_attr($description) . '" />' . "\n";
660 873 echo "<!-- /ThinkRank SEO Meta Description -->\n";
@@ -705,8 +918,34 @@
705 918 echo "<!-- /ThinkRank SEO Meta Tags -->\n";
706 919 }
707 920
708 921 /**
922 + * Build the basic robots directive list from a set of robots flags.
923 + *
924 + * Shared by the search/404 branch of get_robots_meta_content() so those
925 + * pages resolve their directives through the same rules as everything else
926 + * rather than a hardcoded literal.
927 + *
928 + * @since 2.5.0
929 + * @param array $settings Robots flags (index/noindex/nofollow/...).
930 + * @return string[] Directives.
931 + */
932 + private static function build_robots_directives(array $settings): array {
933 + $robots = [];
934 +
935 + $robots[] = !empty($settings['noindex']) ? 'noindex' : 'index';
936 + $robots[] = !empty($settings['nofollow']) ? 'nofollow' : 'follow';
937 +
938 + foreach (['noarchive', 'noimageindex', 'nosnippet'] as $directive) {
939 + if (!empty($settings[$directive])) {
940 + $robots[] = $directive;
941 + }
942 + }
943 +
944 + return $robots;
945 + }
946 +
947 + /**
709 948 * Get robots meta content based on context and settings
710 949 *
711 950 * @return string Robots meta content
712 951 */
@@ -712,13 +951,22 @@
712 951 */
713 952 private function get_robots_meta_content(): string {
714 953 $robots = [];
715 954
716 - // 404 and search results must never be indexed, regardless of the
717 - // configured global/post-type directives. Links are still followed so
718 - // crawlers can discover the rest of the site.
955 + // 404 and search results are noindex/follow by default — the behaviour
956 + // that used to be hardcoded here. It is now settings-driven (#660): the
957 + // Content Type Matrix can give either its own robots directives, and an
958 + // install that never touched them resolves to exactly the old pair.
719 959 if (is_404() || is_search()) {
720 - $robots = apply_filters('thinkrank_robots_meta', ['noindex', 'follow']);
960 + $entity = is_404()
961 + ? \ThinkRank\SEO\Content_Type_Settings::ENTITY_404
962 + : \ThinkRank\SEO\Content_Type_Settings::ENTITY_SEARCH;
963 +
964 + $robots = self::build_robots_directives(
965 + \ThinkRank\SEO\Content_Type_Settings::resolve_robots_meta($entity)
966 + );
967 +
968 + $robots = apply_filters('thinkrank_robots_meta', $robots);
721 969 return implode(', ', array_unique($robots));
722 970 }
723 971
724 972 // 1. Get global robot meta settings (Base)
@@ -747,8 +995,24 @@
747 995 $current_settings = array_merge($current_settings, $global_seo_settings[$post_type]['robots_meta']);
748 996 }
749 997 }
750 998
999 + // 2b. Apply the per-entity directives for the non-singular content
1000 + // types the matrix covers — taxonomy archives plus author and date
1001 + // archives. Terms keep their own per-term override, applied further
1002 + // down so it still wins over the taxonomy-wide value (#660).
1003 + if (!is_singular()) {
1004 + $entity_key = \ThinkRank\SEO\Content_Type_Settings::current_entity_key();
1005 +
1006 + if ($entity_key !== null) {
1007 + $entity_settings = \ThinkRank\SEO\Content_Type_Settings::get_entity_settings($entity_key);
1008 +
1009 + if (!empty($entity_settings['robots_meta_enabled']) && is_array($entity_settings['robots_meta'] ?? null)) {
1010 + $current_settings = array_merge($current_settings, $entity_settings['robots_meta']);
1011 + }
1012 + }
1013 + }
1014 +
751 1015 // Determine Index/Noindex based on merged settings
752 1016 // Priority: if noindex is true, it overrides index
753 1017 if (!empty($current_settings['noindex'])) {
754 1018 $robots[] = 'noindex';
@@ -1115,9 +1379,9 @@
1115 1379 *
1116 1380 * @param array $og_tags Open Graph tags array
1117 1381 * @return void
1118 1382 */
1119 - private function output_social_og_tags(array $og_tags): void {
1383 + private function output_social_og_tags(array $og_tags, array $extra_images = []): void {
1120 1384 // Honor the thinkrank_og_type filter here too — this "Enhanced" path is
1121 1385 // the active OG emitter, so add-ons (e.g. Pro's WooCommerce module which
1122 1386 // sets 'product' on product pages) must be applied to it, not only to
1123 1387 // output_open_graph_tags().
@@ -1161,12 +1425,62 @@
1161 1425 echo '<meta property="' . esc_attr($property) . '" content="' . $this->esc_meta_value($property, $content) . '" />' . "\n";
1162 1426 }
1163 1427 }
1164 1428
1429 + // Alternatives, after the primary and everything belonging to it.
1430 + // Order is the whole point: a consumer reads og:image tags in document
1431 + // order and treats the first as primary, and a structured property
1432 + // attaches to the most recently declared image — so each alternative's
1433 + // companions have to follow its own URL, not be grouped at the end.
1434 + self::output_extra_og_images($extra_images);
1435 +
1165 1436 echo "<!-- /ThinkRank SEO Open Graph Tags -->\n";
1166 1437 }
1167 1438
1168 1439 /**
1440 + * Emit the secondary og:image tags a page offers.
1441 + *
1442 + * Shared by the enhanced and basic emitters so both describe an
1443 + * alternative image the same way (#636).
1444 + *
1445 + * @since 2.7.0
1446 + *
1447 + * @param array $images Each with url, and width/height/type/alt where known.
1448 + * @return void
1449 + */
1450 + private static function output_extra_og_images(array $images): void {
1451 + foreach ($images as $image) {
1452 + $url = isset($image['url']) ? (string) $image['url'] : '';
1453 +
1454 + if ('' === $url) {
1455 + continue;
1456 + }
1457 +
1458 + echo '<meta property="og:image" content="' . esc_url($url) . '" />' . "\n";
1459 +
1460 + if (strpos($url, 'https://') === 0) {
1461 + echo '<meta property="og:image:secure_url" content="' . esc_url($url) . '" />' . "\n";
1462 + }
1463 +
1464 + // Only what is actually known: a dimension guessed for a remote
1465 + // image is a number a consumer lays a card out with before it has
1466 + // fetched the file.
1467 + if (!empty($image['width']) && !empty($image['height'])) {
1468 + echo '<meta property="og:image:width" content="' . esc_attr((string) $image['width']) . '" />' . "\n";
1469 + echo '<meta property="og:image:height" content="' . esc_attr((string) $image['height']) . '" />' . "\n";
1470 + }
1471 +
1472 + if (!empty($image['type'])) {
1473 + echo '<meta property="og:image:type" content="' . esc_attr((string) $image['type']) . '" />' . "\n";
1474 + }
1475 +
1476 + if (!empty($image['alt'])) {
1477 + echo '<meta property="og:image:alt" content="' . esc_attr((string) $image['alt']) . '" />' . "\n";
1478 + }
1479 + }
1480 + }
1481 +
1482 + /**
1169 1483 * Output social media Twitter Card tags from Social Meta Manager
1170 1484 *
1171 1485 * @param array $twitter_tags Twitter Card tags array
1172 1486 * @return void
@@ -1231,8 +1545,16 @@
1231 1545 *
1232 1546 * @return void
1233 1547 */
1234 1548 public function output_platform_meta_tags(): void {
1549 + // Same reasoning as the Open Graph and Twitter emitters: an error page
1550 + // has no shareable identity, and passing '404' through as a social
1551 + // context asks the manager for settings that describe a page which does
1552 + // not exist. Guarding all three keeps them from disagreeing.
1553 + if ($this->current_context === '404') {
1554 + return;
1555 + }
1556 +
1235 1557 // Try Social Meta Manager for platform tags
1236 1558 if ($this->social_manager) {
1237 1559 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1238 1560 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
@@ -1284,8 +1606,17 @@
1284 1606 *
1285 1607 * @return void
1286 1608 */
1287 1609 public function output_open_graph_tags(): void {
1610 + // Per-content-type Open Graph switch. 'inherit' (the default) keeps the
1611 + // site-wide Social Media setting, which the emitters below read (#660).
1612 + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
1613 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_OPEN_GRAPH,
1614 + true
1615 + )) {
1616 + return;
1617 + }
1618 +
1288 1619 // An error page has no shareable identity. Emitting Open Graph here
1289 1620 // advertised the homepage as the og:url of a URL that does not exist.
1290 1621 if ($this->current_context === '404') {
1291 1622 return;
@@ -1312,9 +1643,12 @@
1312 1643 // The Social Meta Manager ran, so it owns Open Graph output. If OG is
1313 1644 // toggled off, emit nothing — do NOT fall through to the basic
1314 1645 // emitter (which would re-add a full OG block despite the toggle).
1315 1646 if (!empty($social_data['og_enabled'])) {
1316 - $this->output_social_og_tags($social_data['og_tags']);
1647 + $this->output_social_og_tags(
1648 + $social_data['og_tags'],
1649 + $social_data['og_extra_images'] ?? []
1650 + );
1317 1651 }
1318 1652 return;
1319 1653 }
1320 1654
@@ -1382,9 +1716,9 @@
1382 1716 // "There is no excerpt because this is a protected post." placeholder,
1383 1717 // so this is not a leak — but publishing that sentence as the social
1384 1718 // description is worse than publishing none (#363).
1385 1719 if (!$description && !$this->is_content_password_protected()) {
1386 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1720 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1387 1721 }
1388 1722
1389 1723 $url = is_singular() ? get_permalink() : home_url();
1390 1724 $site_name = $this->site_identity_data && !empty($this->site_identity_data['identity']['site_name'])
@@ -1412,11 +1746,11 @@
1412 1746 $og_type = apply_filters('thinkrank_og_type', $og_type);
1413 1747
1414 1748 echo "<!-- ThinkRank SEO Open Graph Meta Tags -->\n";
1415 1749 echo "<meta property=\"og:type\" content=\"" . esc_attr($og_type) . "\" />\n";
1416 - echo "<meta property=\"og:title\" content=\"" . esc_attr($title) . "\" />\n";
1750 + echo "<meta property=\"og:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1417 1751 echo "<meta property=\"og:description\" content=\"" . esc_attr($description) . "\" />\n";
1418 - echo "<meta property=\"og:url\" content=\"" . esc_url($url) . "\" />\n";
1752 + echo "<meta property=\"og:url\" content=\"" . esc_url(\ThinkRank\SEO\Url_Scheme::apply($url)) . "\" />\n";
1419 1753 echo "<meta property=\"og:site_name\" content=\"" . esc_attr($site_name) . "\" />\n";
1420 1754 /**
1421 1755 * Filter the og:locale value.
1422 1756 *
@@ -1433,14 +1767,17 @@
1433 1767 $og_locale = (string) apply_filters('thinkrank_og_locale', get_locale());
1434 1768 echo "<meta property=\"og:locale\" content=\"" . esc_attr($og_locale) . "\" />\n";
1435 1769
1436 1770 // Add OG image — per-post override > featured image
1771 + $primary_og_image = '';
1437 1772 if (is_singular() && $this->current_post_id) {
1438 1773 if (!empty($og_image_override)) {
1774 + $primary_og_image = (string) $og_image_override;
1439 1775 echo "<meta property=\"og:image\" content=\"" . esc_url($og_image_override) . "\" />\n";
1440 1776 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($og_image_override) . "\" />\n";
1441 1777 } elseif (has_post_thumbnail($this->current_post_id)) {
1442 1778 $image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
1779 + $primary_og_image = (string) $image_url;
1443 1780 echo "<meta property=\"og:image\" content=\"" . esc_url($image_url) . "\" />\n";
1444 1781 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($image_url) . "\" />\n";
1445 1782
1446 1783 // Get image dimensions and alt text
@@ -1469,8 +1806,30 @@
1469 1806 echo "<meta property=\"og:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n";
1470 1807 }
1471 1808 }
1472 1809
1810 + // Alternatives, same as the enhanced emitter above. This path only
1811 + // runs when the Social Meta Manager is unavailable, but the issue
1812 + // reported against it (#636) and a site that lands here should not
1813 + // silently lose a feature it switched on.
1814 + if (!empty($primary_og_image)) {
1815 + $social_settings = $this->social_manager
1816 + ? $this->social_manager->get_settings(
1817 + $this->current_context === 'homepage' ? 'site' : $this->current_context,
1818 + $this->current_post_id
1819 + )
1820 + : [];
1821 +
1822 + if (!empty($social_settings['og_multiple_images'])) {
1823 + self::output_extra_og_images(
1824 + \ThinkRank\SEO\Social_Images::additional(
1825 + (int) $this->current_post_id,
1826 + $primary_og_image
1827 + )
1828 + );
1829 + }
1830 + }
1831 +
1473 1832 // Add article specific tags for posts only
1474 1833 if ($og_type === 'article') {
1475 1834 echo '<meta property="article:published_time" content="' . esc_attr(get_the_date('c', $this->current_post_id)) . '" />' . "\n";
1476 1835 echo '<meta property="article:modified_time" content="' . esc_attr(get_the_modified_date('c', $this->current_post_id)) . '" />' . "\n";
@@ -1498,8 +1857,16 @@
1498 1857 *
1499 1858 * @return void
1500 1859 */
1501 1860 public function output_twitter_card_tags(): void {
1861 + // Per-content-type Twitter card switch; see output_open_graph_tags().
1862 + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
1863 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_TWITTER,
1864 + true
1865 + )) {
1866 + return;
1867 + }
1868 +
1502 1869 // Same reasoning as the Open Graph block: nothing on a 404 is shareable.
1503 1870 if ($this->current_context === '404') {
1504 1871 return;
1505 1872 }
@@ -1588,9 +1955,9 @@
1588 1955 // "There is no excerpt because this is a protected post." placeholder,
1589 1956 // so this is not a leak — but publishing that sentence as the social
1590 1957 // description is worse than publishing none (#363).
1591 1958 if (!$description && !$this->is_content_password_protected()) {
1592 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1959 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1593 1960 }
1594 1961
1595 1962 // Determine card type based on image availability
1596 1963 $card_type = 'summary';
@@ -1599,9 +1966,9 @@
1599 1966 }
1600 1967
1601 1968 echo "<!-- ThinkRank SEO Twitter Card Meta Tags -->\n";
1602 1969 echo '<meta name="twitter:card" content="' . esc_attr($card_type) . '" />' . "\n";
1603 - echo "<meta name=\"twitter:title\" content=\"" . esc_attr($title) . "\" />\n";
1970 + echo "<meta name=\"twitter:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1604 1971 echo "<meta name=\"twitter:description\" content=\"" . esc_attr($description) . "\" />\n";
1605 1972
1606 1973 // Add Twitter image with proper fallback priority
1607 1974 $twitter_image_url = $this->get_twitter_image_with_fallback();
@@ -1676,8 +2043,13 @@
1676 2043 if (empty($canonical_url)) {
1677 2044 return;
1678 2045 }
1679 2046
2047 + // After the filter, so a canonical an add-on supplied is normalized
2048 + // too — and a cross-domain one is left alone, since Url_Scheme only
2049 + // touches URLs on this site's own host.
2050 + $canonical_url = \ThinkRank\SEO\Url_Scheme::apply($canonical_url);
2051 +
1680 2052 echo "<!-- ThinkRank SEO Canonical URL -->\n";
1681 2053 echo "<link rel=\"canonical\" href=\"" . esc_url($canonical_url) . "\" />\n";
1682 2054 echo "<!-- /ThinkRank SEO Canonical URL -->\n";
1683 2055
@@ -1724,9 +2096,9 @@
1724 2096
1725 2097 if ($current > 1) {
1726 2098 printf(
1727 2099 "<link rel=\"prev\" href=\"%s\" />\n",
1728 - esc_url(self::with_pagination($base, $current - 1))
2100 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current - 1)))
1729 2101 );
1730 2102 }
1731 2103
1732 2104 if ($current < $total) {
@@ -1731,9 +2103,9 @@
1731 2103
1732 2104 if ($current < $total) {
1733 2105 printf(
1734 2106 "<link rel=\"next\" href=\"%s\" />\n",
1735 - esc_url(self::with_pagination($base, $current + 1))
2107 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current + 1)))
1736 2108 );
1737 2109 }
1738 2110 }
1739 2111
@@ -2060,9 +2432,9 @@
2060 2432 if (!empty($post->post_excerpt)) {
2061 2433 $placeholders['%excerpt%'] = $post->post_excerpt;
2062 2434 } elseif (!$this->is_content_password_protected($post->ID)) {
2063 2435 $placeholders['%excerpt%'] = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt(
2064 - (string) $post->post_content
2436 + \ThinkRank\SEO\Builder_Content::visible_content($post)
2065 2437 );
2066 2438 }
2067 2439 }
2068 2440
@@ -2233,9 +2605,9 @@
2233 2605 // Stripped: get_the_archive_title() wraps its subject in a
2234 2606 // <span>, and this placeholder feeds the document <title> as
2235 2607 // well as og:title and twitter:title — a date archive rendered
2236 2608 // as "Month: <span>August 2026</span> | Site".
2237 - $placeholders['%archive_title%'] = wp_strip_all_tags((string) get_the_archive_title());
2609 + $placeholders['%archive_title%'] = self::archive_subject();
2238 2610 break;
2239 2611
2240 2612 case 'homepage':
2241 2613 // The page template resolved for a static posts page needs the
@@ -2376,9 +2748,15 @@
2376 2748 // gated body published its first ~25 words in the page head, and the
2377 2749 // same value is reused for og:description and twitter:description, so
2378 2750 // one unguarded read leaked through three tags (#363).
2379 2751 if (is_singular() && $this->current_post_id && !$this->is_content_password_protected()) {
2380 - $post_content = get_post_field('post_content', $this->current_post_id);
2752 + // Not the raw column: a Bricks page discards `post_content`, so
2753 + // whatever is still stored there is invisible — and this one value
2754 + // becomes the meta, og: and twitter: descriptions (#651).
2755 + $described = get_post($this->current_post_id);
2756 + $post_content = $described instanceof \WP_Post
2757 + ? \ThinkRank\SEO\Builder_Content::visible_content($described)
2758 + : get_post_field('post_content', $this->current_post_id);
2381 2759 if ($post_content) {
2382 2760 $excerpt = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt((string) $post_content);
2383 2761 if (!empty($excerpt)) {
2384 2762 return $excerpt;
@@ -2424,11 +2802,13 @@
2424 2802 if ($description === '') {
2425 2803 return null;
2426 2804 }
2427 2805
2428 - if (strlen($description) > 160) {
2429 - $description = wp_trim_words($description, 25, '...');
2430 - }
2806 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2807 + // CJK description tripped this limit at a third of its length, and
2808 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2809 + // English, 25 characters in Thai (#687).
2810 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2431 2811
2432 2812 return $description;
2433 2813 }
2434 2814
@@ -2475,11 +2855,13 @@
2475 2855 $description = preg_replace('/\s+/', ' ', $description);
2476 2856 $description = trim($description);
2477 2857
2478 2858 // Ensure description doesn't exceed recommended length (160 characters)
2479 - if (strlen($description) > 160) {
2480 - $description = wp_trim_words($description, 25, '...');
2481 - }
2859 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2860 + // CJK description tripped this limit at a third of its length, and
2861 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2862 + // English, 25 characters in Thai (#687).
2863 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2482 2864
2483 2865 return $description;
2484 2866 }
2485 2867
@@ -2546,9 +2928,19 @@
2546 2928
2547 2929 $page_specific_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
2548 2930
2549 2931 if (!empty($page_specific_schemas)) {
2550 - // Apply filter for Pro to allow multiple schemas
2932 + // Every deployed schema is rendered, on every plan. How many a
2933 + // page carries is decided when schemas are activated in the
2934 + // editor, not trimmed here by plan (#673).
2935 +
2936 + /**
2937 + * Filter the page-specific schemas rendered on the current page.
2938 + *
2939 + * @param array $page_specific_schemas Deployed schemas keyed by schema type.
2940 + * @param string $context_type Context type (post, page, product, site).
2941 + * @param int $context_id Post ID.
2942 + */
2551 2943 $page_specific_schemas = apply_filters(
2552 2944 'thinkrank_page_schemas_to_render',
2553 2945 $page_specific_schemas,
2554 2946 $context_type,
@@ -2554,27 +2946,8 @@
2554 2946 $context_type,
2555 2947 $context_id
2556 2948 );
2557 2949
2558 - // Free tier renders at most self::FREE_PAGE_SCHEMA_LIMIT
2559 - // page-specific schemas; Pro renders all of them.
2560 - //
2561 - // Both comments here used to say the free limit was 1 while the
2562 - // code allowed 2 (#405). The number the code enforces is what
2563 - // has shipped, so that is what stands — lowering it would take
2564 - // a schema away from every free site on upgrade — and it now
2565 - // lives in one named place instead of twice in prose and twice
2566 - // in a literal.
2567 - if (!\ThinkRank\Core\Plan_Config::is_pro()
2568 - && count($page_specific_schemas) > self::FREE_PAGE_SCHEMA_LIMIT) {
2569 - $page_specific_schemas = array_slice(
2570 - $page_specific_schemas,
2571 - 0,
2572 - self::FREE_PAGE_SCHEMA_LIMIT,
2573 - true
2574 - );
2575 - }
2576 -
2577 2950 foreach ($page_specific_schemas as $schema_type => $schema_info) {
2578 2951 Schema_Graph::instance()->add_primary($schema_info['data'], (string) $schema_type, 'schema_manager');
2579 2952 }
2580 2953 $has_schema_manager_output = true;
@@ -2638,17 +3011,41 @@
2638 3011 if (!empty($description)) {
2639 3012 $schema['description'] = $description;
2640 3013 }
2641 3014
2642 - $schema['potentialAction'] = [
2643 - '@type' => 'SearchAction',
2644 - 'target' => [
2645 - '@type' => 'EntryPoint',
2646 - 'urlTemplate' => home_url('/?s={search_term_string}'),
2647 - ],
2648 - 'query-input' => 'required name=search_term_string',
2649 - ];
3015 + // Site Identity has accepted an alternate name since the setup wizard
3016 + // shipped, and the MCP ability describes it as "published as schema
3017 + // alternateName" — but no producer ever read it, so the promise was
3018 + // false and every imported Yoast/Rank Math value sat unused (#692).
3019 + $alternate_name = \ThinkRank\SEO\Site_Identity_Manager::alternate_name_for_schema($settings['alternate_name'] ?? null);
3020 + if (null !== $alternate_name) {
3021 + $schema['alternateName'] = $alternate_name;
3022 + }
2650 3023
3024 + // The sitelinks searchbox switch was honoured only for a deployed
3025 + // WebSite row; this live fallback added potentialAction unconditionally,
3026 + // so website_enable_search = 0 still shipped the SearchAction (#688).
3027 + // Absent means not configured, which stays enabled.
3028 + $search_enabled = true;
3029 + if ($this->schema_manager) {
3030 + $schema_settings = $this->schema_manager->get_settings('site', null);
3031 +
3032 + if (array_key_exists('website_enable_search', $schema_settings)) {
3033 + $search_enabled = !empty($schema_settings['website_enable_search']);
3034 + }
3035 + }
3036 +
3037 + if ($search_enabled) {
3038 + $schema['potentialAction'] = [
3039 + '@type' => 'SearchAction',
3040 + 'target' => [
3041 + '@type' => 'EntryPoint',
3042 + 'urlTemplate' => home_url('/?s={search_term_string}'),
3043 + ],
3044 + 'query-input' => 'required name=search_term_string',
3045 + ];
3046 + }
3047 +
2651 3048 return $schema;
2652 3049 }
2653 3050
2654 3051 /**
@@ -2859,8 +3256,22 @@
2859 3256 if (empty($settings['breadcrumbs_enabled'])) {
2860 3257 return;
2861 3258 }
2862 3259
3260 + // Schema Manager's own breadcrumb switch. Only Site Identity's
3261 + // breadcrumbs_enabled was consulted here, so enable_breadcrumbs_schema
3262 + // = 0 removed a deployed BreadcrumbList row and left this live one
3263 + // emitting the node anyway (#688). Absent means not configured, which
3264 + // stays enabled.
3265 + if ($this->schema_manager) {
3266 + $schema_settings = $this->schema_manager->get_settings('site', null);
3267 +
3268 + if (array_key_exists('enable_breadcrumbs_schema', $schema_settings)
3269 + && empty($schema_settings['enable_breadcrumbs_schema'])) {
3270 + return;
3271 + }
3272 + }
3273 +
2863 3274 $breadcrumbs = $this->generate_breadcrumbs($settings);
2864 3275
2865 3276 if (!empty($breadcrumbs['schema'])) {
2866 3277 Schema_Graph::instance()->add_supporting($breadcrumbs['schema'], 'BreadcrumbList');
@@ -3411,15 +3822,17 @@
3411 3822 if (empty($settings['enabled'])) {
3412 3823 return (string) $url;
3413 3824 }
3414 3825
3826 + $size = (int) $size;
3827 +
3415 3828 // Apple touch icon has its own dedicated setting
3416 - if ((int) $size === 180 && !empty($settings['apple_touch_icon_url'])) {
3417 - return esc_url($settings['apple_touch_icon_url']);
3829 + if ($size === 180 && !empty($settings['apple_touch_icon_url'])) {
3830 + return $this->resolve_icon_url((string) $settings['apple_touch_icon_url'], $size);
3418 3831 }
3419 3832
3420 3833 if (!empty($settings['favicon_url'])) {
3421 - return esc_url($settings['favicon_url']);
3834 + return $this->resolve_icon_url((string) $settings['favicon_url'], $size);
3422 3835 }
3423 3836
3424 3837 return (string) $url;
3425 3838 }
@@ -3424,8 +3837,178 @@
3424 3837 return (string) $url;
3425 3838 }
3426 3839
3427 3840 /**
3841 + * Whether breadcrumb labels should prefer the SEO title.
3842 + *
3843 + * Off unless the site turns it on, so updating the plugin never rewrites an
3844 + * existing trail.
3845 + *
3846 + * @since 2.3.1
3847 + *
3848 + * @param array $settings Breadcrumb settings.
3849 + * @return bool
3850 + */
3851 + private function breadcrumbs_use_seo_title(array $settings): bool {
3852 + return !empty($settings['breadcrumb_use_seo_title']);
3853 + }
3854 +
3855 + /**
3856 + * Label for a post in the breadcrumb trail.
3857 + *
3858 + * With the toggle on, the post's own SEO title wins — the same
3859 + * `_thinkrank_seo_title` value (variable tags resolved) the document title
3860 + * uses — so the trail under a search snippet reads the same as the snippet
3861 + * itself. Anything empty falls back to the raw post title; the global title
3862 + * pattern is deliberately NOT part of the chain, since resolving it would
3863 + * append the site name to every crumb.
3864 + *
3865 + * @since 2.3.1
3866 + *
3867 + * @param int $post_id Post ID.
3868 + * @param array $settings Breadcrumb settings.
3869 + * @return string Breadcrumb label.
3870 + */
3871 + private function get_breadcrumb_post_title(int $post_id, array $settings): string {
3872 + $title = (string) get_the_title($post_id);
3873 +
3874 + if (!$this->breadcrumbs_use_seo_title($settings)) {
3875 + return $title;
3876 + }
3877 +
3878 + $seo_title = trim((string) get_post_meta($post_id, '_thinkrank_seo_title', true));
3879 +
3880 + if ('' === $seo_title) {
3881 + return $title;
3882 + }
3883 +
3884 + $resolved = trim(\ThinkRank\SEO\Pattern_Resolver::resolve_value($seo_title, $post_id));
3885 +
3886 + return '' !== $resolved ? $resolved : $title;
3887 + }
3888 +
3889 + /**
3890 + * Label for a term in the breadcrumb trail.
3891 + *
3892 + * Term counterpart to {@see self::get_breadcrumb_post_title()}, resolving
3893 + * the term's `_thinkrank_seo_title` against its own values.
3894 + *
3895 + * @since 2.3.1
3896 + *
3897 + * @param object $term Term object.
3898 + * @param array $settings Breadcrumb settings.
3899 + * @return string Breadcrumb label.
3900 + */
3901 + private function get_breadcrumb_term_title($term, array $settings): string {
3902 + $name = (string) ($term->name ?? '');
3903 +
3904 + if (!$this->breadcrumbs_use_seo_title($settings) || empty($term->term_id)) {
3905 + return $name;
3906 + }
3907 +
3908 + $seo_title = trim((string) get_term_meta((int) $term->term_id, '_thinkrank_seo_title', true));
3909 +
3910 + if ('' === $seo_title) {
3911 + return $name;
3912 + }
3913 +
3914 + $resolved = trim(\ThinkRank\SEO\Pattern_Resolver::resolve_term_value($seo_title, (int) $term->term_id));
3915 +
3916 + return '' !== $resolved ? $resolved : $name;
3917 + }
3918 +
3919 + /**
3920 + * Resolve a configured icon URL to the derivative that fits $size.
3921 + *
3922 + * wp_site_icon() calls get_site_icon_url() four times — 32, 192, 180 and
3923 + * 270 — and pairs the first two with a hardcoded sizes="" attribute. This
3924 + * filter used to answer all four with the same configured URL, so one
3925 + * upload was declared as every size at once: a 1536x1536 original served
3926 + * to paint a 32px tab icon, under a sizes="32x32" label that was simply
3927 + * untrue (#571).
3928 + *
3929 + * Resolution mirrors core's own get_site_icon_url(), including the
3930 + * >= 512 -> 'full' branch, so ThinkRank's override and the core pipeline
3931 + * pick the same file for the same request.
3932 + *
3933 + * An unresolvable URL (one hosted off-site) is returned unchanged. Nothing
3934 + * is knowable about its dimensions, and suppressing it instead would leave
3935 + * the page with no rel="icon" at all — a worse outcome than an approximate
3936 + * size hint.
3937 + *
3938 + * @param string $configured Configured icon URL.
3939 + * @param int $size Icon size core is asking for.
3940 + * @return string Icon URL for that size.
3941 + */
3942 + private function resolve_icon_url(string $configured, int $size): string {
3943 + $cache_key = md5($configured) . ':' . $size;
3944 + $cached = $this->icon_urls();
3945 +
3946 + if (isset($cached[$cache_key])) {
3947 + return $cached[$cache_key];
3948 + }
3949 +
3950 + $attachment_id = \ThinkRank\SEO\Site_Identity_Manager::icon_attachment_id($configured);
3951 +
3952 + if (!$attachment_id) {
3953 + $resolved = esc_url($configured);
3954 + } else {
3955 + // Mirrors core: at 512 and above the original is what is wanted, and
3956 + // asking for an intermediate size that large would only fall back to it.
3957 + $size_data = $size >= 512 ? 'full' : [$size, $size];
3958 + $url = wp_get_attachment_image_url($attachment_id, $size_data);
3959 + $resolved = $url ? esc_url($url) : esc_url($configured);
3960 + }
3961 +
3962 + $this->icon_urls[$cache_key] = $resolved;
3963 +
3964 + if (!$this->icon_urls_dirty) {
3965 + $this->icon_urls_dirty = true;
3966 + // Written once, after the response is assembled, rather than once
3967 + // per size: wp_site_icon() resolves four in a row.
3968 + add_action('shutdown', [$this, 'persist_icon_urls'], 5);
3969 + }
3970 +
3971 + return $resolved;
3972 + }
3973 +
3974 + /**
3975 + * The resolved-icon-URL map, loaded from its transient on first use.
3976 + *
3977 + * @return array<string, string>
3978 + */
3979 + private function icon_urls(): array {
3980 + if ($this->icon_urls === null) {
3981 + $stored = get_transient(\ThinkRank\SEO\Site_Identity_Manager::ICON_URL_TRANSIENT);
3982 + $this->icon_urls = is_array($stored) ? $stored : [];
3983 + }
3984 +
3985 + return $this->icon_urls;
3986 + }
3987 +
3988 + /**
3989 + * Persist newly resolved icon URLs.
3990 + *
3991 + * Public because it runs on `shutdown`. Invalidated wholesale whenever the
3992 + * site identity settings are saved, which is the only moment the icon
3993 + * choice — or the derivatives behind it — can change.
3994 + *
3995 + * @return void
3996 + */
3997 + public function persist_icon_urls(): void {
3998 + if (!$this->icon_urls_dirty || !is_array($this->icon_urls)) {
3999 + return;
4000 + }
4001 +
4002 + $this->icon_urls_dirty = false;
4003 + set_transient(
4004 + \ThinkRank\SEO\Site_Identity_Manager::ICON_URL_TRANSIENT,
4005 + $this->icon_urls,
4006 + DAY_IN_SECONDS
4007 + );
4008 + }
4009 +
4010 + /**
3428 4011 * Get breadcrumb items for current page
3429 4012 *
3430 4013 * @param array $settings Breadcrumb settings
3431 4014 * @return array Breadcrumb items
@@ -3453,9 +4036,9 @@
3453 4036 $categories = get_the_category($current_post_id);
3454 4037 if (!empty($categories)) {
3455 4038 $category = $categories[0];
3456 4039 $items[] = [
3457 - 'title' => $category->name,
4040 + 'title' => $this->get_breadcrumb_term_title($category, $settings),
3458 4041 'url' => get_category_link($category->term_id),
3459 4042 'position' => $position++
3460 4043 ];
3461 4044 }
@@ -3469,9 +4052,9 @@
3469 4052 // already had the correct form: default to on, respect an
3470 4053 // explicit off.
3471 4054 if ($settings['show_current_page'] ?? true) {
3472 4055 $items[] = [
3473 - 'title' => get_the_title($current_post_id),
4056 + 'title' => $this->get_breadcrumb_post_title($current_post_id, $settings),
3474 4057 'url' => get_permalink($current_post_id),
3475 4058 'position' => $position,
3476 4059 'current' => true
3477 4060 ];
@@ -3488,9 +4071,9 @@
3488 4071 while ($parent_id) {
3489 4072 $parent = get_post($parent_id);
3490 4073 if ($parent) {
3491 4074 $parents[] = [
3492 - 'title' => get_the_title($parent->ID),
4075 + 'title' => $this->get_breadcrumb_post_title($parent->ID, $settings),
3493 4076 'url' => get_permalink($parent->ID),
3494 4077 'position' => 0 // Will be set later
3495 4078 ];
3496 4079 $parent_id = $parent->post_parent;
@@ -3510,9 +4093,9 @@
3510 4093
3511 4094 // Add current page
3512 4095 if ($settings['show_current_page'] ?? true) {
3513 4096 $items[] = [
3514 - 'title' => get_the_title($current_post_id),
4097 + 'title' => $this->get_breadcrumb_post_title($current_post_id, $settings),
3515 4098 'url' => get_permalink($current_post_id),
3516 4099 'position' => $position,
3517 4100 'current' => true
3518 4101 ];
@@ -3528,9 +4111,9 @@
3528 4111 while ($parent_id) {
3529 4112 $parent = get_category($parent_id);
3530 4113 if ($parent && !is_wp_error($parent)) {
3531 4114 $parents[] = [
3532 - 'title' => $parent->name,
4115 + 'title' => $this->get_breadcrumb_term_title($parent, $settings),
3533 4116 'url' => get_category_link($parent->term_id),
3534 4117 'position' => 0 // Will be set later
3535 4118 ];
3536 4119 $parent_id = $parent->parent;
@@ -3550,9 +4133,9 @@
3550 4133
3551 4134 // Add current category
3552 4135 if ($settings['show_current_page'] ?? true) {
3553 4136 $items[] = [
3554 - 'title' => $category->name,
4137 + 'title' => $this->get_breadcrumb_term_title($category, $settings),
3555 4138 'url' => get_category_link($category->term_id),
3556 4139 'position' => $position,
3557 4140 'current' => true
3558 4141 ];