PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.13.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.13.0
2.13.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 All 54 releases
← All changes | includes/frontend/class-seo-manager.php +401 -76 2.5.0 → 2.13.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
@@ -380,26 +377,20 @@
380 377 }
381 378
382 379 // Initialize Global SEO Schema Output and store reference
383 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 + );
384 389 $this->global_seo_schema->init();
385 390 }
386 391
387 392 /**
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 393 * Initialize Image SEO Manager
403 394 *
404 395 * @return void
405 396 */
@@ -716,9 +707,86 @@
716 707 *
717 708 * @param string $title Resolved title.
718 709 * @return string Title with the page indicator, when there is one.
719 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 +
720 786 public static function with_page_suffix(string $title): string {
787 + $title = self::strip_title_tags($title);
788 +
721 789 $page = self::current_page_number();
722 790
723 791 if ($page <= 1 || '' === $title) {
724 792 return $title;
@@ -809,11 +877,13 @@
809 877 // Output main ThinkRank SEO header comment (only once)
810 878 self::note_opening_comment();
811 879
812 880 // Ensure description is within optimal length (150-160 characters)
813 - if (strlen($description) > 160) {
814 - $description = wp_trim_words($description, 25, '...');
815 - }
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);
816 886
817 887 echo "<!-- ThinkRank SEO Meta Description -->\n";
818 888 echo '<meta name="description" content="' . esc_attr($description) . '" />' . "\n";
819 889 echo "<!-- /ThinkRank SEO Meta Description -->\n";
@@ -1325,9 +1395,9 @@
1325 1395 *
1326 1396 * @param array $og_tags Open Graph tags array
1327 1397 * @return void
1328 1398 */
1329 - private function output_social_og_tags(array $og_tags): void {
1399 + private function output_social_og_tags(array $og_tags, array $extra_images = []): void {
1330 1400 // Honor the thinkrank_og_type filter here too — this "Enhanced" path is
1331 1401 // the active OG emitter, so add-ons (e.g. Pro's WooCommerce module which
1332 1402 // sets 'product' on product pages) must be applied to it, not only to
1333 1403 // output_open_graph_tags().
@@ -1371,12 +1441,62 @@
1371 1441 echo '<meta property="' . esc_attr($property) . '" content="' . $this->esc_meta_value($property, $content) . '" />' . "\n";
1372 1442 }
1373 1443 }
1374 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 +
1375 1452 echo "<!-- /ThinkRank SEO Open Graph Tags -->\n";
1376 1453 }
1377 1454
1378 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 + /**
1379 1499 * Output social media Twitter Card tags from Social Meta Manager
1380 1500 *
1381 1501 * @param array $twitter_tags Twitter Card tags array
1382 1502 * @return void
@@ -1539,9 +1659,12 @@
1539 1659 // The Social Meta Manager ran, so it owns Open Graph output. If OG is
1540 1660 // toggled off, emit nothing — do NOT fall through to the basic
1541 1661 // emitter (which would re-add a full OG block despite the toggle).
1542 1662 if (!empty($social_data['og_enabled'])) {
1543 - $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 + );
1544 1667 }
1545 1668 return;
1546 1669 }
1547 1670
@@ -1609,9 +1732,9 @@
1609 1732 // "There is no excerpt because this is a protected post." placeholder,
1610 1733 // so this is not a leak — but publishing that sentence as the social
1611 1734 // description is worse than publishing none (#363).
1612 1735 if (!$description && !$this->is_content_password_protected()) {
1613 - $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');
1614 1737 }
1615 1738
1616 1739 $url = is_singular() ? get_permalink() : home_url();
1617 1740 $site_name = $this->site_identity_data && !empty($this->site_identity_data['identity']['site_name'])
@@ -1639,11 +1762,11 @@
1639 1762 $og_type = apply_filters('thinkrank_og_type', $og_type);
1640 1763
1641 1764 echo "<!-- ThinkRank SEO Open Graph Meta Tags -->\n";
1642 1765 echo "<meta property=\"og:type\" content=\"" . esc_attr($og_type) . "\" />\n";
1643 - 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";
1644 1767 echo "<meta property=\"og:description\" content=\"" . esc_attr($description) . "\" />\n";
1645 - 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";
1646 1769 echo "<meta property=\"og:site_name\" content=\"" . esc_attr($site_name) . "\" />\n";
1647 1770 /**
1648 1771 * Filter the og:locale value.
1649 1772 *
@@ -1660,14 +1783,17 @@
1660 1783 $og_locale = (string) apply_filters('thinkrank_og_locale', get_locale());
1661 1784 echo "<meta property=\"og:locale\" content=\"" . esc_attr($og_locale) . "\" />\n";
1662 1785
1663 1786 // Add OG image — per-post override > featured image
1787 + $primary_og_image = '';
1664 1788 if (is_singular() && $this->current_post_id) {
1665 1789 if (!empty($og_image_override)) {
1790 + $primary_og_image = (string) $og_image_override;
1666 1791 echo "<meta property=\"og:image\" content=\"" . esc_url($og_image_override) . "\" />\n";
1667 1792 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($og_image_override) . "\" />\n";
1668 1793 } elseif (has_post_thumbnail($this->current_post_id)) {
1669 1794 $image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
1795 + $primary_og_image = (string) $image_url;
1670 1796 echo "<meta property=\"og:image\" content=\"" . esc_url($image_url) . "\" />\n";
1671 1797 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($image_url) . "\" />\n";
1672 1798
1673 1799 // Get image dimensions and alt text
@@ -1673,12 +1799,16 @@
1673 1799 // Get image dimensions and alt text
1674 1800 $image_id = get_post_thumbnail_id($this->current_post_id);
1675 1801 $image_meta = wp_get_attachment_metadata($image_id);
1676 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);
1677 1807 // SVGs (and other vector uploads) report 0x0 — emitting
1678 1808 // those as og:image dimensions is invalid, so skip them.
1679 - $og_width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0;
1680 - $og_height = isset($image_meta['height']) ? (int) $image_meta['height'] : 0;
1809 + $og_width = $image_file['width'];
1810 + $og_height = $image_file['height'];
1681 1811 if ($og_width > 0 && $og_height > 0) {
1682 1812 echo "<meta property=\"og:image:width\" content=\"" . esc_attr($og_width) . "\" />\n";
1683 1813 echo "<meta property=\"og:image:height\" content=\"" . esc_attr($og_height) . "\" />\n";
1684 1814 }
@@ -1683,9 +1813,9 @@
1683 1813 echo "<meta property=\"og:image:height\" content=\"" . esc_attr($og_height) . "\" />\n";
1684 1814 }
1685 1815 // Derive the real mime type instead of hardcoding image/jpeg,
1686 1816 // which mislabels PNG/WebP featured images.
1687 - $image_mime = get_post_mime_type($image_id);
1817 + $image_mime = $image_file['type'];
1688 1818 if ($image_mime) {
1689 1819 echo "<meta property=\"og:image:type\" content=\"" . esc_attr($image_mime) . "\" />\n";
1690 1820 }
1691 1821 }
@@ -1696,8 +1826,30 @@
1696 1826 echo "<meta property=\"og:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n";
1697 1827 }
1698 1828 }
1699 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 +
1700 1852 // Add article specific tags for posts only
1701 1853 if ($og_type === 'article') {
1702 1854 echo '<meta property="article:published_time" content="' . esc_attr(get_the_date('c', $this->current_post_id)) . '" />' . "\n";
1703 1855 echo '<meta property="article:modified_time" content="' . esc_attr(get_the_modified_date('c', $this->current_post_id)) . '" />' . "\n";
@@ -1823,9 +1975,9 @@
1823 1975 // "There is no excerpt because this is a protected post." placeholder,
1824 1976 // so this is not a leak — but publishing that sentence as the social
1825 1977 // description is worse than publishing none (#363).
1826 1978 if (!$description && !$this->is_content_password_protected()) {
1827 - $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');
1828 1980 }
1829 1981
1830 1982 // Determine card type based on image availability
1831 1983 $card_type = 'summary';
@@ -1834,9 +1986,9 @@
1834 1986 }
1835 1987
1836 1988 echo "<!-- ThinkRank SEO Twitter Card Meta Tags -->\n";
1837 1989 echo '<meta name="twitter:card" content="' . esc_attr($card_type) . '" />' . "\n";
1838 - 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";
1839 1991 echo "<meta name=\"twitter:description\" content=\"" . esc_attr($description) . "\" />\n";
1840 1992
1841 1993 // Add Twitter image with proper fallback priority
1842 1994 $twitter_image_url = $this->get_twitter_image_with_fallback();
@@ -1911,8 +2063,13 @@
1911 2063 if (empty($canonical_url)) {
1912 2064 return;
1913 2065 }
1914 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 +
1915 2072 echo "<!-- ThinkRank SEO Canonical URL -->\n";
1916 2073 echo "<link rel=\"canonical\" href=\"" . esc_url($canonical_url) . "\" />\n";
1917 2074 echo "<!-- /ThinkRank SEO Canonical URL -->\n";
1918 2075
@@ -1959,9 +2116,9 @@
1959 2116
1960 2117 if ($current > 1) {
1961 2118 printf(
1962 2119 "<link rel=\"prev\" href=\"%s\" />\n",
1963 - esc_url(self::with_pagination($base, $current - 1))
2120 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current - 1)))
1964 2121 );
1965 2122 }
1966 2123
1967 2124 if ($current < $total) {
@@ -1966,9 +2123,9 @@
1966 2123
1967 2124 if ($current < $total) {
1968 2125 printf(
1969 2126 "<link rel=\"next\" href=\"%s\" />\n",
1970 - esc_url(self::with_pagination($base, $current + 1))
2127 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current + 1)))
1971 2128 );
1972 2129 }
1973 2130 }
1974 2131
@@ -2468,9 +2625,9 @@
2468 2625 // Stripped: get_the_archive_title() wraps its subject in a
2469 2626 // <span>, and this placeholder feeds the document <title> as
2470 2627 // well as og:title and twitter:title — a date archive rendered
2471 2628 // as "Month: <span>August 2026</span> | Site".
2472 - $placeholders['%archive_title%'] = wp_strip_all_tags((string) get_the_archive_title());
2629 + $placeholders['%archive_title%'] = self::archive_subject();
2473 2630 break;
2474 2631
2475 2632 case 'homepage':
2476 2633 // The page template resolved for a static posts page needs the
@@ -2595,8 +2752,22 @@
2595 2752 if ($archive_description) {
2596 2753 return $archive_description;
2597 2754 }
2598 2755
2756 + // The blog-index homepage's own description (Site Identity
2757 + // homepage_description, #897), in the same vocabulary as its title. A
2758 + // static front page is a page, and its description is set on it.
2759 + if (is_front_page() && is_home() && $this->site_identity_data && $this->site_identity_data['enabled']) {
2760 + $identity = $this->site_identity_manager->get_settings('site');
2761 + $template = trim((string) ($identity['homepage_description'] ?? ''));
2762 + if ($template !== '') {
2763 + $homepage_description = trim($this->process_title_template($template, $this->get_title_placeholders()));
2764 + if ($homepage_description !== '') {
2765 + return $homepage_description;
2766 + }
2767 + }
2768 + }
2769 +
2599 2770 // Third priority: Site Identity default meta description
2600 2771 if ($this->site_identity_data && $this->site_identity_data['enabled']) {
2601 2772 $settings = $this->site_identity_manager->get_settings('site');
2602 2773 $default_description = $settings['default_meta_description'] ?? '';
@@ -2665,11 +2836,13 @@
2665 2836 if ($description === '') {
2666 2837 return null;
2667 2838 }
2668 2839
2669 - if (strlen($description) > 160) {
2670 - $description = wp_trim_words($description, 25, '...');
2671 - }
2840 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2841 + // CJK description tripped this limit at a third of its length, and
2842 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2843 + // English, 25 characters in Thai (#687).
2844 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2672 2845
2673 2846 return $description;
2674 2847 }
2675 2848
@@ -2716,11 +2889,13 @@
2716 2889 $description = preg_replace('/\s+/', ' ', $description);
2717 2890 $description = trim($description);
2718 2891
2719 2892 // Ensure description doesn't exceed recommended length (160 characters)
2720 - if (strlen($description) > 160) {
2721 - $description = wp_trim_words($description, 25, '...');
2722 - }
2893 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2894 + // CJK description tripped this limit at a third of its length, and
2895 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2896 + // English, 25 characters in Thai (#687).
2897 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2723 2898
2724 2899 return $description;
2725 2900 }
2726 2901
@@ -2787,9 +2962,19 @@
2787 2962
2788 2963 $page_specific_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
2789 2964
2790 2965 if (!empty($page_specific_schemas)) {
2791 - // Apply filter for Pro to allow multiple schemas
2966 + // Every deployed schema is rendered, on every plan. How many a
2967 + // page carries is decided when schemas are activated in the
2968 + // editor, not trimmed here by plan (#673).
2969 +
2970 + /**
2971 + * Filter the page-specific schemas rendered on the current page.
2972 + *
2973 + * @param array $page_specific_schemas Deployed schemas keyed by schema type.
2974 + * @param string $context_type Context type (post, page, product, site).
2975 + * @param int $context_id Post ID.
2976 + */
2792 2977 $page_specific_schemas = apply_filters(
2793 2978 'thinkrank_page_schemas_to_render',
2794 2979 $page_specific_schemas,
2795 2980 $context_type,
@@ -2795,29 +2980,22 @@
2795 2980 $context_type,
2796 2981 $context_id
2797 2982 );
2798 2983
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 - }
2984 + // A deployed node is a snapshot from Deploy time and outranks
2985 + // the automatic node, so page and article types would publish
2986 + // a frozen excerpt instead of the description the head
2987 + // resolves. Give them the live one, as the automatic node has.
2988 + $context_post = get_post($context_id);
2817 2989
2818 2990 foreach ($page_specific_schemas as $schema_type => $schema_info) {
2819 - Schema_Graph::instance()->add_primary($schema_info['data'], (string) $schema_type, 'schema_manager');
2991 + $node = $schema_info['data'];
2992 +
2993 + if ($this->global_seo_schema && $context_post instanceof \WP_Post) {
2994 + $node = $this->global_seo_schema->refresh_deployed_description($node, (string) $schema_type, $context_post);
2995 + }
2996 +
2997 + Schema_Graph::instance()->add_primary($node, (string) $schema_type, 'schema_manager');
2820 2998 }
2821 2999 $has_schema_manager_output = true;
2822 3000 }
2823 3001 }
@@ -2875,21 +3053,49 @@
2875 3053 'url' => home_url('/'),
2876 3054 ];
2877 3055
2878 3056 $description = !empty($settings['site_description']) ? $settings['site_description'] : get_bloginfo('description');
3057 + // The tagline is stored esc_html()'d by sanitize_option(), so a site
3058 + // called "Fish & Chips" published `&amp;` literally in its WebSite
3059 + // node; nothing decodes JSON-LD downstream.
3060 + $description = \ThinkRank\Core\Seo_Text::normalize_schema_text((string) $description);
2879 3061 if (!empty($description)) {
2880 3062 $schema['description'] = $description;
2881 3063 }
2882 3064
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 - ];
3065 + // Site Identity has accepted an alternate name since the setup wizard
3066 + // shipped, and the MCP ability describes it as "published as schema
3067 + // alternateName" — but no producer ever read it, so the promise was
3068 + // false and every imported Yoast/Rank Math value sat unused (#692).
3069 + $alternate_name = \ThinkRank\SEO\Site_Identity_Manager::alternate_name_for_schema($settings['alternate_name'] ?? null);
3070 + if (null !== $alternate_name) {
3071 + $schema['alternateName'] = $alternate_name;
3072 + }
2891 3073
3074 + // The sitelinks searchbox switch was honoured only for a deployed
3075 + // WebSite row; this live fallback added potentialAction unconditionally,
3076 + // so website_enable_search = 0 still shipped the SearchAction (#688).
3077 + // Absent means not configured, which stays enabled.
3078 + $search_enabled = true;
3079 + if ($this->schema_manager) {
3080 + $schema_settings = $this->schema_manager->get_settings('site', null);
3081 +
3082 + if (array_key_exists('website_enable_search', $schema_settings)) {
3083 + $search_enabled = !empty($schema_settings['website_enable_search']);
3084 + }
3085 + }
3086 +
3087 + if ($search_enabled) {
3088 + $schema['potentialAction'] = [
3089 + '@type' => 'SearchAction',
3090 + 'target' => [
3091 + '@type' => 'EntryPoint',
3092 + 'urlTemplate' => home_url('/?s={search_term_string}'),
3093 + ],
3094 + 'query-input' => 'required name=search_term_string',
3095 + ];
3096 + }
3097 +
2892 3098 return $schema;
2893 3099 }
2894 3100
2895 3101 /**
@@ -3100,8 +3306,22 @@
3100 3306 if (empty($settings['breadcrumbs_enabled'])) {
3101 3307 return;
3102 3308 }
3103 3309
3310 + // Schema Manager's own breadcrumb switch. Only Site Identity's
3311 + // breadcrumbs_enabled was consulted here, so enable_breadcrumbs_schema
3312 + // = 0 removed a deployed BreadcrumbList row and left this live one
3313 + // emitting the node anyway (#688). Absent means not configured, which
3314 + // stays enabled.
3315 + if ($this->schema_manager) {
3316 + $schema_settings = $this->schema_manager->get_settings('site', null);
3317 +
3318 + if (array_key_exists('enable_breadcrumbs_schema', $schema_settings)
3319 + && empty($schema_settings['enable_breadcrumbs_schema'])) {
3320 + return;
3321 + }
3322 + }
3323 +
3104 3324 $breadcrumbs = $this->generate_breadcrumbs($settings);
3105 3325
3106 3326 if (!empty($breadcrumbs['schema'])) {
3107 3327 Schema_Graph::instance()->add_supporting($breadcrumbs['schema'], 'BreadcrumbList');
@@ -3356,8 +3576,102 @@
3356 3576 * @since 1.32.0
3357 3577 *
3358 3578 * @return void
3359 3579 */
3580 + /**
3581 + * Serve a ThinkRank sitemap document for this request, when it is one.
3582 + *
3583 + * Only acts in dynamic delivery mode. In static mode a real file exists and
3584 + * the web server returns it without WordPress ever loading, so answering
3585 + * here as well would mean two sources for the same bytes.
3586 + *
3587 + * @since 2.9.0
3588 + *
3589 + * @return void
3590 + */
3591 + public function maybe_serve_sitemap(): void {
3592 + $filename = $this->requested_sitemap_filename();
3593 + if ('' === $filename) {
3594 + return;
3595 + }
3596 +
3597 + try {
3598 + // Read-only instance: passing false keeps it from registering a
3599 + // second copy of the auto-generation hooks.
3600 + $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3601 + $settings = $generator->get_settings('site');
3602 +
3603 + if (empty($settings['enabled'])) {
3604 + return;
3605 + }
3606 +
3607 + if ('dynamic' !== $generator->resolve_delivery_mode($settings)) {
3608 + return;
3609 + }
3610 +
3611 + if (!$generator->publishes_document_name($filename, $settings)) {
3612 + return;
3613 + }
3614 +
3615 + $xml = $generator->render_document($filename, $settings);
3616 + } catch (\Throwable $e) {
3617 + // A failed render must not replace the sitemap with a fatal. Leave
3618 + // the request alone so WordPress answers as it otherwise would.
3619 + return;
3620 + }
3621 +
3622 + if (!is_string($xml) || '' === trim($xml)) {
3623 + return;
3624 + }
3625 +
3626 + status_header(200);
3627 + header('Content-Type: application/xml; charset=UTF-8');
3628 + header('X-Robots-Tag: noindex, follow', true);
3629 +
3630 + // Built XML, escaped by the builders as they assemble it; escaping the
3631 + // document here would corrupt it.
3632 + echo $xml; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
3633 + exit;
3634 + }
3635 +
3636 + /**
3637 + * The sitemap file name this request is asking for, if it looks like one.
3638 + *
3639 + * Deliberately a cheap shape test. Whether the site actually publishes the
3640 + * name is settled by the caller against the generator, so that a request
3641 + * for someone else's sitemap is never answered here.
3642 + *
3643 + * @since 2.9.0
3644 + *
3645 + * @return string File name, or '' when this is not a sitemap request.
3646 + */
3647 + private function requested_sitemap_filename(): string {
3648 + if (empty($_SERVER['REQUEST_URI'])) {
3649 + return '';
3650 + }
3651 +
3652 + $path = wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), PHP_URL_PATH);
3653 + if (!is_string($path) || '' === $path) {
3654 + return '';
3655 + }
3656 +
3657 + // Strip the install's home path so subdirectory installs match too.
3658 + $home_path = (string) wp_parse_url(home_url('/'), PHP_URL_PATH);
3659 + if ('' !== $home_path && '/' !== $home_path && 0 === strpos($path, $home_path)) {
3660 + $path = substr($path, strlen($home_path));
3661 + }
3662 +
3663 + $candidate = strtolower(trim($path, '/'));
3664 +
3665 + // One path segment ending in .xml. Anything nested is not a file we
3666 + // publish to the web root.
3667 + if ('' === $candidate || strpos($candidate, '/') !== false) {
3668 + return '';
3669 + }
3670 +
3671 + return substr($candidate, -4) === '.xml' ? $candidate : '';
3672 + }
3673 +
3360 3674 public function maybe_serve_llms_txt(): void {
3361 3675 if (!$this->is_llms_txt_request()) {
3362 3676 return;
3363 3677 }
@@ -3558,11 +3872,22 @@
3558 3872 // second copy of the save_post/term auto-generation hooks.
3559 3873 $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3560 3874 $settings = $generator->get_settings('site');
3561 3875
3876 + // "Can ThinkRank actually answer its sitemap URL right now?" In
3877 + // static mode that means the file is on disk; in dynamic mode
3878 + // maybe_serve_sitemap() answers it, so there is nothing to look
3879 + // for. Keeping the file test as the only answer would have left
3880 + // core's sitemap in place on every dynamic site, which is the
3881 + // crawl conflict this suppression exists to prevent (#752).
3882 + // The #346 behaviour is unchanged: a static site with nothing
3883 + // published still falls through to core rather than 404ing.
3884 + $can_serve = 'dynamic' === $generator->resolve_delivery_mode($settings)
3885 + || $generator->primary_sitemap_file_exists($settings);
3886 +
3562 3887 $this->thinkrank_sitemap_enabled = !empty($settings['enabled'])
3563 3888 && !$this->publishes_at_core_sitemap_url($settings)
3564 - && $generator->primary_sitemap_file_exists($settings);
3889 + && $can_serve;
3565 3890
3566 3891 if ($this->thinkrank_sitemap_enabled) {
3567 3892 $this->thinkrank_sitemap_url = $generator->get_primary_sitemap_url($settings);
3568 3893 }