PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.12.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.12.0
2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk All 53 releases
← All changes | includes/frontend/class-seo-manager.php +377 -32 2.6.0 → 2.12.0 View file →
@@ -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
@@ -369,8 +377,16 @@
369 377 }
370 378
371 379 // Initialize Global SEO Schema Output and store reference
372 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 + );
373 389 $this->global_seo_schema->init();
374 390 }
375 391
376 392 /**
@@ -691,9 +707,86 @@
691 707 *
692 708 * @param string $title Resolved title.
693 709 * @return string Title with the page indicator, when there is one.
694 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 +
695 786 public static function with_page_suffix(string $title): string {
787 + $title = self::strip_title_tags($title);
788 +
696 789 $page = self::current_page_number();
697 790
698 791 if ($page <= 1 || '' === $title) {
699 792 return $title;
@@ -784,11 +877,13 @@
784 877 // Output main ThinkRank SEO header comment (only once)
785 878 self::note_opening_comment();
786 879
787 880 // Ensure description is within optimal length (150-160 characters)
788 - if (strlen($description) > 160) {
789 - $description = wp_trim_words($description, 25, '...');
790 - }
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);
791 886
792 887 echo "<!-- ThinkRank SEO Meta Description -->\n";
793 888 echo '<meta name="description" content="' . esc_attr($description) . '" />' . "\n";
794 889 echo "<!-- /ThinkRank SEO Meta Description -->\n";
@@ -1300,9 +1395,9 @@
1300 1395 *
1301 1396 * @param array $og_tags Open Graph tags array
1302 1397 * @return void
1303 1398 */
1304 - private function output_social_og_tags(array $og_tags): void {
1399 + private function output_social_og_tags(array $og_tags, array $extra_images = []): void {
1305 1400 // Honor the thinkrank_og_type filter here too — this "Enhanced" path is
1306 1401 // the active OG emitter, so add-ons (e.g. Pro's WooCommerce module which
1307 1402 // sets 'product' on product pages) must be applied to it, not only to
1308 1403 // output_open_graph_tags().
@@ -1346,12 +1441,62 @@
1346 1441 echo '<meta property="' . esc_attr($property) . '" content="' . $this->esc_meta_value($property, $content) . '" />' . "\n";
1347 1442 }
1348 1443 }
1349 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 +
1350 1452 echo "<!-- /ThinkRank SEO Open Graph Tags -->\n";
1351 1453 }
1352 1454
1353 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 + /**
1354 1499 * Output social media Twitter Card tags from Social Meta Manager
1355 1500 *
1356 1501 * @param array $twitter_tags Twitter Card tags array
1357 1502 * @return void
@@ -1514,9 +1659,12 @@
1514 1659 // The Social Meta Manager ran, so it owns Open Graph output. If OG is
1515 1660 // toggled off, emit nothing — do NOT fall through to the basic
1516 1661 // emitter (which would re-add a full OG block despite the toggle).
1517 1662 if (!empty($social_data['og_enabled'])) {
1518 - $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 + );
1519 1667 }
1520 1668 return;
1521 1669 }
1522 1670
@@ -1584,9 +1732,9 @@
1584 1732 // "There is no excerpt because this is a protected post." placeholder,
1585 1733 // so this is not a leak — but publishing that sentence as the social
1586 1734 // description is worse than publishing none (#363).
1587 1735 if (!$description && !$this->is_content_password_protected()) {
1588 - $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');
1589 1737 }
1590 1738
1591 1739 $url = is_singular() ? get_permalink() : home_url();
1592 1740 $site_name = $this->site_identity_data && !empty($this->site_identity_data['identity']['site_name'])
@@ -1614,11 +1762,11 @@
1614 1762 $og_type = apply_filters('thinkrank_og_type', $og_type);
1615 1763
1616 1764 echo "<!-- ThinkRank SEO Open Graph Meta Tags -->\n";
1617 1765 echo "<meta property=\"og:type\" content=\"" . esc_attr($og_type) . "\" />\n";
1618 - 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";
1619 1767 echo "<meta property=\"og:description\" content=\"" . esc_attr($description) . "\" />\n";
1620 - 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";
1621 1769 echo "<meta property=\"og:site_name\" content=\"" . esc_attr($site_name) . "\" />\n";
1622 1770 /**
1623 1771 * Filter the og:locale value.
1624 1772 *
@@ -1635,14 +1783,17 @@
1635 1783 $og_locale = (string) apply_filters('thinkrank_og_locale', get_locale());
1636 1784 echo "<meta property=\"og:locale\" content=\"" . esc_attr($og_locale) . "\" />\n";
1637 1785
1638 1786 // Add OG image — per-post override > featured image
1787 + $primary_og_image = '';
1639 1788 if (is_singular() && $this->current_post_id) {
1640 1789 if (!empty($og_image_override)) {
1790 + $primary_og_image = (string) $og_image_override;
1641 1791 echo "<meta property=\"og:image\" content=\"" . esc_url($og_image_override) . "\" />\n";
1642 1792 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($og_image_override) . "\" />\n";
1643 1793 } elseif (has_post_thumbnail($this->current_post_id)) {
1644 1794 $image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
1795 + $primary_og_image = (string) $image_url;
1645 1796 echo "<meta property=\"og:image\" content=\"" . esc_url($image_url) . "\" />\n";
1646 1797 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($image_url) . "\" />\n";
1647 1798
1648 1799 // Get image dimensions and alt text
@@ -1648,12 +1799,16 @@
1648 1799 // Get image dimensions and alt text
1649 1800 $image_id = get_post_thumbnail_id($this->current_post_id);
1650 1801 $image_meta = wp_get_attachment_metadata($image_id);
1651 1802 if ($image_meta) {
1803 + // The `large` file being published, not the original it
1804 + // was generated from: the metadata's own width and height
1805 + // describe an image this tag does not point at (#847).
1806 + $image_file = \ThinkRank\SEO\Attachment_Lookup::describe((int) $image_id, (string) $image_url);
1652 1807 // SVGs (and other vector uploads) report 0x0 — emitting
1653 1808 // those as og:image dimensions is invalid, so skip them.
1654 - $og_width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0;
1655 - $og_height = isset($image_meta['height']) ? (int) $image_meta['height'] : 0;
1809 + $og_width = $image_file['width'];
1810 + $og_height = $image_file['height'];
1656 1811 if ($og_width > 0 && $og_height > 0) {
1657 1812 echo "<meta property=\"og:image:width\" content=\"" . esc_attr($og_width) . "\" />\n";
1658 1813 echo "<meta property=\"og:image:height\" content=\"" . esc_attr($og_height) . "\" />\n";
1659 1814 }
@@ -1658,9 +1813,9 @@
1658 1813 echo "<meta property=\"og:image:height\" content=\"" . esc_attr($og_height) . "\" />\n";
1659 1814 }
1660 1815 // Derive the real mime type instead of hardcoding image/jpeg,
1661 1816 // which mislabels PNG/WebP featured images.
1662 - $image_mime = get_post_mime_type($image_id);
1817 + $image_mime = $image_file['type'];
1663 1818 if ($image_mime) {
1664 1819 echo "<meta property=\"og:image:type\" content=\"" . esc_attr($image_mime) . "\" />\n";
1665 1820 }
1666 1821 }
@@ -1671,8 +1826,30 @@
1671 1826 echo "<meta property=\"og:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n";
1672 1827 }
1673 1828 }
1674 1829
1830 + // Alternatives, same as the enhanced emitter above. This path only
1831 + // runs when the Social Meta Manager is unavailable, but the issue
1832 + // reported against it (#636) and a site that lands here should not
1833 + // silently lose a feature it switched on.
1834 + if (!empty($primary_og_image)) {
1835 + $social_settings = $this->social_manager
1836 + ? $this->social_manager->get_settings(
1837 + $this->current_context === 'homepage' ? 'site' : $this->current_context,
1838 + $this->current_post_id
1839 + )
1840 + : [];
1841 +
1842 + if (!empty($social_settings['og_multiple_images'])) {
1843 + self::output_extra_og_images(
1844 + \ThinkRank\SEO\Social_Images::additional(
1845 + (int) $this->current_post_id,
1846 + $primary_og_image
1847 + )
1848 + );
1849 + }
1850 + }
1851 +
1675 1852 // Add article specific tags for posts only
1676 1853 if ($og_type === 'article') {
1677 1854 echo '<meta property="article:published_time" content="' . esc_attr(get_the_date('c', $this->current_post_id)) . '" />' . "\n";
1678 1855 echo '<meta property="article:modified_time" content="' . esc_attr(get_the_modified_date('c', $this->current_post_id)) . '" />' . "\n";
@@ -1798,9 +1975,9 @@
1798 1975 // "There is no excerpt because this is a protected post." placeholder,
1799 1976 // so this is not a leak — but publishing that sentence as the social
1800 1977 // description is worse than publishing none (#363).
1801 1978 if (!$description && !$this->is_content_password_protected()) {
1802 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1979 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1803 1980 }
1804 1981
1805 1982 // Determine card type based on image availability
1806 1983 $card_type = 'summary';
@@ -1809,9 +1986,9 @@
1809 1986 }
1810 1987
1811 1988 echo "<!-- ThinkRank SEO Twitter Card Meta Tags -->\n";
1812 1989 echo '<meta name="twitter:card" content="' . esc_attr($card_type) . '" />' . "\n";
1813 - echo "<meta name=\"twitter:title\" content=\"" . esc_attr($title) . "\" />\n";
1990 + echo "<meta name=\"twitter:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1814 1991 echo "<meta name=\"twitter:description\" content=\"" . esc_attr($description) . "\" />\n";
1815 1992
1816 1993 // Add Twitter image with proper fallback priority
1817 1994 $twitter_image_url = $this->get_twitter_image_with_fallback();
@@ -1886,8 +2063,13 @@
1886 2063 if (empty($canonical_url)) {
1887 2064 return;
1888 2065 }
1889 2066
2067 + // After the filter, so a canonical an add-on supplied is normalized
2068 + // too — and a cross-domain one is left alone, since Url_Scheme only
2069 + // touches URLs on this site's own host.
2070 + $canonical_url = \ThinkRank\SEO\Url_Scheme::apply($canonical_url);
2071 +
1890 2072 echo "<!-- ThinkRank SEO Canonical URL -->\n";
1891 2073 echo "<link rel=\"canonical\" href=\"" . esc_url($canonical_url) . "\" />\n";
1892 2074 echo "<!-- /ThinkRank SEO Canonical URL -->\n";
1893 2075
@@ -1934,9 +2116,9 @@
1934 2116
1935 2117 if ($current > 1) {
1936 2118 printf(
1937 2119 "<link rel=\"prev\" href=\"%s\" />\n",
1938 - esc_url(self::with_pagination($base, $current - 1))
2120 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current - 1)))
1939 2121 );
1940 2122 }
1941 2123
1942 2124 if ($current < $total) {
@@ -1941,9 +2123,9 @@
1941 2123
1942 2124 if ($current < $total) {
1943 2125 printf(
1944 2126 "<link rel=\"next\" href=\"%s\" />\n",
1945 - esc_url(self::with_pagination($base, $current + 1))
2127 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current + 1)))
1946 2128 );
1947 2129 }
1948 2130 }
1949 2131
@@ -2443,9 +2625,9 @@
2443 2625 // Stripped: get_the_archive_title() wraps its subject in a
2444 2626 // <span>, and this placeholder feeds the document <title> as
2445 2627 // well as og:title and twitter:title — a date archive rendered
2446 2628 // as "Month: <span>August 2026</span> | Site".
2447 - $placeholders['%archive_title%'] = wp_strip_all_tags((string) get_the_archive_title());
2629 + $placeholders['%archive_title%'] = self::archive_subject();
2448 2630 break;
2449 2631
2450 2632 case 'homepage':
2451 2633 // The page template resolved for a static posts page needs the
@@ -2640,11 +2822,13 @@
2640 2822 if ($description === '') {
2641 2823 return null;
2642 2824 }
2643 2825
2644 - if (strlen($description) > 160) {
2645 - $description = wp_trim_words($description, 25, '...');
2646 - }
2826 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2827 + // CJK description tripped this limit at a third of its length, and
2828 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2829 + // English, 25 characters in Thai (#687).
2830 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2647 2831
2648 2832 return $description;
2649 2833 }
2650 2834
@@ -2691,11 +2875,13 @@
2691 2875 $description = preg_replace('/\s+/', ' ', $description);
2692 2876 $description = trim($description);
2693 2877
2694 2878 // Ensure description doesn't exceed recommended length (160 characters)
2695 - if (strlen($description) > 160) {
2696 - $description = wp_trim_words($description, 25, '...');
2697 - }
2879 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2880 + // CJK description tripped this limit at a third of its length, and
2881 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2882 + // English, 25 characters in Thai (#687).
2883 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2698 2884
2699 2885 return $description;
2700 2886 }
2701 2887
@@ -2780,10 +2966,22 @@
2780 2966 $context_type,
2781 2967 $context_id
2782 2968 );
2783 2969
2970 + // A deployed node is a snapshot from Deploy time and outranks
2971 + // the automatic node, so page and article types would publish
2972 + // a frozen excerpt instead of the description the head
2973 + // resolves. Give them the live one, as the automatic node has.
2974 + $context_post = get_post($context_id);
2975 +
2784 2976 foreach ($page_specific_schemas as $schema_type => $schema_info) {
2785 - Schema_Graph::instance()->add_primary($schema_info['data'], (string) $schema_type, 'schema_manager');
2977 + $node = $schema_info['data'];
2978 +
2979 + if ($this->global_seo_schema && $context_post instanceof \WP_Post) {
2980 + $node = $this->global_seo_schema->refresh_deployed_description($node, (string) $schema_type, $context_post);
2981 + }
2982 +
2983 + Schema_Graph::instance()->add_primary($node, (string) $schema_type, 'schema_manager');
2786 2984 }
2787 2985 $has_schema_manager_output = true;
2788 2986 }
2789 2987 }
@@ -2841,21 +3039,49 @@
2841 3039 'url' => home_url('/'),
2842 3040 ];
2843 3041
2844 3042 $description = !empty($settings['site_description']) ? $settings['site_description'] : get_bloginfo('description');
3043 + // The tagline is stored esc_html()'d by sanitize_option(), so a site
3044 + // called "Fish & Chips" published `&amp;` literally in its WebSite
3045 + // node; nothing decodes JSON-LD downstream.
3046 + $description = \ThinkRank\Core\Seo_Text::normalize_schema_text((string) $description);
2845 3047 if (!empty($description)) {
2846 3048 $schema['description'] = $description;
2847 3049 }
2848 3050
2849 - $schema['potentialAction'] = [
2850 - '@type' => 'SearchAction',
2851 - 'target' => [
2852 - '@type' => 'EntryPoint',
2853 - 'urlTemplate' => home_url('/?s={search_term_string}'),
2854 - ],
2855 - 'query-input' => 'required name=search_term_string',
2856 - ];
3051 + // Site Identity has accepted an alternate name since the setup wizard
3052 + // shipped, and the MCP ability describes it as "published as schema
3053 + // alternateName" — but no producer ever read it, so the promise was
3054 + // false and every imported Yoast/Rank Math value sat unused (#692).
3055 + $alternate_name = \ThinkRank\SEO\Site_Identity_Manager::alternate_name_for_schema($settings['alternate_name'] ?? null);
3056 + if (null !== $alternate_name) {
3057 + $schema['alternateName'] = $alternate_name;
3058 + }
2857 3059
3060 + // The sitelinks searchbox switch was honoured only for a deployed
3061 + // WebSite row; this live fallback added potentialAction unconditionally,
3062 + // so website_enable_search = 0 still shipped the SearchAction (#688).
3063 + // Absent means not configured, which stays enabled.
3064 + $search_enabled = true;
3065 + if ($this->schema_manager) {
3066 + $schema_settings = $this->schema_manager->get_settings('site', null);
3067 +
3068 + if (array_key_exists('website_enable_search', $schema_settings)) {
3069 + $search_enabled = !empty($schema_settings['website_enable_search']);
3070 + }
3071 + }
3072 +
3073 + if ($search_enabled) {
3074 + $schema['potentialAction'] = [
3075 + '@type' => 'SearchAction',
3076 + 'target' => [
3077 + '@type' => 'EntryPoint',
3078 + 'urlTemplate' => home_url('/?s={search_term_string}'),
3079 + ],
3080 + 'query-input' => 'required name=search_term_string',
3081 + ];
3082 + }
3083 +
2858 3084 return $schema;
2859 3085 }
2860 3086
2861 3087 /**
@@ -3066,8 +3292,22 @@
3066 3292 if (empty($settings['breadcrumbs_enabled'])) {
3067 3293 return;
3068 3294 }
3069 3295
3296 + // Schema Manager's own breadcrumb switch. Only Site Identity's
3297 + // breadcrumbs_enabled was consulted here, so enable_breadcrumbs_schema
3298 + // = 0 removed a deployed BreadcrumbList row and left this live one
3299 + // emitting the node anyway (#688). Absent means not configured, which
3300 + // stays enabled.
3301 + if ($this->schema_manager) {
3302 + $schema_settings = $this->schema_manager->get_settings('site', null);
3303 +
3304 + if (array_key_exists('enable_breadcrumbs_schema', $schema_settings)
3305 + && empty($schema_settings['enable_breadcrumbs_schema'])) {
3306 + return;
3307 + }
3308 + }
3309 +
3070 3310 $breadcrumbs = $this->generate_breadcrumbs($settings);
3071 3311
3072 3312 if (!empty($breadcrumbs['schema'])) {
3073 3313 Schema_Graph::instance()->add_supporting($breadcrumbs['schema'], 'BreadcrumbList');
@@ -3322,8 +3562,102 @@
3322 3562 * @since 1.32.0
3323 3563 *
3324 3564 * @return void
3325 3565 */
3566 + /**
3567 + * Serve a ThinkRank sitemap document for this request, when it is one.
3568 + *
3569 + * Only acts in dynamic delivery mode. In static mode a real file exists and
3570 + * the web server returns it without WordPress ever loading, so answering
3571 + * here as well would mean two sources for the same bytes.
3572 + *
3573 + * @since 2.9.0
3574 + *
3575 + * @return void
3576 + */
3577 + public function maybe_serve_sitemap(): void {
3578 + $filename = $this->requested_sitemap_filename();
3579 + if ('' === $filename) {
3580 + return;
3581 + }
3582 +
3583 + try {
3584 + // Read-only instance: passing false keeps it from registering a
3585 + // second copy of the auto-generation hooks.
3586 + $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3587 + $settings = $generator->get_settings('site');
3588 +
3589 + if (empty($settings['enabled'])) {
3590 + return;
3591 + }
3592 +
3593 + if ('dynamic' !== $generator->resolve_delivery_mode($settings)) {
3594 + return;
3595 + }
3596 +
3597 + if (!$generator->publishes_document_name($filename, $settings)) {
3598 + return;
3599 + }
3600 +
3601 + $xml = $generator->render_document($filename, $settings);
3602 + } catch (\Throwable $e) {
3603 + // A failed render must not replace the sitemap with a fatal. Leave
3604 + // the request alone so WordPress answers as it otherwise would.
3605 + return;
3606 + }
3607 +
3608 + if (!is_string($xml) || '' === trim($xml)) {
3609 + return;
3610 + }
3611 +
3612 + status_header(200);
3613 + header('Content-Type: application/xml; charset=UTF-8');
3614 + header('X-Robots-Tag: noindex, follow', true);
3615 +
3616 + // Built XML, escaped by the builders as they assemble it; escaping the
3617 + // document here would corrupt it.
3618 + echo $xml; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
3619 + exit;
3620 + }
3621 +
3622 + /**
3623 + * The sitemap file name this request is asking for, if it looks like one.
3624 + *
3625 + * Deliberately a cheap shape test. Whether the site actually publishes the
3626 + * name is settled by the caller against the generator, so that a request
3627 + * for someone else's sitemap is never answered here.
3628 + *
3629 + * @since 2.9.0
3630 + *
3631 + * @return string File name, or '' when this is not a sitemap request.
3632 + */
3633 + private function requested_sitemap_filename(): string {
3634 + if (empty($_SERVER['REQUEST_URI'])) {
3635 + return '';
3636 + }
3637 +
3638 + $path = wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), PHP_URL_PATH);
3639 + if (!is_string($path) || '' === $path) {
3640 + return '';
3641 + }
3642 +
3643 + // Strip the install's home path so subdirectory installs match too.
3644 + $home_path = (string) wp_parse_url(home_url('/'), PHP_URL_PATH);
3645 + if ('' !== $home_path && '/' !== $home_path && 0 === strpos($path, $home_path)) {
3646 + $path = substr($path, strlen($home_path));
3647 + }
3648 +
3649 + $candidate = strtolower(trim($path, '/'));
3650 +
3651 + // One path segment ending in .xml. Anything nested is not a file we
3652 + // publish to the web root.
3653 + if ('' === $candidate || strpos($candidate, '/') !== false) {
3654 + return '';
3655 + }
3656 +
3657 + return substr($candidate, -4) === '.xml' ? $candidate : '';
3658 + }
3659 +
3326 3660 public function maybe_serve_llms_txt(): void {
3327 3661 if (!$this->is_llms_txt_request()) {
3328 3662 return;
3329 3663 }
@@ -3524,11 +3858,22 @@
3524 3858 // second copy of the save_post/term auto-generation hooks.
3525 3859 $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3526 3860 $settings = $generator->get_settings('site');
3527 3861
3862 + // "Can ThinkRank actually answer its sitemap URL right now?" In
3863 + // static mode that means the file is on disk; in dynamic mode
3864 + // maybe_serve_sitemap() answers it, so there is nothing to look
3865 + // for. Keeping the file test as the only answer would have left
3866 + // core's sitemap in place on every dynamic site, which is the
3867 + // crawl conflict this suppression exists to prevent (#752).
3868 + // The #346 behaviour is unchanged: a static site with nothing
3869 + // published still falls through to core rather than 404ing.
3870 + $can_serve = 'dynamic' === $generator->resolve_delivery_mode($settings)
3871 + || $generator->primary_sitemap_file_exists($settings);
3872 +
3528 3873 $this->thinkrank_sitemap_enabled = !empty($settings['enabled'])
3529 3874 && !$this->publishes_at_core_sitemap_url($settings)
3530 - && $generator->primary_sitemap_file_exists($settings);
3875 + && $can_serve;
3531 3876
3532 3877 if ($this->thinkrank_sitemap_enabled) {
3533 3878 $this->thinkrank_sitemap_url = $generator->get_primary_sitemap_url($settings);
3534 3879 }