PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 All 50 releases
← All changes | includes/frontend/class-seo-manager.php +356 -73 2.5.0 → 2.9.0 View file →
@@ -38,16 +38,8 @@
38 38 * Current post metadata
39 39 *
40 40 * @var array
41 41 */
42 - /**
43 - * Page-specific schemas the free tier renders on one page.
44 - *
45 - * @since 2.0.1
46 - * @var int
47 - */
48 - private const FREE_PAGE_SCHEMA_LIMIT = 2;
49 -
50 42 private array $current_metadata = [];
51 43
52 44 /**
53 45 * Term ID of the archive being rendered, when the request is a term archive.
@@ -183,11 +175,8 @@
183 175
184 176 // Initialize Global SEO Schema Output
185 177 $this->initialize_global_seo_schema();
186 178
187 - // Initialize Google Analytics Tracking Manager
188 - $this->initialize_google_analytics_tracking();
189 -
190 179 // Initialize Image SEO Manager
191 180 $this->initialize_image_seo_manager();
192 181
193 182 // Initialize External Links Manager (rel=nofollow / target=_blank)
@@ -277,8 +266,16 @@
277 266 // LLMs_Txt_Manager for the static file) guarantees an explicit UTF-8
278 267 // charset. Priority 8 keeps it ahead of redirect_canonical().
279 268 add_action('template_redirect', [$this, 'maybe_serve_llms_txt'], 8);
280 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 +
281 278 // Take WordPress core's own sitemap offline while ThinkRank's is active.
282 279 // Two sitemap indexes on one site is a crawl conflict: core keeps
283 280 // /wp-sitemap.xml served and injects its own "Sitemap:" line into
284 281 // robots.txt (WP_Sitemaps::add_robots, priority 0). Until now that line
@@ -384,22 +381,8 @@
384 381 $this->global_seo_schema->init();
385 382 }
386 383
387 384 /**
388 - * Initialize Google Analytics Tracking Manager
389 - *
390 - * @return void
391 - */
392 - private function initialize_google_analytics_tracking(): void {
393 - if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
394 - require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-google-analytics-tracking-manager.php';
395 - }
396 -
397 - // Initialize Google Analytics Tracking Manager
398 - new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
399 - }
400 -
401 - /**
402 385 * Initialize Image SEO Manager
403 386 *
404 387 * @return void
405 388 */
@@ -716,9 +699,86 @@
716 699 *
717 700 * @param string $title Resolved title.
718 701 * @return string Title with the page indicator, when there is one.
719 702 */
703 + /**
704 + * The archive's subject, without the label WordPress prefixes it with.
705 + *
706 + * `get_the_archive_title()` returns "Month: September 2026", "Archives:
707 + * Recipes", "Category: Uncategorized" — the label is core's, aimed at an
708 + * archive heading on the page, and it reads badly in a browser tab, an
709 + * og:title or a search result. Category, tag and author contexts already
710 + * avoid it by using the raw name; the generic archive context did not, so
711 + * date, custom-post-type and custom-taxonomy archives carried it (#640).
712 + *
713 + * Removed through core's own `get_the_archive_title_prefix` filter rather
714 + * than by matching the prefix text, because that text is translated and
715 + * differs per archive type — a string comparison would work in English and
716 + * silently stop working everywhere else.
717 + *
718 + * A site that wants a prefix can put one in its title template, where it is
719 + * visible and editable, instead of inheriting one it cannot see.
720 + *
721 + * @since 2.7.0
722 + *
723 + * @return string Archive subject, with markup and the core prefix removed.
724 + */
725 + private static function archive_subject(): string {
726 + $drop_prefix = static function (): string {
727 + return '';
728 + };
729 +
730 + add_filter('get_the_archive_title_prefix', $drop_prefix, 99);
731 +
732 + $title = (string) get_the_archive_title();
733 +
734 + remove_filter('get_the_archive_title_prefix', $drop_prefix, 99);
735 +
736 + // The <span> core wraps the subject in survives the prefix filter.
737 + return trim(wp_strip_all_tags($title));
738 + }
739 +
740 + /**
741 + * Remove HTML from a title that is about to be emitted.
742 + *
743 + * A title carrying markup is broken twice over, in two different ways, and
744 + * both were reaching real pages: inside `<title>` the tags render literally,
745 + * because that element is RCDATA and never parses them; inside `og:title`
746 + * and `twitter:title` they are attribute-escaped, so the reader sees
747 + * `&lt;em&gt;` as visible text (#640).
748 + *
749 + * Applied at the point of emission rather than at each source, so it covers
750 + * every branch that can produce a title — post meta, Global SEO templates,
751 + * Site Identity templates — without each having to remember.
752 + *
753 + * Unconditional rather than a setting: there is no title for which markup is
754 + * the correct output. The filter is the escape hatch for anyone who
755 + * disagrees, and lets a site keep entities it deliberately encoded.
756 + *
757 + * @since 2.7.0
758 + *
759 + * @param string $title Title about to be emitted.
760 + * @return string Title with any markup removed.
761 + */
762 + public static function strip_title_tags(string $title): string {
763 + /**
764 + * Filter whether HTML is stripped from generated titles.
765 + *
766 + * @since 2.7.0
767 + *
768 + * @param bool $strip Whether to strip. Default true.
769 + * @param string $title The title being emitted.
770 + */
771 + if (!apply_filters('thinkrank_strip_title_tags', true, $title)) {
772 + return $title;
773 + }
774 +
775 + return trim(wp_strip_all_tags($title));
776 + }
777 +
720 778 public static function with_page_suffix(string $title): string {
779 + $title = self::strip_title_tags($title);
780 +
721 781 $page = self::current_page_number();
722 782
723 783 if ($page <= 1 || '' === $title) {
724 784 return $title;
@@ -809,11 +869,13 @@
809 869 // Output main ThinkRank SEO header comment (only once)
810 870 self::note_opening_comment();
811 871
812 872 // Ensure description is within optimal length (150-160 characters)
813 - if (strlen($description) > 160) {
814 - $description = wp_trim_words($description, 25, '...');
815 - }
873 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
874 + // CJK description tripped this limit at a third of its length, and
875 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
876 + // English, 25 characters in Thai (#687).
877 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
816 878
817 879 echo "<!-- ThinkRank SEO Meta Description -->\n";
818 880 echo '<meta name="description" content="' . esc_attr($description) . '" />' . "\n";
819 881 echo "<!-- /ThinkRank SEO Meta Description -->\n";
@@ -1325,9 +1387,9 @@
1325 1387 *
1326 1388 * @param array $og_tags Open Graph tags array
1327 1389 * @return void
1328 1390 */
1329 - private function output_social_og_tags(array $og_tags): void {
1391 + private function output_social_og_tags(array $og_tags, array $extra_images = []): void {
1330 1392 // Honor the thinkrank_og_type filter here too — this "Enhanced" path is
1331 1393 // the active OG emitter, so add-ons (e.g. Pro's WooCommerce module which
1332 1394 // sets 'product' on product pages) must be applied to it, not only to
1333 1395 // output_open_graph_tags().
@@ -1371,12 +1433,62 @@
1371 1433 echo '<meta property="' . esc_attr($property) . '" content="' . $this->esc_meta_value($property, $content) . '" />' . "\n";
1372 1434 }
1373 1435 }
1374 1436
1437 + // Alternatives, after the primary and everything belonging to it.
1438 + // Order is the whole point: a consumer reads og:image tags in document
1439 + // order and treats the first as primary, and a structured property
1440 + // attaches to the most recently declared image — so each alternative's
1441 + // companions have to follow its own URL, not be grouped at the end.
1442 + self::output_extra_og_images($extra_images);
1443 +
1375 1444 echo "<!-- /ThinkRank SEO Open Graph Tags -->\n";
1376 1445 }
1377 1446
1378 1447 /**
1448 + * Emit the secondary og:image tags a page offers.
1449 + *
1450 + * Shared by the enhanced and basic emitters so both describe an
1451 + * alternative image the same way (#636).
1452 + *
1453 + * @since 2.7.0
1454 + *
1455 + * @param array $images Each with url, and width/height/type/alt where known.
1456 + * @return void
1457 + */
1458 + private static function output_extra_og_images(array $images): void {
1459 + foreach ($images as $image) {
1460 + $url = isset($image['url']) ? (string) $image['url'] : '';
1461 +
1462 + if ('' === $url) {
1463 + continue;
1464 + }
1465 +
1466 + echo '<meta property="og:image" content="' . esc_url($url) . '" />' . "\n";
1467 +
1468 + if (strpos($url, 'https://') === 0) {
1469 + echo '<meta property="og:image:secure_url" content="' . esc_url($url) . '" />' . "\n";
1470 + }
1471 +
1472 + // Only what is actually known: a dimension guessed for a remote
1473 + // image is a number a consumer lays a card out with before it has
1474 + // fetched the file.
1475 + if (!empty($image['width']) && !empty($image['height'])) {
1476 + echo '<meta property="og:image:width" content="' . esc_attr((string) $image['width']) . '" />' . "\n";
1477 + echo '<meta property="og:image:height" content="' . esc_attr((string) $image['height']) . '" />' . "\n";
1478 + }
1479 +
1480 + if (!empty($image['type'])) {
1481 + echo '<meta property="og:image:type" content="' . esc_attr((string) $image['type']) . '" />' . "\n";
1482 + }
1483 +
1484 + if (!empty($image['alt'])) {
1485 + echo '<meta property="og:image:alt" content="' . esc_attr((string) $image['alt']) . '" />' . "\n";
1486 + }
1487 + }
1488 + }
1489 +
1490 + /**
1379 1491 * Output social media Twitter Card tags from Social Meta Manager
1380 1492 *
1381 1493 * @param array $twitter_tags Twitter Card tags array
1382 1494 * @return void
@@ -1539,9 +1651,12 @@
1539 1651 // The Social Meta Manager ran, so it owns Open Graph output. If OG is
1540 1652 // toggled off, emit nothing — do NOT fall through to the basic
1541 1653 // emitter (which would re-add a full OG block despite the toggle).
1542 1654 if (!empty($social_data['og_enabled'])) {
1543 - $this->output_social_og_tags($social_data['og_tags']);
1655 + $this->output_social_og_tags(
1656 + $social_data['og_tags'],
1657 + $social_data['og_extra_images'] ?? []
1658 + );
1544 1659 }
1545 1660 return;
1546 1661 }
1547 1662
@@ -1609,9 +1724,9 @@
1609 1724 // "There is no excerpt because this is a protected post." placeholder,
1610 1725 // so this is not a leak — but publishing that sentence as the social
1611 1726 // description is worse than publishing none (#363).
1612 1727 if (!$description && !$this->is_content_password_protected()) {
1613 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1728 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1614 1729 }
1615 1730
1616 1731 $url = is_singular() ? get_permalink() : home_url();
1617 1732 $site_name = $this->site_identity_data && !empty($this->site_identity_data['identity']['site_name'])
@@ -1639,11 +1754,11 @@
1639 1754 $og_type = apply_filters('thinkrank_og_type', $og_type);
1640 1755
1641 1756 echo "<!-- ThinkRank SEO Open Graph Meta Tags -->\n";
1642 1757 echo "<meta property=\"og:type\" content=\"" . esc_attr($og_type) . "\" />\n";
1643 - echo "<meta property=\"og:title\" content=\"" . esc_attr($title) . "\" />\n";
1758 + echo "<meta property=\"og:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1644 1759 echo "<meta property=\"og:description\" content=\"" . esc_attr($description) . "\" />\n";
1645 - echo "<meta property=\"og:url\" content=\"" . esc_url($url) . "\" />\n";
1760 + echo "<meta property=\"og:url\" content=\"" . esc_url(\ThinkRank\SEO\Url_Scheme::apply($url)) . "\" />\n";
1646 1761 echo "<meta property=\"og:site_name\" content=\"" . esc_attr($site_name) . "\" />\n";
1647 1762 /**
1648 1763 * Filter the og:locale value.
1649 1764 *
@@ -1660,14 +1775,17 @@
1660 1775 $og_locale = (string) apply_filters('thinkrank_og_locale', get_locale());
1661 1776 echo "<meta property=\"og:locale\" content=\"" . esc_attr($og_locale) . "\" />\n";
1662 1777
1663 1778 // Add OG image — per-post override > featured image
1779 + $primary_og_image = '';
1664 1780 if (is_singular() && $this->current_post_id) {
1665 1781 if (!empty($og_image_override)) {
1782 + $primary_og_image = (string) $og_image_override;
1666 1783 echo "<meta property=\"og:image\" content=\"" . esc_url($og_image_override) . "\" />\n";
1667 1784 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($og_image_override) . "\" />\n";
1668 1785 } elseif (has_post_thumbnail($this->current_post_id)) {
1669 1786 $image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
1787 + $primary_og_image = (string) $image_url;
1670 1788 echo "<meta property=\"og:image\" content=\"" . esc_url($image_url) . "\" />\n";
1671 1789 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($image_url) . "\" />\n";
1672 1790
1673 1791 // Get image dimensions and alt text
@@ -1696,8 +1814,30 @@
1696 1814 echo "<meta property=\"og:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n";
1697 1815 }
1698 1816 }
1699 1817
1818 + // Alternatives, same as the enhanced emitter above. This path only
1819 + // runs when the Social Meta Manager is unavailable, but the issue
1820 + // reported against it (#636) and a site that lands here should not
1821 + // silently lose a feature it switched on.
1822 + if (!empty($primary_og_image)) {
1823 + $social_settings = $this->social_manager
1824 + ? $this->social_manager->get_settings(
1825 + $this->current_context === 'homepage' ? 'site' : $this->current_context,
1826 + $this->current_post_id
1827 + )
1828 + : [];
1829 +
1830 + if (!empty($social_settings['og_multiple_images'])) {
1831 + self::output_extra_og_images(
1832 + \ThinkRank\SEO\Social_Images::additional(
1833 + (int) $this->current_post_id,
1834 + $primary_og_image
1835 + )
1836 + );
1837 + }
1838 + }
1839 +
1700 1840 // Add article specific tags for posts only
1701 1841 if ($og_type === 'article') {
1702 1842 echo '<meta property="article:published_time" content="' . esc_attr(get_the_date('c', $this->current_post_id)) . '" />' . "\n";
1703 1843 echo '<meta property="article:modified_time" content="' . esc_attr(get_the_modified_date('c', $this->current_post_id)) . '" />' . "\n";
@@ -1823,9 +1963,9 @@
1823 1963 // "There is no excerpt because this is a protected post." placeholder,
1824 1964 // so this is not a leak — but publishing that sentence as the social
1825 1965 // description is worse than publishing none (#363).
1826 1966 if (!$description && !$this->is_content_password_protected()) {
1827 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1967 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1828 1968 }
1829 1969
1830 1970 // Determine card type based on image availability
1831 1971 $card_type = 'summary';
@@ -1834,9 +1974,9 @@
1834 1974 }
1835 1975
1836 1976 echo "<!-- ThinkRank SEO Twitter Card Meta Tags -->\n";
1837 1977 echo '<meta name="twitter:card" content="' . esc_attr($card_type) . '" />' . "\n";
1838 - echo "<meta name=\"twitter:title\" content=\"" . esc_attr($title) . "\" />\n";
1978 + echo "<meta name=\"twitter:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1839 1979 echo "<meta name=\"twitter:description\" content=\"" . esc_attr($description) . "\" />\n";
1840 1980
1841 1981 // Add Twitter image with proper fallback priority
1842 1982 $twitter_image_url = $this->get_twitter_image_with_fallback();
@@ -1911,8 +2051,13 @@
1911 2051 if (empty($canonical_url)) {
1912 2052 return;
1913 2053 }
1914 2054
2055 + // After the filter, so a canonical an add-on supplied is normalized
2056 + // too — and a cross-domain one is left alone, since Url_Scheme only
2057 + // touches URLs on this site's own host.
2058 + $canonical_url = \ThinkRank\SEO\Url_Scheme::apply($canonical_url);
2059 +
1915 2060 echo "<!-- ThinkRank SEO Canonical URL -->\n";
1916 2061 echo "<link rel=\"canonical\" href=\"" . esc_url($canonical_url) . "\" />\n";
1917 2062 echo "<!-- /ThinkRank SEO Canonical URL -->\n";
1918 2063
@@ -1959,9 +2104,9 @@
1959 2104
1960 2105 if ($current > 1) {
1961 2106 printf(
1962 2107 "<link rel=\"prev\" href=\"%s\" />\n",
1963 - esc_url(self::with_pagination($base, $current - 1))
2108 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current - 1)))
1964 2109 );
1965 2110 }
1966 2111
1967 2112 if ($current < $total) {
@@ -1966,9 +2111,9 @@
1966 2111
1967 2112 if ($current < $total) {
1968 2113 printf(
1969 2114 "<link rel=\"next\" href=\"%s\" />\n",
1970 - esc_url(self::with_pagination($base, $current + 1))
2115 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current + 1)))
1971 2116 );
1972 2117 }
1973 2118 }
1974 2119
@@ -2468,9 +2613,9 @@
2468 2613 // Stripped: get_the_archive_title() wraps its subject in a
2469 2614 // <span>, and this placeholder feeds the document <title> as
2470 2615 // well as og:title and twitter:title — a date archive rendered
2471 2616 // as "Month: <span>August 2026</span> | Site".
2472 - $placeholders['%archive_title%'] = wp_strip_all_tags((string) get_the_archive_title());
2617 + $placeholders['%archive_title%'] = self::archive_subject();
2473 2618 break;
2474 2619
2475 2620 case 'homepage':
2476 2621 // The page template resolved for a static posts page needs the
@@ -2665,11 +2810,13 @@
2665 2810 if ($description === '') {
2666 2811 return null;
2667 2812 }
2668 2813
2669 - if (strlen($description) > 160) {
2670 - $description = wp_trim_words($description, 25, '...');
2671 - }
2814 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2815 + // CJK description tripped this limit at a third of its length, and
2816 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2817 + // English, 25 characters in Thai (#687).
2818 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2672 2819
2673 2820 return $description;
2674 2821 }
2675 2822
@@ -2716,11 +2863,13 @@
2716 2863 $description = preg_replace('/\s+/', ' ', $description);
2717 2864 $description = trim($description);
2718 2865
2719 2866 // Ensure description doesn't exceed recommended length (160 characters)
2720 - if (strlen($description) > 160) {
2721 - $description = wp_trim_words($description, 25, '...');
2722 - }
2867 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2868 + // CJK description tripped this limit at a third of its length, and
2869 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2870 + // English, 25 characters in Thai (#687).
2871 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2723 2872
2724 2873 return $description;
2725 2874 }
2726 2875
@@ -2787,9 +2936,19 @@
2787 2936
2788 2937 $page_specific_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
2789 2938
2790 2939 if (!empty($page_specific_schemas)) {
2791 - // Apply filter for Pro to allow multiple schemas
2940 + // Every deployed schema is rendered, on every plan. How many a
2941 + // page carries is decided when schemas are activated in the
2942 + // editor, not trimmed here by plan (#673).
2943 +
2944 + /**
2945 + * Filter the page-specific schemas rendered on the current page.
2946 + *
2947 + * @param array $page_specific_schemas Deployed schemas keyed by schema type.
2948 + * @param string $context_type Context type (post, page, product, site).
2949 + * @param int $context_id Post ID.
2950 + */
2792 2951 $page_specific_schemas = apply_filters(
2793 2952 'thinkrank_page_schemas_to_render',
2794 2953 $page_specific_schemas,
2795 2954 $context_type,
@@ -2795,27 +2954,8 @@
2795 2954 $context_type,
2796 2955 $context_id
2797 2956 );
2798 2957
2799 - // Free tier renders at most self::FREE_PAGE_SCHEMA_LIMIT
2800 - // page-specific schemas; Pro renders all of them.
2801 - //
2802 - // Both comments here used to say the free limit was 1 while the
2803 - // code allowed 2 (#405). The number the code enforces is what
2804 - // has shipped, so that is what stands — lowering it would take
2805 - // a schema away from every free site on upgrade — and it now
2806 - // lives in one named place instead of twice in prose and twice
2807 - // in a literal.
2808 - if (!\ThinkRank\Core\Plan_Config::is_pro()
2809 - && count($page_specific_schemas) > self::FREE_PAGE_SCHEMA_LIMIT) {
2810 - $page_specific_schemas = array_slice(
2811 - $page_specific_schemas,
2812 - 0,
2813 - self::FREE_PAGE_SCHEMA_LIMIT,
2814 - true
2815 - );
2816 - }
2817 -
2818 2958 foreach ($page_specific_schemas as $schema_type => $schema_info) {
2819 2959 Schema_Graph::instance()->add_primary($schema_info['data'], (string) $schema_type, 'schema_manager');
2820 2960 }
2821 2961 $has_schema_manager_output = true;
@@ -2879,17 +3019,41 @@
2879 3019 if (!empty($description)) {
2880 3020 $schema['description'] = $description;
2881 3021 }
2882 3022
2883 - $schema['potentialAction'] = [
2884 - '@type' => 'SearchAction',
2885 - 'target' => [
2886 - '@type' => 'EntryPoint',
2887 - 'urlTemplate' => home_url('/?s={search_term_string}'),
2888 - ],
2889 - 'query-input' => 'required name=search_term_string',
2890 - ];
3023 + // Site Identity has accepted an alternate name since the setup wizard
3024 + // shipped, and the MCP ability describes it as "published as schema
3025 + // alternateName" — but no producer ever read it, so the promise was
3026 + // false and every imported Yoast/Rank Math value sat unused (#692).
3027 + $alternate_name = \ThinkRank\SEO\Site_Identity_Manager::alternate_name_for_schema($settings['alternate_name'] ?? null);
3028 + if (null !== $alternate_name) {
3029 + $schema['alternateName'] = $alternate_name;
3030 + }
2891 3031
3032 + // The sitelinks searchbox switch was honoured only for a deployed
3033 + // WebSite row; this live fallback added potentialAction unconditionally,
3034 + // so website_enable_search = 0 still shipped the SearchAction (#688).
3035 + // Absent means not configured, which stays enabled.
3036 + $search_enabled = true;
3037 + if ($this->schema_manager) {
3038 + $schema_settings = $this->schema_manager->get_settings('site', null);
3039 +
3040 + if (array_key_exists('website_enable_search', $schema_settings)) {
3041 + $search_enabled = !empty($schema_settings['website_enable_search']);
3042 + }
3043 + }
3044 +
3045 + if ($search_enabled) {
3046 + $schema['potentialAction'] = [
3047 + '@type' => 'SearchAction',
3048 + 'target' => [
3049 + '@type' => 'EntryPoint',
3050 + 'urlTemplate' => home_url('/?s={search_term_string}'),
3051 + ],
3052 + 'query-input' => 'required name=search_term_string',
3053 + ];
3054 + }
3055 +
2892 3056 return $schema;
2893 3057 }
2894 3058
2895 3059 /**
@@ -3100,8 +3264,22 @@
3100 3264 if (empty($settings['breadcrumbs_enabled'])) {
3101 3265 return;
3102 3266 }
3103 3267
3268 + // Schema Manager's own breadcrumb switch. Only Site Identity's
3269 + // breadcrumbs_enabled was consulted here, so enable_breadcrumbs_schema
3270 + // = 0 removed a deployed BreadcrumbList row and left this live one
3271 + // emitting the node anyway (#688). Absent means not configured, which
3272 + // stays enabled.
3273 + if ($this->schema_manager) {
3274 + $schema_settings = $this->schema_manager->get_settings('site', null);
3275 +
3276 + if (array_key_exists('enable_breadcrumbs_schema', $schema_settings)
3277 + && empty($schema_settings['enable_breadcrumbs_schema'])) {
3278 + return;
3279 + }
3280 + }
3281 +
3104 3282 $breadcrumbs = $this->generate_breadcrumbs($settings);
3105 3283
3106 3284 if (!empty($breadcrumbs['schema'])) {
3107 3285 Schema_Graph::instance()->add_supporting($breadcrumbs['schema'], 'BreadcrumbList');
@@ -3356,8 +3534,102 @@
3356 3534 * @since 1.32.0
3357 3535 *
3358 3536 * @return void
3359 3537 */
3538 + /**
3539 + * Serve a ThinkRank sitemap document for this request, when it is one.
3540 + *
3541 + * Only acts in dynamic delivery mode. In static mode a real file exists and
3542 + * the web server returns it without WordPress ever loading, so answering
3543 + * here as well would mean two sources for the same bytes.
3544 + *
3545 + * @since 2.9.0
3546 + *
3547 + * @return void
3548 + */
3549 + public function maybe_serve_sitemap(): void {
3550 + $filename = $this->requested_sitemap_filename();
3551 + if ('' === $filename) {
3552 + return;
3553 + }
3554 +
3555 + try {
3556 + // Read-only instance: passing false keeps it from registering a
3557 + // second copy of the auto-generation hooks.
3558 + $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3559 + $settings = $generator->get_settings('site');
3560 +
3561 + if (empty($settings['enabled'])) {
3562 + return;
3563 + }
3564 +
3565 + if ('dynamic' !== $generator->resolve_delivery_mode($settings)) {
3566 + return;
3567 + }
3568 +
3569 + if (!$generator->publishes_document_name($filename, $settings)) {
3570 + return;
3571 + }
3572 +
3573 + $xml = $generator->render_document($filename, $settings);
3574 + } catch (\Throwable $e) {
3575 + // A failed render must not replace the sitemap with a fatal. Leave
3576 + // the request alone so WordPress answers as it otherwise would.
3577 + return;
3578 + }
3579 +
3580 + if (!is_string($xml) || '' === trim($xml)) {
3581 + return;
3582 + }
3583 +
3584 + status_header(200);
3585 + header('Content-Type: application/xml; charset=UTF-8');
3586 + header('X-Robots-Tag: noindex, follow', true);
3587 +
3588 + // Built XML, escaped by the builders as they assemble it; escaping the
3589 + // document here would corrupt it.
3590 + echo $xml; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
3591 + exit;
3592 + }
3593 +
3594 + /**
3595 + * The sitemap file name this request is asking for, if it looks like one.
3596 + *
3597 + * Deliberately a cheap shape test. Whether the site actually publishes the
3598 + * name is settled by the caller against the generator, so that a request
3599 + * for someone else's sitemap is never answered here.
3600 + *
3601 + * @since 2.9.0
3602 + *
3603 + * @return string File name, or '' when this is not a sitemap request.
3604 + */
3605 + private function requested_sitemap_filename(): string {
3606 + if (empty($_SERVER['REQUEST_URI'])) {
3607 + return '';
3608 + }
3609 +
3610 + $path = wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), PHP_URL_PATH);
3611 + if (!is_string($path) || '' === $path) {
3612 + return '';
3613 + }
3614 +
3615 + // Strip the install's home path so subdirectory installs match too.
3616 + $home_path = (string) wp_parse_url(home_url('/'), PHP_URL_PATH);
3617 + if ('' !== $home_path && '/' !== $home_path && 0 === strpos($path, $home_path)) {
3618 + $path = substr($path, strlen($home_path));
3619 + }
3620 +
3621 + $candidate = strtolower(trim($path, '/'));
3622 +
3623 + // One path segment ending in .xml. Anything nested is not a file we
3624 + // publish to the web root.
3625 + if ('' === $candidate || strpos($candidate, '/') !== false) {
3626 + return '';
3627 + }
3628 +
3629 + return substr($candidate, -4) === '.xml' ? $candidate : '';
3630 + }
3631 +
3360 3632 public function maybe_serve_llms_txt(): void {
3361 3633 if (!$this->is_llms_txt_request()) {
3362 3634 return;
3363 3635 }
@@ -3558,11 +3830,22 @@
3558 3830 // second copy of the save_post/term auto-generation hooks.
3559 3831 $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3560 3832 $settings = $generator->get_settings('site');
3561 3833
3834 + // "Can ThinkRank actually answer its sitemap URL right now?" In
3835 + // static mode that means the file is on disk; in dynamic mode
3836 + // maybe_serve_sitemap() answers it, so there is nothing to look
3837 + // for. Keeping the file test as the only answer would have left
3838 + // core's sitemap in place on every dynamic site, which is the
3839 + // crawl conflict this suppression exists to prevent (#752).
3840 + // The #346 behaviour is unchanged: a static site with nothing
3841 + // published still falls through to core rather than 404ing.
3842 + $can_serve = 'dynamic' === $generator->resolve_delivery_mode($settings)
3843 + || $generator->primary_sitemap_file_exists($settings);
3844 +
3562 3845 $this->thinkrank_sitemap_enabled = !empty($settings['enabled'])
3563 3846 && !$this->publishes_at_core_sitemap_url($settings)
3564 - && $generator->primary_sitemap_file_exists($settings);
3847 + && $can_serve;
3565 3848
3566 3849 if ($this->thinkrank_sitemap_enabled) {
3567 3850 $this->thinkrank_sitemap_url = $generator->get_primary_sitemap_url($settings);
3568 3851 }