PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.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 +242 -72 2.5.0 → 2.7.0 View file →
@@ -38,16 +38,8 @@
38 38 * Current post metadata
39 39 *
40 40 * @var array
41 41 */
42 - /**
43 - * Page-specific schemas the free tier renders on one page.
44 - *
45 - * @since 2.0.1
46 - * @var int
47 - */
48 - private const FREE_PAGE_SCHEMA_LIMIT = 2;
49 -
50 42 private array $current_metadata = [];
51 43
52 44 /**
53 45 * Term ID of the archive being rendered, when the request is a term archive.
@@ -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)
@@ -384,22 +373,8 @@
384 373 $this->global_seo_schema->init();
385 374 }
386 375
387 376 /**
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 377 * Initialize Image SEO Manager
403 378 *
404 379 * @return void
405 380 */
@@ -716,9 +691,86 @@
716 691 *
717 692 * @param string $title Resolved title.
718 693 * @return string Title with the page indicator, when there is one.
719 694 */
695 + /**
696 + * The archive's subject, without the label WordPress prefixes it with.
697 + *
698 + * `get_the_archive_title()` returns "Month: September 2026", "Archives:
699 + * Recipes", "Category: Uncategorized" — the label is core's, aimed at an
700 + * archive heading on the page, and it reads badly in a browser tab, an
701 + * og:title or a search result. Category, tag and author contexts already
702 + * avoid it by using the raw name; the generic archive context did not, so
703 + * date, custom-post-type and custom-taxonomy archives carried it (#640).
704 + *
705 + * Removed through core's own `get_the_archive_title_prefix` filter rather
706 + * than by matching the prefix text, because that text is translated and
707 + * differs per archive type — a string comparison would work in English and
708 + * silently stop working everywhere else.
709 + *
710 + * A site that wants a prefix can put one in its title template, where it is
711 + * visible and editable, instead of inheriting one it cannot see.
712 + *
713 + * @since 2.7.0
714 + *
715 + * @return string Archive subject, with markup and the core prefix removed.
716 + */
717 + private static function archive_subject(): string {
718 + $drop_prefix = static function (): string {
719 + return '';
720 + };
721 +
722 + add_filter('get_the_archive_title_prefix', $drop_prefix, 99);
723 +
724 + $title = (string) get_the_archive_title();
725 +
726 + remove_filter('get_the_archive_title_prefix', $drop_prefix, 99);
727 +
728 + // The <span> core wraps the subject in survives the prefix filter.
729 + return trim(wp_strip_all_tags($title));
730 + }
731 +
732 + /**
733 + * Remove HTML from a title that is about to be emitted.
734 + *
735 + * A title carrying markup is broken twice over, in two different ways, and
736 + * both were reaching real pages: inside `<title>` the tags render literally,
737 + * because that element is RCDATA and never parses them; inside `og:title`
738 + * and `twitter:title` they are attribute-escaped, so the reader sees
739 + * `&lt;em&gt;` as visible text (#640).
740 + *
741 + * Applied at the point of emission rather than at each source, so it covers
742 + * every branch that can produce a title — post meta, Global SEO templates,
743 + * Site Identity templates — without each having to remember.
744 + *
745 + * Unconditional rather than a setting: there is no title for which markup is
746 + * the correct output. The filter is the escape hatch for anyone who
747 + * disagrees, and lets a site keep entities it deliberately encoded.
748 + *
749 + * @since 2.7.0
750 + *
751 + * @param string $title Title about to be emitted.
752 + * @return string Title with any markup removed.
753 + */
754 + public static function strip_title_tags(string $title): string {
755 + /**
756 + * Filter whether HTML is stripped from generated titles.
757 + *
758 + * @since 2.7.0
759 + *
760 + * @param bool $strip Whether to strip. Default true.
761 + * @param string $title The title being emitted.
762 + */
763 + if (!apply_filters('thinkrank_strip_title_tags', true, $title)) {
764 + return $title;
765 + }
766 +
767 + return trim(wp_strip_all_tags($title));
768 + }
769 +
720 770 public static function with_page_suffix(string $title): string {
771 + $title = self::strip_title_tags($title);
772 +
721 773 $page = self::current_page_number();
722 774
723 775 if ($page <= 1 || '' === $title) {
724 776 return $title;
@@ -809,11 +861,13 @@
809 861 // Output main ThinkRank SEO header comment (only once)
810 862 self::note_opening_comment();
811 863
812 864 // Ensure description is within optimal length (150-160 characters)
813 - if (strlen($description) > 160) {
814 - $description = wp_trim_words($description, 25, '...');
815 - }
865 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
866 + // CJK description tripped this limit at a third of its length, and
867 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
868 + // English, 25 characters in Thai (#687).
869 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
816 870
817 871 echo "<!-- ThinkRank SEO Meta Description -->\n";
818 872 echo '<meta name="description" content="' . esc_attr($description) . '" />' . "\n";
819 873 echo "<!-- /ThinkRank SEO Meta Description -->\n";
@@ -1325,9 +1379,9 @@
1325 1379 *
1326 1380 * @param array $og_tags Open Graph tags array
1327 1381 * @return void
1328 1382 */
1329 - private function output_social_og_tags(array $og_tags): void {
1383 + private function output_social_og_tags(array $og_tags, array $extra_images = []): void {
1330 1384 // Honor the thinkrank_og_type filter here too — this "Enhanced" path is
1331 1385 // the active OG emitter, so add-ons (e.g. Pro's WooCommerce module which
1332 1386 // sets 'product' on product pages) must be applied to it, not only to
1333 1387 // output_open_graph_tags().
@@ -1371,12 +1425,62 @@
1371 1425 echo '<meta property="' . esc_attr($property) . '" content="' . $this->esc_meta_value($property, $content) . '" />' . "\n";
1372 1426 }
1373 1427 }
1374 1428
1429 + // Alternatives, after the primary and everything belonging to it.
1430 + // Order is the whole point: a consumer reads og:image tags in document
1431 + // order and treats the first as primary, and a structured property
1432 + // attaches to the most recently declared image — so each alternative's
1433 + // companions have to follow its own URL, not be grouped at the end.
1434 + self::output_extra_og_images($extra_images);
1435 +
1375 1436 echo "<!-- /ThinkRank SEO Open Graph Tags -->\n";
1376 1437 }
1377 1438
1378 1439 /**
1440 + * Emit the secondary og:image tags a page offers.
1441 + *
1442 + * Shared by the enhanced and basic emitters so both describe an
1443 + * alternative image the same way (#636).
1444 + *
1445 + * @since 2.7.0
1446 + *
1447 + * @param array $images Each with url, and width/height/type/alt where known.
1448 + * @return void
1449 + */
1450 + private static function output_extra_og_images(array $images): void {
1451 + foreach ($images as $image) {
1452 + $url = isset($image['url']) ? (string) $image['url'] : '';
1453 +
1454 + if ('' === $url) {
1455 + continue;
1456 + }
1457 +
1458 + echo '<meta property="og:image" content="' . esc_url($url) . '" />' . "\n";
1459 +
1460 + if (strpos($url, 'https://') === 0) {
1461 + echo '<meta property="og:image:secure_url" content="' . esc_url($url) . '" />' . "\n";
1462 + }
1463 +
1464 + // Only what is actually known: a dimension guessed for a remote
1465 + // image is a number a consumer lays a card out with before it has
1466 + // fetched the file.
1467 + if (!empty($image['width']) && !empty($image['height'])) {
1468 + echo '<meta property="og:image:width" content="' . esc_attr((string) $image['width']) . '" />' . "\n";
1469 + echo '<meta property="og:image:height" content="' . esc_attr((string) $image['height']) . '" />' . "\n";
1470 + }
1471 +
1472 + if (!empty($image['type'])) {
1473 + echo '<meta property="og:image:type" content="' . esc_attr((string) $image['type']) . '" />' . "\n";
1474 + }
1475 +
1476 + if (!empty($image['alt'])) {
1477 + echo '<meta property="og:image:alt" content="' . esc_attr((string) $image['alt']) . '" />' . "\n";
1478 + }
1479 + }
1480 + }
1481 +
1482 + /**
1379 1483 * Output social media Twitter Card tags from Social Meta Manager
1380 1484 *
1381 1485 * @param array $twitter_tags Twitter Card tags array
1382 1486 * @return void
@@ -1539,9 +1643,12 @@
1539 1643 // The Social Meta Manager ran, so it owns Open Graph output. If OG is
1540 1644 // toggled off, emit nothing — do NOT fall through to the basic
1541 1645 // emitter (which would re-add a full OG block despite the toggle).
1542 1646 if (!empty($social_data['og_enabled'])) {
1543 - $this->output_social_og_tags($social_data['og_tags']);
1647 + $this->output_social_og_tags(
1648 + $social_data['og_tags'],
1649 + $social_data['og_extra_images'] ?? []
1650 + );
1544 1651 }
1545 1652 return;
1546 1653 }
1547 1654
@@ -1609,9 +1716,9 @@
1609 1716 // "There is no excerpt because this is a protected post." placeholder,
1610 1717 // so this is not a leak — but publishing that sentence as the social
1611 1718 // description is worse than publishing none (#363).
1612 1719 if (!$description && !$this->is_content_password_protected()) {
1613 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1720 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1614 1721 }
1615 1722
1616 1723 $url = is_singular() ? get_permalink() : home_url();
1617 1724 $site_name = $this->site_identity_data && !empty($this->site_identity_data['identity']['site_name'])
@@ -1639,11 +1746,11 @@
1639 1746 $og_type = apply_filters('thinkrank_og_type', $og_type);
1640 1747
1641 1748 echo "<!-- ThinkRank SEO Open Graph Meta Tags -->\n";
1642 1749 echo "<meta property=\"og:type\" content=\"" . esc_attr($og_type) . "\" />\n";
1643 - echo "<meta property=\"og:title\" content=\"" . esc_attr($title) . "\" />\n";
1750 + echo "<meta property=\"og:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1644 1751 echo "<meta property=\"og:description\" content=\"" . esc_attr($description) . "\" />\n";
1645 - echo "<meta property=\"og:url\" content=\"" . esc_url($url) . "\" />\n";
1752 + echo "<meta property=\"og:url\" content=\"" . esc_url(\ThinkRank\SEO\Url_Scheme::apply($url)) . "\" />\n";
1646 1753 echo "<meta property=\"og:site_name\" content=\"" . esc_attr($site_name) . "\" />\n";
1647 1754 /**
1648 1755 * Filter the og:locale value.
1649 1756 *
@@ -1660,14 +1767,17 @@
1660 1767 $og_locale = (string) apply_filters('thinkrank_og_locale', get_locale());
1661 1768 echo "<meta property=\"og:locale\" content=\"" . esc_attr($og_locale) . "\" />\n";
1662 1769
1663 1770 // Add OG image — per-post override > featured image
1771 + $primary_og_image = '';
1664 1772 if (is_singular() && $this->current_post_id) {
1665 1773 if (!empty($og_image_override)) {
1774 + $primary_og_image = (string) $og_image_override;
1666 1775 echo "<meta property=\"og:image\" content=\"" . esc_url($og_image_override) . "\" />\n";
1667 1776 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($og_image_override) . "\" />\n";
1668 1777 } elseif (has_post_thumbnail($this->current_post_id)) {
1669 1778 $image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
1779 + $primary_og_image = (string) $image_url;
1670 1780 echo "<meta property=\"og:image\" content=\"" . esc_url($image_url) . "\" />\n";
1671 1781 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($image_url) . "\" />\n";
1672 1782
1673 1783 // Get image dimensions and alt text
@@ -1696,8 +1806,30 @@
1696 1806 echo "<meta property=\"og:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n";
1697 1807 }
1698 1808 }
1699 1809
1810 + // Alternatives, same as the enhanced emitter above. This path only
1811 + // runs when the Social Meta Manager is unavailable, but the issue
1812 + // reported against it (#636) and a site that lands here should not
1813 + // silently lose a feature it switched on.
1814 + if (!empty($primary_og_image)) {
1815 + $social_settings = $this->social_manager
1816 + ? $this->social_manager->get_settings(
1817 + $this->current_context === 'homepage' ? 'site' : $this->current_context,
1818 + $this->current_post_id
1819 + )
1820 + : [];
1821 +
1822 + if (!empty($social_settings['og_multiple_images'])) {
1823 + self::output_extra_og_images(
1824 + \ThinkRank\SEO\Social_Images::additional(
1825 + (int) $this->current_post_id,
1826 + $primary_og_image
1827 + )
1828 + );
1829 + }
1830 + }
1831 +
1700 1832 // Add article specific tags for posts only
1701 1833 if ($og_type === 'article') {
1702 1834 echo '<meta property="article:published_time" content="' . esc_attr(get_the_date('c', $this->current_post_id)) . '" />' . "\n";
1703 1835 echo '<meta property="article:modified_time" content="' . esc_attr(get_the_modified_date('c', $this->current_post_id)) . '" />' . "\n";
@@ -1823,9 +1955,9 @@
1823 1955 // "There is no excerpt because this is a protected post." placeholder,
1824 1956 // so this is not a leak — but publishing that sentence as the social
1825 1957 // description is worse than publishing none (#363).
1826 1958 if (!$description && !$this->is_content_password_protected()) {
1827 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1959 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1828 1960 }
1829 1961
1830 1962 // Determine card type based on image availability
1831 1963 $card_type = 'summary';
@@ -1834,9 +1966,9 @@
1834 1966 }
1835 1967
1836 1968 echo "<!-- ThinkRank SEO Twitter Card Meta Tags -->\n";
1837 1969 echo '<meta name="twitter:card" content="' . esc_attr($card_type) . '" />' . "\n";
1838 - echo "<meta name=\"twitter:title\" content=\"" . esc_attr($title) . "\" />\n";
1970 + echo "<meta name=\"twitter:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1839 1971 echo "<meta name=\"twitter:description\" content=\"" . esc_attr($description) . "\" />\n";
1840 1972
1841 1973 // Add Twitter image with proper fallback priority
1842 1974 $twitter_image_url = $this->get_twitter_image_with_fallback();
@@ -1911,8 +2043,13 @@
1911 2043 if (empty($canonical_url)) {
1912 2044 return;
1913 2045 }
1914 2046
2047 + // After the filter, so a canonical an add-on supplied is normalized
2048 + // too — and a cross-domain one is left alone, since Url_Scheme only
2049 + // touches URLs on this site's own host.
2050 + $canonical_url = \ThinkRank\SEO\Url_Scheme::apply($canonical_url);
2051 +
1915 2052 echo "<!-- ThinkRank SEO Canonical URL -->\n";
1916 2053 echo "<link rel=\"canonical\" href=\"" . esc_url($canonical_url) . "\" />\n";
1917 2054 echo "<!-- /ThinkRank SEO Canonical URL -->\n";
1918 2055
@@ -1959,9 +2096,9 @@
1959 2096
1960 2097 if ($current > 1) {
1961 2098 printf(
1962 2099 "<link rel=\"prev\" href=\"%s\" />\n",
1963 - esc_url(self::with_pagination($base, $current - 1))
2100 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current - 1)))
1964 2101 );
1965 2102 }
1966 2103
1967 2104 if ($current < $total) {
@@ -1966,9 +2103,9 @@
1966 2103
1967 2104 if ($current < $total) {
1968 2105 printf(
1969 2106 "<link rel=\"next\" href=\"%s\" />\n",
1970 - esc_url(self::with_pagination($base, $current + 1))
2107 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current + 1)))
1971 2108 );
1972 2109 }
1973 2110 }
1974 2111
@@ -2468,9 +2605,9 @@
2468 2605 // Stripped: get_the_archive_title() wraps its subject in a
2469 2606 // <span>, and this placeholder feeds the document <title> as
2470 2607 // well as og:title and twitter:title — a date archive rendered
2471 2608 // as "Month: <span>August 2026</span> | Site".
2472 - $placeholders['%archive_title%'] = wp_strip_all_tags((string) get_the_archive_title());
2609 + $placeholders['%archive_title%'] = self::archive_subject();
2473 2610 break;
2474 2611
2475 2612 case 'homepage':
2476 2613 // The page template resolved for a static posts page needs the
@@ -2665,11 +2802,13 @@
2665 2802 if ($description === '') {
2666 2803 return null;
2667 2804 }
2668 2805
2669 - if (strlen($description) > 160) {
2670 - $description = wp_trim_words($description, 25, '...');
2671 - }
2806 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2807 + // CJK description tripped this limit at a third of its length, and
2808 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2809 + // English, 25 characters in Thai (#687).
2810 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2672 2811
2673 2812 return $description;
2674 2813 }
2675 2814
@@ -2716,11 +2855,13 @@
2716 2855 $description = preg_replace('/\s+/', ' ', $description);
2717 2856 $description = trim($description);
2718 2857
2719 2858 // Ensure description doesn't exceed recommended length (160 characters)
2720 - if (strlen($description) > 160) {
2721 - $description = wp_trim_words($description, 25, '...');
2722 - }
2859 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2860 + // CJK description tripped this limit at a third of its length, and
2861 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2862 + // English, 25 characters in Thai (#687).
2863 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2723 2864
2724 2865 return $description;
2725 2866 }
2726 2867
@@ -2787,9 +2928,19 @@
2787 2928
2788 2929 $page_specific_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
2789 2930
2790 2931 if (!empty($page_specific_schemas)) {
2791 - // Apply filter for Pro to allow multiple schemas
2932 + // Every deployed schema is rendered, on every plan. How many a
2933 + // page carries is decided when schemas are activated in the
2934 + // editor, not trimmed here by plan (#673).
2935 +
2936 + /**
2937 + * Filter the page-specific schemas rendered on the current page.
2938 + *
2939 + * @param array $page_specific_schemas Deployed schemas keyed by schema type.
2940 + * @param string $context_type Context type (post, page, product, site).
2941 + * @param int $context_id Post ID.
2942 + */
2792 2943 $page_specific_schemas = apply_filters(
2793 2944 'thinkrank_page_schemas_to_render',
2794 2945 $page_specific_schemas,
2795 2946 $context_type,
@@ -2795,27 +2946,8 @@
2795 2946 $context_type,
2796 2947 $context_id
2797 2948 );
2798 2949
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 2950 foreach ($page_specific_schemas as $schema_type => $schema_info) {
2819 2951 Schema_Graph::instance()->add_primary($schema_info['data'], (string) $schema_type, 'schema_manager');
2820 2952 }
2821 2953 $has_schema_manager_output = true;
@@ -2879,17 +3011,41 @@
2879 3011 if (!empty($description)) {
2880 3012 $schema['description'] = $description;
2881 3013 }
2882 3014
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 - ];
3015 + // Site Identity has accepted an alternate name since the setup wizard
3016 + // shipped, and the MCP ability describes it as "published as schema
3017 + // alternateName" — but no producer ever read it, so the promise was
3018 + // false and every imported Yoast/Rank Math value sat unused (#692).
3019 + $alternate_name = \ThinkRank\SEO\Site_Identity_Manager::alternate_name_for_schema($settings['alternate_name'] ?? null);
3020 + if (null !== $alternate_name) {
3021 + $schema['alternateName'] = $alternate_name;
3022 + }
2891 3023
3024 + // The sitelinks searchbox switch was honoured only for a deployed
3025 + // WebSite row; this live fallback added potentialAction unconditionally,
3026 + // so website_enable_search = 0 still shipped the SearchAction (#688).
3027 + // Absent means not configured, which stays enabled.
3028 + $search_enabled = true;
3029 + if ($this->schema_manager) {
3030 + $schema_settings = $this->schema_manager->get_settings('site', null);
3031 +
3032 + if (array_key_exists('website_enable_search', $schema_settings)) {
3033 + $search_enabled = !empty($schema_settings['website_enable_search']);
3034 + }
3035 + }
3036 +
3037 + if ($search_enabled) {
3038 + $schema['potentialAction'] = [
3039 + '@type' => 'SearchAction',
3040 + 'target' => [
3041 + '@type' => 'EntryPoint',
3042 + 'urlTemplate' => home_url('/?s={search_term_string}'),
3043 + ],
3044 + 'query-input' => 'required name=search_term_string',
3045 + ];
3046 + }
3047 +
2892 3048 return $schema;
2893 3049 }
2894 3050
2895 3051 /**
@@ -3098,8 +3254,22 @@
3098 3254
3099 3255 // Only output if breadcrumbs are enabled
3100 3256 if (empty($settings['breadcrumbs_enabled'])) {
3101 3257 return;
3258 + }
3259 +
3260 + // Schema Manager's own breadcrumb switch. Only Site Identity's
3261 + // breadcrumbs_enabled was consulted here, so enable_breadcrumbs_schema
3262 + // = 0 removed a deployed BreadcrumbList row and left this live one
3263 + // emitting the node anyway (#688). Absent means not configured, which
3264 + // stays enabled.
3265 + if ($this->schema_manager) {
3266 + $schema_settings = $this->schema_manager->get_settings('site', null);
3267 +
3268 + if (array_key_exists('enable_breadcrumbs_schema', $schema_settings)
3269 + && empty($schema_settings['enable_breadcrumbs_schema'])) {
3270 + return;
3271 + }
3102 3272 }
3103 3273
3104 3274 $breadcrumbs = $this->generate_breadcrumbs($settings);
3105 3275