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 +169 -36 2.12.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
@@ -2752,8 +2797,22 @@
2752 2797 if ($archive_description) {
2753 2798 return $archive_description;
2754 2799 }
2755 2800
2801 + // The blog-index homepage's own description (Site Identity
2802 + // homepage_description, #897), in the same vocabulary as its title. A
2803 + // static front page is a page, and its description is set on it.
2804 + if (is_front_page() && is_home() && $this->site_identity_data && $this->site_identity_data['enabled']) {
2805 + $identity = $this->site_identity_manager->get_settings('site');
2806 + $template = trim((string) ($identity['homepage_description'] ?? ''));
2807 + if ($template !== '') {
2808 + $homepage_description = trim($this->process_title_template($template, $this->get_title_placeholders()));
2809 + if ($homepage_description !== '') {
2810 + return $homepage_description;
2811 + }
2812 + }
2813 + }
2814 +
2756 2815 // Third priority: Site Identity default meta description
2757 2816 if ($this->site_identity_data && $this->site_identity_data['enabled']) {
2758 2817 $settings = $this->site_identity_manager->get_settings('site');
2759 2818 $default_description = $settings['default_meta_description'] ?? '';
@@ -2892,9 +2951,9 @@
2892 2951 *
2893 2952 * @return void
2894 2953 */
2895 2954 public function output_site_schema_markup(): void {
2896 - $has_schema_manager_output = false;
2955 + $has_site_entity_output = false;
2897 2956 $has_website_schema = false;
2898 2957
2899 2958 // The master switch on Essential SEO -> Schema Manager. Until #461 this
2900 2959 // was never read here, so turning schema off left every deployed entity
@@ -2914,9 +2973,9 @@
2914 2973 if (!empty($site_wide_schemas)) {
2915 2974 foreach ($site_wide_schemas as $schema_type => $schema_info) {
2916 2975 Schema_Graph::instance()->add_supporting($schema_info['data'], (string) $schema_type);
2917 2976 }
2918 - $has_schema_manager_output = true;
2977 + $has_site_entity_output = self::schemas_include_site_entity($site_wide_schemas);
2919 2978 $has_website_schema = isset($site_wide_schemas['WebSite']);
2920 2979 }
2921 2980 }
2922 2981
@@ -2942,10 +3001,9 @@
2942 3001
2943 3002 // PRIORITY 2: Also output page-specific schemas (Article, HowTo, FAQ, etc.) on individual posts/pages
2944 3003 if ($this->schema_manager && (is_single() || is_page())) {
2945 3004 $context_id = get_the_ID();
2946 - $context_type = get_post_type( $context_id );
2947 - $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 ) );
2948 3006
2949 3007 $page_specific_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
2950 3008
2951 3009 if (!empty($page_specific_schemas)) {
@@ -2981,9 +3039,12 @@
2981 3039 }
2982 3040
2983 3041 Schema_Graph::instance()->add_primary($node, (string) $schema_type, 'schema_manager');
2984 3042 }
2985 - $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 + }
2986 3047 }
2987 3048 }
2988 3049
2989 3050 // Absorb FAQ content from the post body (FAQ block / Elementor widget)
@@ -2995,10 +3056,17 @@
2995 3056 Schema_Graph::instance()->collect_post_faq($queried_post);
2996 3057 }
2997 3058 }
2998 3059
2999 - // Skip Site Identity fallback if any Schema Manager schemas were output
3000 - 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) {
3001 3069 return;
3002 3070 }
3003 3071
3004 3072 // PRIORITY 2: Fall back to Site Identity schemas (like basic Twitter Cards)
@@ -3020,8 +3088,67 @@
3020 3088 }
3021 3089 }
3022 3090
3023 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 + /**
3024 3151 * Generate the default WebSite schema for the homepage.
3025 3152 *
3026 3153 * Includes a SearchAction potentialAction so search engines can surface a
3027 3154 * sitelinks searchbox, mirroring what Rank Math/Yoast output by default.
@@ -3034,9 +3161,12 @@
3034 3161 $schema = [
3035 3162 '@context' => 'https://schema.org',
3036 3163 '@type' => 'WebSite',
3037 3164 '@id' => home_url('/#website'),
3038 - '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 + ),
3039 3169 'url' => home_url('/'),
3040 3170 ];
3041 3171
3042 3172 $description = !empty($settings['site_description']) ? $settings['site_description'] : get_bloginfo('description');
@@ -3059,29 +3189,18 @@
3059 3189
3060 3190 // The sitelinks searchbox switch was honoured only for a deployed
3061 3191 // WebSite row; this live fallback added potentialAction unconditionally,
3062 3192 // 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);
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) : [];
3067 3197
3068 - if (array_key_exists('website_enable_search', $schema_settings)) {
3069 - $search_enabled = !empty($schema_settings['website_enable_search']);
3070 - }
3198 + $search_action = \ThinkRank\SEO\Schema_Builder::website_search_action($schema_settings);
3199 + if (null !== $search_action) {
3200 + $schema['potentialAction'] = $search_action;
3071 3201 }
3072 3202
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 -
3084 3203 return $schema;
3085 3204 }
3086 3205
3087 3206 /**
@@ -3120,8 +3239,13 @@
3120 3239 $settings['site_description'] ?? null,
3121 3240 get_bloginfo('description')
3122 3241 );
3123 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 +
3124 3248 if (empty($org_name)) {
3125 3249 return null;
3126 3250 }
3127 3251
@@ -3141,10 +3265,15 @@
3141 3265 $schema['description'] = $org_description;
3142 3266 }
3143 3267
3144 3268 // Add logo if available with proper ImageObject structure
3145 - // Priority: Schema Manager logo > Site Identity logo
3146 - $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 + );
3147 3276
3148 3277 if (!empty($logo_url)) {
3149 3278 $schema['logo'] = [
3150 3279 '@type' => 'ImageObject',
@@ -4349,12 +4478,16 @@
4349 4478
4350 4479 $schema_items = [];
4351 4480
4352 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).
4353 4486 $schema_items[] = [
4354 4487 '@type' => 'ListItem',
4355 4488 'position' => $item['position'],
4356 - 'name' => $item['title'],
4489 + 'name' => \ThinkRank\Core\Seo_Text::normalize_schema_name((string) $item['title']),
4357 4490 'item' => $item['url']
4358 4491 ];
4359 4492 }
4360 4493