PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.11.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.11.0
2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 All 52 releases
← All changes | includes/frontend/class-seo-manager.php +540 -74 2.4.0 → 2.11.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.
@@ -120,8 +112,16 @@
120 112 */
121 113 private ?\ThinkRank\SEO\Image_SEO_Manager $image_seo_manager = null;
122 114
123 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 + /**
124 124 * Current page context
125 125 *
126 126 * @var string
127 127 */
@@ -175,14 +175,14 @@
175 175
176 176 // Initialize Global SEO Schema Output
177 177 $this->initialize_global_seo_schema();
178 178
179 - // Initialize Google Analytics Tracking Manager
180 - $this->initialize_google_analytics_tracking();
181 -
182 179 // Initialize Image SEO Manager
183 180 $this->initialize_image_seo_manager();
184 181
182 + // Initialize External Links Manager (rel=nofollow / target=_blank)
183 + $this->initialize_external_links_manager();
184 +
185 185 // Initialize current post and context data first
186 186 add_action('wp', [$this, 'initialize_current_context']);
187 187
188 188 // ...then let it be corrected if the request turns into a 404 later.
@@ -266,8 +266,16 @@
266 266 // LLMs_Txt_Manager for the static file) guarantees an explicit UTF-8
267 267 // charset. Priority 8 keeps it ahead of redirect_canonical().
268 268 add_action('template_redirect', [$this, 'maybe_serve_llms_txt'], 8);
269 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 +
270 278 // Take WordPress core's own sitemap offline while ThinkRank's is active.
271 279 // Two sitemap indexes on one site is a crawl conflict: core keeps
272 280 // /wp-sitemap.xml served and injects its own "Sitemap:" line into
273 281 // robots.txt (WP_Sitemaps::add_robots, priority 0). Until now that line
@@ -299,8 +307,17 @@
299 307 // Serve the Site Identity favicon through core's site-icon pipeline so
300 308 // wp_site_icon() outputs it on the front-end (and previews pick it up)
301 309 add_filter('get_site_icon_url', [$this, 'filter_site_icon_url'], 10, 2);
302 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 +
303 320 // Process image SEO in content
304 321 add_filter('the_content', [$this, 'filter_content_images'], 99999);
305 322 add_filter('post_thumbnail_html', [$this, 'filter_content_images'], 11, 2);
306 323 add_filter('woocommerce_single_product_image_thumbnail_html', [$this, 'filter_content_images'], 11);
@@ -360,39 +377,73 @@
360 377 }
361 378
362 379 // Initialize Global SEO Schema Output and store reference
363 380 $this->global_seo_schema = new Global_SEO_Schema_Output();
381 + // Let schema reuse the description this class already resolves, so the
382 + // JSON-LD and the meta/og/twitter tags cannot disagree about what the
383 + // page is (#766). Passed as a callback rather than a value: schema is
384 + // built during wp_head, by which point the request context this
385 + // resolution depends on is set, and it must not be captured earlier.
386 + $this->global_seo_schema->set_description_resolver(
387 + fn (): string => (string) $this->get_meta_description()
388 + );
364 389 $this->global_seo_schema->init();
365 390 }
366 391
367 392 /**
368 - * Initialize Google Analytics Tracking Manager
393 + * Initialize Image SEO Manager
369 394 *
370 395 * @return void
371 396 */
372 - private function initialize_google_analytics_tracking(): void {
373 - if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
374 - require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-google-analytics-tracking-manager.php';
397 + private function initialize_image_seo_manager(): void {
398 + if (!class_exists('ThinkRank\\SEO\\Image_SEO_Manager')) {
399 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-image-seo-manager.php';
375 400 }
376 401
377 - // Initialize Google Analytics Tracking Manager
378 - new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
402 + $this->image_seo_manager = new \ThinkRank\SEO\Image_SEO_Manager();
379 403 }
380 404
381 405 /**
382 - * Initialize Image SEO Manager
406 + * Initialize External Links Manager
383 407 *
408 + * @since 2.5.0
384 409 * @return void
385 410 */
386 - private function initialize_image_seo_manager(): void {
387 - if (!class_exists('ThinkRank\\SEO\\Image_SEO_Manager')) {
388 - require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-image-seo-manager.php';
411 + private function initialize_external_links_manager(): void {
412 + if (!class_exists('ThinkRank\\SEO\\External_Links_Manager')) {
413 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-external-links-manager.php';
389 414 }
390 415
391 - $this->image_seo_manager = new \ThinkRank\SEO\Image_SEO_Manager();
416 + $this->external_links_manager = new \ThinkRank\SEO\External_Links_Manager();
392 417 }
393 418
394 419 /**
420 + * Filter rendered content to annotate external links
421 + *
422 + * @since 2.5.0
423 + * @param mixed $content Content to filter; passed through untouched when
424 + * it is not a string.
425 + * @return mixed Filtered content.
426 + */
427 + public function filter_external_links($content) {
428 + // No return type: a filter value another plugin hands through as null
429 + // or an object belongs to whoever set it, and coercing it to '' would
430 + // silently drop their content on the floor.
431 + if (!is_string($content) || $content === '' || !$this->external_links_manager) {
432 + return $content;
433 + }
434 +
435 + // Feeds carry the same markup to a reader we do not control; leave
436 + // them as authored rather than annotating for a context that has no
437 + // browser tab to open.
438 + if (is_feed()) {
439 + return $content;
440 + }
441 +
442 + return $this->external_links_manager->process_content($content);
443 + }
444 +
445 + /**
395 446 * Filter content to inject image SEO attributes
396 447 *
397 448 * @since 1.0.0
398 449 * @param string $content Content to filter
@@ -622,8 +673,14 @@
622 673 * @param string $title Original title
623 674 * @return string Modified title
624 675 */
625 676 public function override_document_title($title): string {
677 + // A content type with metas switched off keeps whatever title the theme
678 + // and WordPress produce (#660).
679 + if (!$this->metas_enabled()) {
680 + return $title;
681 + }
682 +
626 683 // First priority: Post-specific ThinkRank metadata
627 684 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
628 685 return self::with_page_suffix($this->current_metadata['title']);
629 686 }
@@ -650,9 +707,86 @@
650 707 *
651 708 * @param string $title Resolved title.
652 709 * @return string Title with the page indicator, when there is one.
653 710 */
711 + /**
712 + * The archive's subject, without the label WordPress prefixes it with.
713 + *
714 + * `get_the_archive_title()` returns "Month: September 2026", "Archives:
715 + * Recipes", "Category: Uncategorized" — the label is core's, aimed at an
716 + * archive heading on the page, and it reads badly in a browser tab, an
717 + * og:title or a search result. Category, tag and author contexts already
718 + * avoid it by using the raw name; the generic archive context did not, so
719 + * date, custom-post-type and custom-taxonomy archives carried it (#640).
720 + *
721 + * Removed through core's own `get_the_archive_title_prefix` filter rather
722 + * than by matching the prefix text, because that text is translated and
723 + * differs per archive type — a string comparison would work in English and
724 + * silently stop working everywhere else.
725 + *
726 + * A site that wants a prefix can put one in its title template, where it is
727 + * visible and editable, instead of inheriting one it cannot see.
728 + *
729 + * @since 2.7.0
730 + *
731 + * @return string Archive subject, with markup and the core prefix removed.
732 + */
733 + private static function archive_subject(): string {
734 + $drop_prefix = static function (): string {
735 + return '';
736 + };
737 +
738 + add_filter('get_the_archive_title_prefix', $drop_prefix, 99);
739 +
740 + $title = (string) get_the_archive_title();
741 +
742 + remove_filter('get_the_archive_title_prefix', $drop_prefix, 99);
743 +
744 + // The <span> core wraps the subject in survives the prefix filter.
745 + return trim(wp_strip_all_tags($title));
746 + }
747 +
748 + /**
749 + * Remove HTML from a title that is about to be emitted.
750 + *
751 + * A title carrying markup is broken twice over, in two different ways, and
752 + * both were reaching real pages: inside `<title>` the tags render literally,
753 + * because that element is RCDATA and never parses them; inside `og:title`
754 + * and `twitter:title` they are attribute-escaped, so the reader sees
755 + * `&lt;em&gt;` as visible text (#640).
756 + *
757 + * Applied at the point of emission rather than at each source, so it covers
758 + * every branch that can produce a title — post meta, Global SEO templates,
759 + * Site Identity templates — without each having to remember.
760 + *
761 + * Unconditional rather than a setting: there is no title for which markup is
762 + * the correct output. The filter is the escape hatch for anyone who
763 + * disagrees, and lets a site keep entities it deliberately encoded.
764 + *
765 + * @since 2.7.0
766 + *
767 + * @param string $title Title about to be emitted.
768 + * @return string Title with any markup removed.
769 + */
770 + public static function strip_title_tags(string $title): string {
771 + /**
772 + * Filter whether HTML is stripped from generated titles.
773 + *
774 + * @since 2.7.0
775 + *
776 + * @param bool $strip Whether to strip. Default true.
777 + * @param string $title The title being emitted.
778 + */
779 + if (!apply_filters('thinkrank_strip_title_tags', true, $title)) {
780 + return $title;
781 + }
782 +
783 + return trim(wp_strip_all_tags($title));
784 + }
785 +
654 786 public static function with_page_suffix(string $title): string {
787 + $title = self::strip_title_tags($title);
788 +
655 789 $page = self::current_page_number();
656 790
657 791 if ($page <= 1 || '' === $title) {
658 792 return $title;
@@ -677,8 +811,12 @@
677 811 * @param string $sep Title separator
678 812 * @return string Modified title
679 813 */
680 814 public function override_wp_title(string $title, string $sep = ''): string {
815 + if (!$this->metas_enabled()) {
816 + return $title;
817 + }
818 +
681 819 // First priority: Post-specific ThinkRank metadata
682 820 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
683 821 $site_name = get_bloginfo('name');
684 822 return self::with_page_suffix(
@@ -695,8 +833,25 @@
695 833 return $title;
696 834 }
697 835
698 836 /**
837 + * Whether ThinkRank owns the title and meta description for this request.
838 + *
839 + * Metas are on site-wide by default; the per-content-type matrix can switch
840 + * them off for one content type, in which case ThinkRank stops overriding
841 + * the document title and prints no meta description (#660).
842 + *
843 + * @since 2.5.0
844 + * @return bool
845 + */
846 + private function metas_enabled(): bool {
847 + return \ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
848 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_META,
849 + true
850 + );
851 + }
852 +
853 + /**
699 854 * Output meta description (HIGH PRIORITY)
700 855 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates > WordPress defaults
701 856 *
702 857 * Author archives are skipped entirely: Author_Archives_Manager owns that
@@ -711,8 +866,12 @@
711 866 if (is_author()) {
712 867 return;
713 868 }
714 869
870 + if (!$this->metas_enabled()) {
871 + return;
872 + }
873 +
715 874 $description = $this->get_meta_description();
716 875
717 876 if ($description) {
718 877 // Output main ThinkRank SEO header comment (only once)
@@ -718,11 +877,13 @@
718 877 // Output main ThinkRank SEO header comment (only once)
719 878 self::note_opening_comment();
720 879
721 880 // Ensure description is within optimal length (150-160 characters)
722 - if (strlen($description) > 160) {
723 - $description = wp_trim_words($description, 25, '...');
724 - }
881 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
882 + // CJK description tripped this limit at a third of its length, and
883 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
884 + // English, 25 characters in Thai (#687).
885 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
725 886
726 887 echo "<!-- ThinkRank SEO Meta Description -->\n";
727 888 echo '<meta name="description" content="' . esc_attr($description) . '" />' . "\n";
728 889 echo "<!-- /ThinkRank SEO Meta Description -->\n";
@@ -773,8 +934,34 @@
773 934 echo "<!-- /ThinkRank SEO Meta Tags -->\n";
774 935 }
775 936
776 937 /**
938 + * Build the basic robots directive list from a set of robots flags.
939 + *
940 + * Shared by the search/404 branch of get_robots_meta_content() so those
941 + * pages resolve their directives through the same rules as everything else
942 + * rather than a hardcoded literal.
943 + *
944 + * @since 2.5.0
945 + * @param array $settings Robots flags (index/noindex/nofollow/...).
946 + * @return string[] Directives.
947 + */
948 + private static function build_robots_directives(array $settings): array {
949 + $robots = [];
950 +
951 + $robots[] = !empty($settings['noindex']) ? 'noindex' : 'index';
952 + $robots[] = !empty($settings['nofollow']) ? 'nofollow' : 'follow';
953 +
954 + foreach (['noarchive', 'noimageindex', 'nosnippet'] as $directive) {
955 + if (!empty($settings[$directive])) {
956 + $robots[] = $directive;
957 + }
958 + }
959 +
960 + return $robots;
961 + }
962 +
963 + /**
777 964 * Get robots meta content based on context and settings
778 965 *
779 966 * @return string Robots meta content
780 967 */
@@ -780,13 +967,22 @@
780 967 */
781 968 private function get_robots_meta_content(): string {
782 969 $robots = [];
783 970
784 - // 404 and search results must never be indexed, regardless of the
785 - // configured global/post-type directives. Links are still followed so
786 - // crawlers can discover the rest of the site.
971 + // 404 and search results are noindex/follow by default — the behaviour
972 + // that used to be hardcoded here. It is now settings-driven (#660): the
973 + // Content Type Matrix can give either its own robots directives, and an
974 + // install that never touched them resolves to exactly the old pair.
787 975 if (is_404() || is_search()) {
788 - $robots = apply_filters('thinkrank_robots_meta', ['noindex', 'follow']);
976 + $entity = is_404()
977 + ? \ThinkRank\SEO\Content_Type_Settings::ENTITY_404
978 + : \ThinkRank\SEO\Content_Type_Settings::ENTITY_SEARCH;
979 +
980 + $robots = self::build_robots_directives(
981 + \ThinkRank\SEO\Content_Type_Settings::resolve_robots_meta($entity)
982 + );
983 +
984 + $robots = apply_filters('thinkrank_robots_meta', $robots);
789 985 return implode(', ', array_unique($robots));
790 986 }
791 987
792 988 // 1. Get global robot meta settings (Base)
@@ -815,8 +1011,24 @@
815 1011 $current_settings = array_merge($current_settings, $global_seo_settings[$post_type]['robots_meta']);
816 1012 }
817 1013 }
818 1014
1015 + // 2b. Apply the per-entity directives for the non-singular content
1016 + // types the matrix covers — taxonomy archives plus author and date
1017 + // archives. Terms keep their own per-term override, applied further
1018 + // down so it still wins over the taxonomy-wide value (#660).
1019 + if (!is_singular()) {
1020 + $entity_key = \ThinkRank\SEO\Content_Type_Settings::current_entity_key();
1021 +
1022 + if ($entity_key !== null) {
1023 + $entity_settings = \ThinkRank\SEO\Content_Type_Settings::get_entity_settings($entity_key);
1024 +
1025 + if (!empty($entity_settings['robots_meta_enabled']) && is_array($entity_settings['robots_meta'] ?? null)) {
1026 + $current_settings = array_merge($current_settings, $entity_settings['robots_meta']);
1027 + }
1028 + }
1029 + }
1030 +
819 1031 // Determine Index/Noindex based on merged settings
820 1032 // Priority: if noindex is true, it overrides index
821 1033 if (!empty($current_settings['noindex'])) {
822 1034 $robots[] = 'noindex';
@@ -1183,9 +1395,9 @@
1183 1395 *
1184 1396 * @param array $og_tags Open Graph tags array
1185 1397 * @return void
1186 1398 */
1187 - private function output_social_og_tags(array $og_tags): void {
1399 + private function output_social_og_tags(array $og_tags, array $extra_images = []): void {
1188 1400 // Honor the thinkrank_og_type filter here too — this "Enhanced" path is
1189 1401 // the active OG emitter, so add-ons (e.g. Pro's WooCommerce module which
1190 1402 // sets 'product' on product pages) must be applied to it, not only to
1191 1403 // output_open_graph_tags().
@@ -1229,12 +1441,62 @@
1229 1441 echo '<meta property="' . esc_attr($property) . '" content="' . $this->esc_meta_value($property, $content) . '" />' . "\n";
1230 1442 }
1231 1443 }
1232 1444
1445 + // Alternatives, after the primary and everything belonging to it.
1446 + // Order is the whole point: a consumer reads og:image tags in document
1447 + // order and treats the first as primary, and a structured property
1448 + // attaches to the most recently declared image — so each alternative's
1449 + // companions have to follow its own URL, not be grouped at the end.
1450 + self::output_extra_og_images($extra_images);
1451 +
1233 1452 echo "<!-- /ThinkRank SEO Open Graph Tags -->\n";
1234 1453 }
1235 1454
1236 1455 /**
1456 + * Emit the secondary og:image tags a page offers.
1457 + *
1458 + * Shared by the enhanced and basic emitters so both describe an
1459 + * alternative image the same way (#636).
1460 + *
1461 + * @since 2.7.0
1462 + *
1463 + * @param array $images Each with url, and width/height/type/alt where known.
1464 + * @return void
1465 + */
1466 + private static function output_extra_og_images(array $images): void {
1467 + foreach ($images as $image) {
1468 + $url = isset($image['url']) ? (string) $image['url'] : '';
1469 +
1470 + if ('' === $url) {
1471 + continue;
1472 + }
1473 +
1474 + echo '<meta property="og:image" content="' . esc_url($url) . '" />' . "\n";
1475 +
1476 + if (strpos($url, 'https://') === 0) {
1477 + echo '<meta property="og:image:secure_url" content="' . esc_url($url) . '" />' . "\n";
1478 + }
1479 +
1480 + // Only what is actually known: a dimension guessed for a remote
1481 + // image is a number a consumer lays a card out with before it has
1482 + // fetched the file.
1483 + if (!empty($image['width']) && !empty($image['height'])) {
1484 + echo '<meta property="og:image:width" content="' . esc_attr((string) $image['width']) . '" />' . "\n";
1485 + echo '<meta property="og:image:height" content="' . esc_attr((string) $image['height']) . '" />' . "\n";
1486 + }
1487 +
1488 + if (!empty($image['type'])) {
1489 + echo '<meta property="og:image:type" content="' . esc_attr((string) $image['type']) . '" />' . "\n";
1490 + }
1491 +
1492 + if (!empty($image['alt'])) {
1493 + echo '<meta property="og:image:alt" content="' . esc_attr((string) $image['alt']) . '" />' . "\n";
1494 + }
1495 + }
1496 + }
1497 +
1498 + /**
1237 1499 * Output social media Twitter Card tags from Social Meta Manager
1238 1500 *
1239 1501 * @param array $twitter_tags Twitter Card tags array
1240 1502 * @return void
@@ -1360,8 +1622,17 @@
1360 1622 *
1361 1623 * @return void
1362 1624 */
1363 1625 public function output_open_graph_tags(): void {
1626 + // Per-content-type Open Graph switch. 'inherit' (the default) keeps the
1627 + // site-wide Social Media setting, which the emitters below read (#660).
1628 + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
1629 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_OPEN_GRAPH,
1630 + true
1631 + )) {
1632 + return;
1633 + }
1634 +
1364 1635 // An error page has no shareable identity. Emitting Open Graph here
1365 1636 // advertised the homepage as the og:url of a URL that does not exist.
1366 1637 if ($this->current_context === '404') {
1367 1638 return;
@@ -1388,9 +1659,12 @@
1388 1659 // The Social Meta Manager ran, so it owns Open Graph output. If OG is
1389 1660 // toggled off, emit nothing — do NOT fall through to the basic
1390 1661 // emitter (which would re-add a full OG block despite the toggle).
1391 1662 if (!empty($social_data['og_enabled'])) {
1392 - $this->output_social_og_tags($social_data['og_tags']);
1663 + $this->output_social_og_tags(
1664 + $social_data['og_tags'],
1665 + $social_data['og_extra_images'] ?? []
1666 + );
1393 1667 }
1394 1668 return;
1395 1669 }
1396 1670
@@ -1458,9 +1732,9 @@
1458 1732 // "There is no excerpt because this is a protected post." placeholder,
1459 1733 // so this is not a leak — but publishing that sentence as the social
1460 1734 // description is worse than publishing none (#363).
1461 1735 if (!$description && !$this->is_content_password_protected()) {
1462 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1736 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1463 1737 }
1464 1738
1465 1739 $url = is_singular() ? get_permalink() : home_url();
1466 1740 $site_name = $this->site_identity_data && !empty($this->site_identity_data['identity']['site_name'])
@@ -1488,11 +1762,11 @@
1488 1762 $og_type = apply_filters('thinkrank_og_type', $og_type);
1489 1763
1490 1764 echo "<!-- ThinkRank SEO Open Graph Meta Tags -->\n";
1491 1765 echo "<meta property=\"og:type\" content=\"" . esc_attr($og_type) . "\" />\n";
1492 - echo "<meta property=\"og:title\" content=\"" . esc_attr($title) . "\" />\n";
1766 + echo "<meta property=\"og:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1493 1767 echo "<meta property=\"og:description\" content=\"" . esc_attr($description) . "\" />\n";
1494 - echo "<meta property=\"og:url\" content=\"" . esc_url($url) . "\" />\n";
1768 + echo "<meta property=\"og:url\" content=\"" . esc_url(\ThinkRank\SEO\Url_Scheme::apply($url)) . "\" />\n";
1495 1769 echo "<meta property=\"og:site_name\" content=\"" . esc_attr($site_name) . "\" />\n";
1496 1770 /**
1497 1771 * Filter the og:locale value.
1498 1772 *
@@ -1509,14 +1783,17 @@
1509 1783 $og_locale = (string) apply_filters('thinkrank_og_locale', get_locale());
1510 1784 echo "<meta property=\"og:locale\" content=\"" . esc_attr($og_locale) . "\" />\n";
1511 1785
1512 1786 // Add OG image — per-post override > featured image
1787 + $primary_og_image = '';
1513 1788 if (is_singular() && $this->current_post_id) {
1514 1789 if (!empty($og_image_override)) {
1790 + $primary_og_image = (string) $og_image_override;
1515 1791 echo "<meta property=\"og:image\" content=\"" . esc_url($og_image_override) . "\" />\n";
1516 1792 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($og_image_override) . "\" />\n";
1517 1793 } elseif (has_post_thumbnail($this->current_post_id)) {
1518 1794 $image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
1795 + $primary_og_image = (string) $image_url;
1519 1796 echo "<meta property=\"og:image\" content=\"" . esc_url($image_url) . "\" />\n";
1520 1797 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($image_url) . "\" />\n";
1521 1798
1522 1799 // Get image dimensions and alt text
@@ -1545,8 +1822,30 @@
1545 1822 echo "<meta property=\"og:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n";
1546 1823 }
1547 1824 }
1548 1825
1826 + // Alternatives, same as the enhanced emitter above. This path only
1827 + // runs when the Social Meta Manager is unavailable, but the issue
1828 + // reported against it (#636) and a site that lands here should not
1829 + // silently lose a feature it switched on.
1830 + if (!empty($primary_og_image)) {
1831 + $social_settings = $this->social_manager
1832 + ? $this->social_manager->get_settings(
1833 + $this->current_context === 'homepage' ? 'site' : $this->current_context,
1834 + $this->current_post_id
1835 + )
1836 + : [];
1837 +
1838 + if (!empty($social_settings['og_multiple_images'])) {
1839 + self::output_extra_og_images(
1840 + \ThinkRank\SEO\Social_Images::additional(
1841 + (int) $this->current_post_id,
1842 + $primary_og_image
1843 + )
1844 + );
1845 + }
1846 + }
1847 +
1549 1848 // Add article specific tags for posts only
1550 1849 if ($og_type === 'article') {
1551 1850 echo '<meta property="article:published_time" content="' . esc_attr(get_the_date('c', $this->current_post_id)) . '" />' . "\n";
1552 1851 echo '<meta property="article:modified_time" content="' . esc_attr(get_the_modified_date('c', $this->current_post_id)) . '" />' . "\n";
@@ -1574,8 +1873,16 @@
1574 1873 *
1575 1874 * @return void
1576 1875 */
1577 1876 public function output_twitter_card_tags(): void {
1877 + // Per-content-type Twitter card switch; see output_open_graph_tags().
1878 + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
1879 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_TWITTER,
1880 + true
1881 + )) {
1882 + return;
1883 + }
1884 +
1578 1885 // Same reasoning as the Open Graph block: nothing on a 404 is shareable.
1579 1886 if ($this->current_context === '404') {
1580 1887 return;
1581 1888 }
@@ -1664,9 +1971,9 @@
1664 1971 // "There is no excerpt because this is a protected post." placeholder,
1665 1972 // so this is not a leak — but publishing that sentence as the social
1666 1973 // description is worse than publishing none (#363).
1667 1974 if (!$description && !$this->is_content_password_protected()) {
1668 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1975 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1669 1976 }
1670 1977
1671 1978 // Determine card type based on image availability
1672 1979 $card_type = 'summary';
@@ -1675,9 +1982,9 @@
1675 1982 }
1676 1983
1677 1984 echo "<!-- ThinkRank SEO Twitter Card Meta Tags -->\n";
1678 1985 echo '<meta name="twitter:card" content="' . esc_attr($card_type) . '" />' . "\n";
1679 - echo "<meta name=\"twitter:title\" content=\"" . esc_attr($title) . "\" />\n";
1986 + echo "<meta name=\"twitter:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1680 1987 echo "<meta name=\"twitter:description\" content=\"" . esc_attr($description) . "\" />\n";
1681 1988
1682 1989 // Add Twitter image with proper fallback priority
1683 1990 $twitter_image_url = $this->get_twitter_image_with_fallback();
@@ -1752,8 +2059,13 @@
1752 2059 if (empty($canonical_url)) {
1753 2060 return;
1754 2061 }
1755 2062
2063 + // After the filter, so a canonical an add-on supplied is normalized
2064 + // too — and a cross-domain one is left alone, since Url_Scheme only
2065 + // touches URLs on this site's own host.
2066 + $canonical_url = \ThinkRank\SEO\Url_Scheme::apply($canonical_url);
2067 +
1756 2068 echo "<!-- ThinkRank SEO Canonical URL -->\n";
1757 2069 echo "<link rel=\"canonical\" href=\"" . esc_url($canonical_url) . "\" />\n";
1758 2070 echo "<!-- /ThinkRank SEO Canonical URL -->\n";
1759 2071
@@ -1800,9 +2112,9 @@
1800 2112
1801 2113 if ($current > 1) {
1802 2114 printf(
1803 2115 "<link rel=\"prev\" href=\"%s\" />\n",
1804 - esc_url(self::with_pagination($base, $current - 1))
2116 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current - 1)))
1805 2117 );
1806 2118 }
1807 2119
1808 2120 if ($current < $total) {
@@ -1807,9 +2119,9 @@
1807 2119
1808 2120 if ($current < $total) {
1809 2121 printf(
1810 2122 "<link rel=\"next\" href=\"%s\" />\n",
1811 - esc_url(self::with_pagination($base, $current + 1))
2123 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current + 1)))
1812 2124 );
1813 2125 }
1814 2126 }
1815 2127
@@ -2309,9 +2621,9 @@
2309 2621 // Stripped: get_the_archive_title() wraps its subject in a
2310 2622 // <span>, and this placeholder feeds the document <title> as
2311 2623 // well as og:title and twitter:title — a date archive rendered
2312 2624 // as "Month: <span>August 2026</span> | Site".
2313 - $placeholders['%archive_title%'] = wp_strip_all_tags((string) get_the_archive_title());
2625 + $placeholders['%archive_title%'] = self::archive_subject();
2314 2626 break;
2315 2627
2316 2628 case 'homepage':
2317 2629 // The page template resolved for a static posts page needs the
@@ -2506,11 +2818,13 @@
2506 2818 if ($description === '') {
2507 2819 return null;
2508 2820 }
2509 2821
2510 - if (strlen($description) > 160) {
2511 - $description = wp_trim_words($description, 25, '...');
2512 - }
2822 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2823 + // CJK description tripped this limit at a third of its length, and
2824 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2825 + // English, 25 characters in Thai (#687).
2826 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2513 2827
2514 2828 return $description;
2515 2829 }
2516 2830
@@ -2557,11 +2871,13 @@
2557 2871 $description = preg_replace('/\s+/', ' ', $description);
2558 2872 $description = trim($description);
2559 2873
2560 2874 // Ensure description doesn't exceed recommended length (160 characters)
2561 - if (strlen($description) > 160) {
2562 - $description = wp_trim_words($description, 25, '...');
2563 - }
2875 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2876 + // CJK description tripped this limit at a third of its length, and
2877 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2878 + // English, 25 characters in Thai (#687).
2879 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2564 2880
2565 2881 return $description;
2566 2882 }
2567 2883
@@ -2628,9 +2944,19 @@
2628 2944
2629 2945 $page_specific_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
2630 2946
2631 2947 if (!empty($page_specific_schemas)) {
2632 - // Apply filter for Pro to allow multiple schemas
2948 + // Every deployed schema is rendered, on every plan. How many a
2949 + // page carries is decided when schemas are activated in the
2950 + // editor, not trimmed here by plan (#673).
2951 +
2952 + /**
2953 + * Filter the page-specific schemas rendered on the current page.
2954 + *
2955 + * @param array $page_specific_schemas Deployed schemas keyed by schema type.
2956 + * @param string $context_type Context type (post, page, product, site).
2957 + * @param int $context_id Post ID.
2958 + */
2633 2959 $page_specific_schemas = apply_filters(
2634 2960 'thinkrank_page_schemas_to_render',
2635 2961 $page_specific_schemas,
2636 2962 $context_type,
@@ -2636,29 +2962,22 @@
2636 2962 $context_type,
2637 2963 $context_id
2638 2964 );
2639 2965
2640 - // Free tier renders at most self::FREE_PAGE_SCHEMA_LIMIT
2641 - // page-specific schemas; Pro renders all of them.
2642 - //
2643 - // Both comments here used to say the free limit was 1 while the
2644 - // code allowed 2 (#405). The number the code enforces is what
2645 - // has shipped, so that is what stands — lowering it would take
2646 - // a schema away from every free site on upgrade — and it now
2647 - // lives in one named place instead of twice in prose and twice
2648 - // in a literal.
2649 - if (!\ThinkRank\Core\Plan_Config::is_pro()
2650 - && count($page_specific_schemas) > self::FREE_PAGE_SCHEMA_LIMIT) {
2651 - $page_specific_schemas = array_slice(
2652 - $page_specific_schemas,
2653 - 0,
2654 - self::FREE_PAGE_SCHEMA_LIMIT,
2655 - true
2656 - );
2657 - }
2966 + // A deployed node is a snapshot from Deploy time and outranks
2967 + // the automatic node, so page and article types would publish
2968 + // a frozen excerpt instead of the description the head
2969 + // resolves. Give them the live one, as the automatic node has.
2970 + $context_post = get_post($context_id);
2658 2971
2659 2972 foreach ($page_specific_schemas as $schema_type => $schema_info) {
2660 - Schema_Graph::instance()->add_primary($schema_info['data'], (string) $schema_type, 'schema_manager');
2973 + $node = $schema_info['data'];
2974 +
2975 + if ($this->global_seo_schema && $context_post instanceof \WP_Post) {
2976 + $node = $this->global_seo_schema->refresh_deployed_description($node, (string) $schema_type, $context_post);
2977 + }
2978 +
2979 + Schema_Graph::instance()->add_primary($node, (string) $schema_type, 'schema_manager');
2661 2980 }
2662 2981 $has_schema_manager_output = true;
2663 2982 }
2664 2983 }
@@ -2716,21 +3035,49 @@
2716 3035 'url' => home_url('/'),
2717 3036 ];
2718 3037
2719 3038 $description = !empty($settings['site_description']) ? $settings['site_description'] : get_bloginfo('description');
3039 + // The tagline is stored esc_html()'d by sanitize_option(), so a site
3040 + // called "Fish & Chips" published `&amp;` literally in its WebSite
3041 + // node; nothing decodes JSON-LD downstream.
3042 + $description = \ThinkRank\Core\Seo_Text::normalize_schema_text((string) $description);
2720 3043 if (!empty($description)) {
2721 3044 $schema['description'] = $description;
2722 3045 }
2723 3046
2724 - $schema['potentialAction'] = [
2725 - '@type' => 'SearchAction',
2726 - 'target' => [
2727 - '@type' => 'EntryPoint',
2728 - 'urlTemplate' => home_url('/?s={search_term_string}'),
2729 - ],
2730 - 'query-input' => 'required name=search_term_string',
2731 - ];
3047 + // Site Identity has accepted an alternate name since the setup wizard
3048 + // shipped, and the MCP ability describes it as "published as schema
3049 + // alternateName" — but no producer ever read it, so the promise was
3050 + // false and every imported Yoast/Rank Math value sat unused (#692).
3051 + $alternate_name = \ThinkRank\SEO\Site_Identity_Manager::alternate_name_for_schema($settings['alternate_name'] ?? null);
3052 + if (null !== $alternate_name) {
3053 + $schema['alternateName'] = $alternate_name;
3054 + }
2732 3055
3056 + // The sitelinks searchbox switch was honoured only for a deployed
3057 + // WebSite row; this live fallback added potentialAction unconditionally,
3058 + // so website_enable_search = 0 still shipped the SearchAction (#688).
3059 + // Absent means not configured, which stays enabled.
3060 + $search_enabled = true;
3061 + if ($this->schema_manager) {
3062 + $schema_settings = $this->schema_manager->get_settings('site', null);
3063 +
3064 + if (array_key_exists('website_enable_search', $schema_settings)) {
3065 + $search_enabled = !empty($schema_settings['website_enable_search']);
3066 + }
3067 + }
3068 +
3069 + if ($search_enabled) {
3070 + $schema['potentialAction'] = [
3071 + '@type' => 'SearchAction',
3072 + 'target' => [
3073 + '@type' => 'EntryPoint',
3074 + 'urlTemplate' => home_url('/?s={search_term_string}'),
3075 + ],
3076 + 'query-input' => 'required name=search_term_string',
3077 + ];
3078 + }
3079 +
2733 3080 return $schema;
2734 3081 }
2735 3082
2736 3083 /**
@@ -2941,8 +3288,22 @@
2941 3288 if (empty($settings['breadcrumbs_enabled'])) {
2942 3289 return;
2943 3290 }
2944 3291
3292 + // Schema Manager's own breadcrumb switch. Only Site Identity's
3293 + // breadcrumbs_enabled was consulted here, so enable_breadcrumbs_schema
3294 + // = 0 removed a deployed BreadcrumbList row and left this live one
3295 + // emitting the node anyway (#688). Absent means not configured, which
3296 + // stays enabled.
3297 + if ($this->schema_manager) {
3298 + $schema_settings = $this->schema_manager->get_settings('site', null);
3299 +
3300 + if (array_key_exists('enable_breadcrumbs_schema', $schema_settings)
3301 + && empty($schema_settings['enable_breadcrumbs_schema'])) {
3302 + return;
3303 + }
3304 + }
3305 +
2945 3306 $breadcrumbs = $this->generate_breadcrumbs($settings);
2946 3307
2947 3308 if (!empty($breadcrumbs['schema'])) {
2948 3309 Schema_Graph::instance()->add_supporting($breadcrumbs['schema'], 'BreadcrumbList');
@@ -3197,8 +3558,102 @@
3197 3558 * @since 1.32.0
3198 3559 *
3199 3560 * @return void
3200 3561 */
3562 + /**
3563 + * Serve a ThinkRank sitemap document for this request, when it is one.
3564 + *
3565 + * Only acts in dynamic delivery mode. In static mode a real file exists and
3566 + * the web server returns it without WordPress ever loading, so answering
3567 + * here as well would mean two sources for the same bytes.
3568 + *
3569 + * @since 2.9.0
3570 + *
3571 + * @return void
3572 + */
3573 + public function maybe_serve_sitemap(): void {
3574 + $filename = $this->requested_sitemap_filename();
3575 + if ('' === $filename) {
3576 + return;
3577 + }
3578 +
3579 + try {
3580 + // Read-only instance: passing false keeps it from registering a
3581 + // second copy of the auto-generation hooks.
3582 + $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3583 + $settings = $generator->get_settings('site');
3584 +
3585 + if (empty($settings['enabled'])) {
3586 + return;
3587 + }
3588 +
3589 + if ('dynamic' !== $generator->resolve_delivery_mode($settings)) {
3590 + return;
3591 + }
3592 +
3593 + if (!$generator->publishes_document_name($filename, $settings)) {
3594 + return;
3595 + }
3596 +
3597 + $xml = $generator->render_document($filename, $settings);
3598 + } catch (\Throwable $e) {
3599 + // A failed render must not replace the sitemap with a fatal. Leave
3600 + // the request alone so WordPress answers as it otherwise would.
3601 + return;
3602 + }
3603 +
3604 + if (!is_string($xml) || '' === trim($xml)) {
3605 + return;
3606 + }
3607 +
3608 + status_header(200);
3609 + header('Content-Type: application/xml; charset=UTF-8');
3610 + header('X-Robots-Tag: noindex, follow', true);
3611 +
3612 + // Built XML, escaped by the builders as they assemble it; escaping the
3613 + // document here would corrupt it.
3614 + echo $xml; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
3615 + exit;
3616 + }
3617 +
3618 + /**
3619 + * The sitemap file name this request is asking for, if it looks like one.
3620 + *
3621 + * Deliberately a cheap shape test. Whether the site actually publishes the
3622 + * name is settled by the caller against the generator, so that a request
3623 + * for someone else's sitemap is never answered here.
3624 + *
3625 + * @since 2.9.0
3626 + *
3627 + * @return string File name, or '' when this is not a sitemap request.
3628 + */
3629 + private function requested_sitemap_filename(): string {
3630 + if (empty($_SERVER['REQUEST_URI'])) {
3631 + return '';
3632 + }
3633 +
3634 + $path = wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), PHP_URL_PATH);
3635 + if (!is_string($path) || '' === $path) {
3636 + return '';
3637 + }
3638 +
3639 + // Strip the install's home path so subdirectory installs match too.
3640 + $home_path = (string) wp_parse_url(home_url('/'), PHP_URL_PATH);
3641 + if ('' !== $home_path && '/' !== $home_path && 0 === strpos($path, $home_path)) {
3642 + $path = substr($path, strlen($home_path));
3643 + }
3644 +
3645 + $candidate = strtolower(trim($path, '/'));
3646 +
3647 + // One path segment ending in .xml. Anything nested is not a file we
3648 + // publish to the web root.
3649 + if ('' === $candidate || strpos($candidate, '/') !== false) {
3650 + return '';
3651 + }
3652 +
3653 + return substr($candidate, -4) === '.xml' ? $candidate : '';
3654 + }
3655 +
3201 3656 public function maybe_serve_llms_txt(): void {
3202 3657 if (!$this->is_llms_txt_request()) {
3203 3658 return;
3204 3659 }
@@ -3399,11 +3854,22 @@
3399 3854 // second copy of the save_post/term auto-generation hooks.
3400 3855 $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3401 3856 $settings = $generator->get_settings('site');
3402 3857
3858 + // "Can ThinkRank actually answer its sitemap URL right now?" In
3859 + // static mode that means the file is on disk; in dynamic mode
3860 + // maybe_serve_sitemap() answers it, so there is nothing to look
3861 + // for. Keeping the file test as the only answer would have left
3862 + // core's sitemap in place on every dynamic site, which is the
3863 + // crawl conflict this suppression exists to prevent (#752).
3864 + // The #346 behaviour is unchanged: a static site with nothing
3865 + // published still falls through to core rather than 404ing.
3866 + $can_serve = 'dynamic' === $generator->resolve_delivery_mode($settings)
3867 + || $generator->primary_sitemap_file_exists($settings);
3868 +
3403 3869 $this->thinkrank_sitemap_enabled = !empty($settings['enabled'])
3404 3870 && !$this->publishes_at_core_sitemap_url($settings)
3405 - && $generator->primary_sitemap_file_exists($settings);
3871 + && $can_serve;
3406 3872
3407 3873 if ($this->thinkrank_sitemap_enabled) {
3408 3874 $this->thinkrank_sitemap_url = $generator->get_primary_sitemap_url($settings);
3409 3875 }