PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.3
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.3
2.14.3 2.14.2 2.14.1 2.14.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 All 58 releases
← All changes | includes/frontend/class-seo-manager.php +155 -36 2.13.0 → 2.14.3 View file →
@@ -666,8 +666,53 @@
666 666 return $this->generate_context_title();
667 667 }
668 668
669 669 /**
670 + * The title Open Graph and Twitter fall back to when no override is set.
671 + *
672 + * The effective SEO title everywhere except author archives, whose
673 + * <title> is printed by Author_Archives_Manager (a pre_get_document_title
674 + * filter at priority 15 that replaces this class's). Passing
675 + * get_effective_seo_title() there gave og:title and twitter:title the Site
676 + * Identity author title, so the share card and the tab disagreed (#977).
677 + *
678 + * @since 2.15.0
679 + * @return string|null Fallback title, or null if none applies.
680 + */
681 + private function get_social_fallback_title(): ?string {
682 + if (is_author()) {
683 + $author_title = \ThinkRank\SEO\Author_Archives_Manager::resolve_title((int) get_queried_object_id());
684 + if ($author_title !== '') {
685 + return $author_title;
686 + }
687 + }
688 +
689 + return $this->get_effective_seo_title();
690 + }
691 +
692 + /**
693 + * The description Open Graph and Twitter fall back to when no override is set.
694 + *
695 + * Same split as get_social_fallback_title(): on an author archive the meta
696 + * description comes from Author_Archives_Manager, and get_meta_description()
697 + * knows nothing about it, so og:description and twitter:description were
698 + * missing there even while the description tag was printed (#977).
699 + *
700 + * @since 2.15.0
701 + * @return string|null Fallback description, or null if none applies.
702 + */
703 + private function get_social_fallback_description(): ?string {
704 + if (is_author()) {
705 + $author_description = \ThinkRank\SEO\Author_Archives_Manager::resolve_description((int) get_queried_object_id());
706 + if ($author_description !== '') {
707 + return $author_description;
708 + }
709 + }
710 +
711 + return $this->get_meta_description();
712 + }
713 +
714 + /**
670 715 * Override WordPress document title (HIGH PRIORITY)
671 716 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates
672 717 *
673 718 * @param string $title Original title
@@ -1580,10 +1625,10 @@
1580 1625 // (platform tags don't depend on them, so output is unchanged).
1581 1626 $social_data = $this->social_manager->get_output_data(
1582 1627 $social_context,
1583 1628 $this->current_post_id,
1584 - $this->get_effective_seo_title(),
1585 - $this->get_meta_description()
1629 + $this->get_social_fallback_title(),
1630 + $this->get_social_fallback_description()
1586 1631 );
1587 1632
1588 1633 if ($social_data['enabled'] && !empty($social_data['platform_tags'])) {
1589 1634 $this->output_social_platform_tags($social_data['platform_tags']);
@@ -1651,10 +1696,10 @@
1651 1696 // value the preview shows.
1652 1697 $social_data = $this->social_manager->get_output_data(
1653 1698 $social_context,
1654 1699 $this->current_post_id,
1655 - $this->get_effective_seo_title(),
1656 - $this->get_meta_description()
1700 + $this->get_social_fallback_title(),
1701 + $this->get_social_fallback_description()
1657 1702 );
1658 1703
1659 1704 // The Social Meta Manager ran, so it owns Open Graph output. If OG is
1660 1705 // toggled off, emit nothing — do NOT fall through to the basic
@@ -1902,10 +1947,10 @@
1902 1947 // description and the metabox preview.
1903 1948 $social_data = $this->social_manager->get_output_data(
1904 1949 $social_context,
1905 1950 $this->current_post_id,
1906 - $this->get_effective_seo_title(),
1907 - $this->get_meta_description()
1951 + $this->get_social_fallback_title(),
1952 + $this->get_social_fallback_description()
1908 1953 );
1909 1954
1910 1955
1911 1956 // The Social Meta Manager ran, so it owns Twitter output. If Twitter
@@ -2501,9 +2546,9 @@
2501 2546 $separator_pattern = preg_quote($separator, '/');
2502 2547 $title = preg_replace('/\s*' . $separator_pattern . '\s*' . $separator_pattern . '\s*/', ' ' . $separator . ' ', $title);
2503 2548
2504 2549 // Remove leading/trailing separators
2505 - $title = trim($title, " \t\n\r\0\x0B" . $separator);
2550 + $title = \ThinkRank\Core\Seo_Text::trim_separator($title, $separator);
2506 2551
2507 2552 return $title;
2508 2553 }
2509 2554
@@ -2906,9 +2951,9 @@
2906 2951 *
2907 2952 * @return void
2908 2953 */
2909 2954 public function output_site_schema_markup(): void {
2910 - $has_schema_manager_output = false;
2955 + $has_site_entity_output = false;
2911 2956 $has_website_schema = false;
2912 2957
2913 2958 // The master switch on Essential SEO -> Schema Manager. Until #461 this
2914 2959 // was never read here, so turning schema off left every deployed entity
@@ -2928,9 +2973,9 @@
2928 2973 if (!empty($site_wide_schemas)) {
2929 2974 foreach ($site_wide_schemas as $schema_type => $schema_info) {
2930 2975 Schema_Graph::instance()->add_supporting($schema_info['data'], (string) $schema_type);
2931 2976 }
2932 - $has_schema_manager_output = true;
2977 + $has_site_entity_output = self::schemas_include_site_entity($site_wide_schemas);
2933 2978 $has_website_schema = isset($site_wide_schemas['WebSite']);
2934 2979 }
2935 2980 }
2936 2981
@@ -2956,10 +3001,9 @@
2956 3001
2957 3002 // PRIORITY 2: Also output page-specific schemas (Article, HowTo, FAQ, etc.) on individual posts/pages
2958 3003 if ($this->schema_manager && (is_single() || is_page())) {
2959 3004 $context_id = get_the_ID();
2960 - $context_type = get_post_type( $context_id );
2961 - $context_type = in_array( $context_type, [ 'site', 'post', 'page', 'product' ] , true) ? $context_type : 'post';
3005 + $context_type = \ThinkRank\SEO\Schema_Management_System::context_type_for_post_type( (string) get_post_type( $context_id ) );
2962 3006
2963 3007 $page_specific_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
2964 3008
2965 3009 if (!empty($page_specific_schemas)) {
@@ -2995,9 +3039,12 @@
2995 3039 }
2996 3040
2997 3041 Schema_Graph::instance()->add_primary($node, (string) $schema_type, 'schema_manager');
2998 3042 }
2999 - $has_schema_manager_output = true;
3043 +
3044 + if (!$has_site_entity_output) {
3045 + $has_site_entity_output = self::schemas_include_site_entity($page_specific_schemas);
3046 + }
3000 3047 }
3001 3048 }
3002 3049
3003 3050 // Absorb FAQ content from the post body (FAQ block / Elementor widget)
@@ -3009,10 +3056,17 @@
3009 3056 Schema_Graph::instance()->collect_post_faq($queried_post);
3010 3057 }
3011 3058 }
3012 3059
3013 - // Skip Site Identity fallback if any Schema Manager schemas were output
3014 - if ($has_schema_manager_output) {
3060 + // The deployed snapshot is exact, so the live builder never runs over
3061 + // it. But the Site Identity fallback below emits one entity, the
3062 + // Organization, and it used to be skipped whenever ANY row was
3063 + // output: a site whose deployed set was a WebSite, or a post with
3064 + // only a deployed Article, published no Organization at all and its
3065 + // publisher could not resolve to #organization (#1006). Skip it only
3066 + // when the Schema Manager already supplied the entity it stands in
3067 + // for, which also keeps a deployed #organization from being doubled.
3068 + if ($has_site_entity_output) {
3015 3069 return;
3016 3070 }
3017 3071
3018 3072 // PRIORITY 2: Fall back to Site Identity schemas (like basic Twitter Cards)
@@ -3034,8 +3088,67 @@
3034 3088 }
3035 3089 }
3036 3090
3037 3091 /**
3092 + * Organization types the Schema Manager can publish the site entity as.
3093 + *
3094 + * The values the organization_type setting offers. LocalBusiness and its
3095 + * subtypes are recognised through Local_Business_Types_Config instead.
3096 + *
3097 + * @since 2.15.0
3098 + * @var string[]
3099 + */
3100 + private const SITE_ENTITY_ORGANIZATION_TYPES = [
3101 + 'Organization',
3102 + 'Corporation',
3103 + 'EducationalOrganization',
3104 + 'GovernmentOrganization',
3105 + 'NGO',
3106 + 'PerformingGroup',
3107 + 'SportsOrganization',
3108 + ];
3109 +
3110 + /**
3111 + * Whether deployed schemas already describe the site's own entity.
3112 + *
3113 + * The entity is what the Site Identity Organization fallback stands in
3114 + * for: an Organization (or one of its configurable subtypes), a Person,
3115 + * or a LocalBusiness of any subtype. Both the row's schema type and the
3116 + * node's resolved @type are checked, since a LocalBusiness row is
3117 + * published as "Dentist" and an Organization row can carry a subtype.
3118 + *
3119 + * @since 2.15.0
3120 + *
3121 + * @param array $schemas Deployed schemas keyed by schema type, each with a 'data' node.
3122 + * @return bool
3123 + */
3124 + private static function schemas_include_site_entity(array $schemas): bool {
3125 + foreach ($schemas as $schema_type => $schema_info) {
3126 + $types = [(string) $schema_type];
3127 +
3128 + if (is_array($schema_info) && is_array($schema_info['data'] ?? null) && isset($schema_info['data']['@type'])) {
3129 + $types = array_merge($types, (array) $schema_info['data']['@type']);
3130 + }
3131 +
3132 + foreach ($types as $type) {
3133 + if (!is_string($type)) {
3134 + continue;
3135 + }
3136 +
3137 + if (
3138 + 'Person' === $type
3139 + || in_array($type, self::SITE_ENTITY_ORGANIZATION_TYPES, true)
3140 + || \ThinkRank\Config\Local_Business_Types_Config::is_local_business($type)
3141 + ) {
3142 + return true;
3143 + }
3144 + }
3145 + }
3146 +
3147 + return false;
3148 + }
3149 +
3150 + /**
3038 3151 * Generate the default WebSite schema for the homepage.
3039 3152 *
3040 3153 * Includes a SearchAction potentialAction so search engines can surface a
3041 3154 * sitelinks searchbox, mirroring what Rank Math/Yoast output by default.
@@ -3048,9 +3161,12 @@
3048 3161 $schema = [
3049 3162 '@context' => 'https://schema.org',
3050 3163 '@type' => 'WebSite',
3051 3164 '@id' => home_url('/#website'),
3052 - 'name' => !empty($settings['site_name']) ? $settings['site_name'] : get_bloginfo('name'),
3165 + // The site name is stored esc_html()'d too, like the tagline below.
3166 + 'name' => \ThinkRank\Core\Seo_Text::normalize_schema_name(
3167 + (string) (!empty($settings['site_name']) ? $settings['site_name'] : get_bloginfo('name'))
3168 + ),
3053 3169 'url' => home_url('/'),
3054 3170 ];
3055 3171
3056 3172 $description = !empty($settings['site_description']) ? $settings['site_description'] : get_bloginfo('description');
@@ -3073,29 +3189,18 @@
3073 3189
3074 3190 // The sitelinks searchbox switch was honoured only for a deployed
3075 3191 // WebSite row; this live fallback added potentialAction unconditionally,
3076 3192 // 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);
3193 + // Absent means not configured, which stays enabled. The switch and the
3194 + // custom search URL go through the same helper the deployed WebSite
3195 + // uses, so the two paths cannot drift apart again (#1009).
3196 + $schema_settings = $this->schema_manager ? $this->schema_manager->get_settings('site', null) : [];
3081 3197
3082 - if (array_key_exists('website_enable_search', $schema_settings)) {
3083 - $search_enabled = !empty($schema_settings['website_enable_search']);
3084 - }
3198 + $search_action = \ThinkRank\SEO\Schema_Builder::website_search_action($schema_settings);
3199 + if (null !== $search_action) {
3200 + $schema['potentialAction'] = $search_action;
3085 3201 }
3086 3202
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 -
3098 3203 return $schema;
3099 3204 }
3100 3205
3101 3206 /**
@@ -3134,8 +3239,13 @@
3134 3239 $settings['site_description'] ?? null,
3135 3240 get_bloginfo('description')
3136 3241 );
3137 3242
3243 + // Stored esc_html()'d by sanitize_option(), so "Fish & Chips" was
3244 + // published as `Fish &amp; Chips` (#976). Same for the description.
3245 + $org_name = \ThinkRank\Core\Seo_Text::normalize_schema_name((string) $org_name);
3246 + $org_description = \ThinkRank\Core\Seo_Text::normalize_schema_text((string) $org_description);
3247 +
3138 3248 if (empty($org_name)) {
3139 3249 return null;
3140 3250 }
3141 3251
@@ -3155,10 +3265,15 @@
3155 3265 $schema['description'] = $org_description;
3156 3266 }
3157 3267
3158 3268 // Add logo if available with proper ImageObject structure
3159 - // Priority: Schema Manager logo > Site Identity logo
3160 - $logo_url = $schema_settings['organization_logo'] ?? $settings['logo_url'] ?? '';
3269 + // Priority: Schema Manager logo > Site Identity logo. first_non_empty()
3270 + // rather than ??: organization_logo is always present and defaults to
3271 + // '', which stopped a ?? chain before the Site Identity logo (#1010).
3272 + $logo_url = $this->first_non_empty(
3273 + $schema_settings['organization_logo'] ?? null,
3274 + $settings['logo_url'] ?? null
3275 + );
3161 3276
3162 3277 if (!empty($logo_url)) {
3163 3278 $schema['logo'] = [
3164 3279 '@type' => 'ImageObject',
@@ -4363,12 +4478,16 @@
4363 4478
4364 4479 $schema_items = [];
4365 4480
4366 4481 foreach ($items as $item) {
4482 + // The trail's labels are HTML (get_the_title() is texturized, term
4483 + // names are stored esc_html()'d), which the visible breadcrumb
4484 + // prints as such. JSON-LD is plain text, so the schema copy is
4485 + // decoded here and the visible trail is left alone (#976).
4367 4486 $schema_items[] = [
4368 4487 '@type' => 'ListItem',
4369 4488 'position' => $item['position'],
4370 - 'name' => $item['title'],
4489 + 'name' => \ThinkRank\Core\Seo_Text::normalize_schema_name((string) $item['title']),
4371 4490 'item' => $item['url']
4372 4491 ];
4373 4492 }
4374 4493