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.15.0 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 All 59 releases
← All changes | includes/frontend/class-seo-manager.php +201 -40 2.9.0 → 2.14.3 View file →
@@ -377,8 +377,16 @@
377 377 }
378 378
379 379 // Initialize Global SEO Schema Output and store reference
380 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 + );
381 389 $this->global_seo_schema->init();
382 390 }
383 391
384 392 /**
@@ -658,8 +666,53 @@
658 666 return $this->generate_context_title();
659 667 }
660 668
661 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 + /**
662 715 * Override WordPress document title (HIGH PRIORITY)
663 716 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates
664 717 *
665 718 * @param string $title Original title
@@ -1572,10 +1625,10 @@
1572 1625 // (platform tags don't depend on them, so output is unchanged).
1573 1626 $social_data = $this->social_manager->get_output_data(
1574 1627 $social_context,
1575 1628 $this->current_post_id,
1576 - $this->get_effective_seo_title(),
1577 - $this->get_meta_description()
1629 + $this->get_social_fallback_title(),
1630 + $this->get_social_fallback_description()
1578 1631 );
1579 1632
1580 1633 if ($social_data['enabled'] && !empty($social_data['platform_tags'])) {
1581 1634 $this->output_social_platform_tags($social_data['platform_tags']);
@@ -1643,10 +1696,10 @@
1643 1696 // value the preview shows.
1644 1697 $social_data = $this->social_manager->get_output_data(
1645 1698 $social_context,
1646 1699 $this->current_post_id,
1647 - $this->get_effective_seo_title(),
1648 - $this->get_meta_description()
1700 + $this->get_social_fallback_title(),
1701 + $this->get_social_fallback_description()
1649 1702 );
1650 1703
1651 1704 // The Social Meta Manager ran, so it owns Open Graph output. If OG is
1652 1705 // toggled off, emit nothing — do NOT fall through to the basic
@@ -1791,12 +1844,16 @@
1791 1844 // Get image dimensions and alt text
1792 1845 $image_id = get_post_thumbnail_id($this->current_post_id);
1793 1846 $image_meta = wp_get_attachment_metadata($image_id);
1794 1847 if ($image_meta) {
1848 + // The `large` file being published, not the original it
1849 + // was generated from: the metadata's own width and height
1850 + // describe an image this tag does not point at (#847).
1851 + $image_file = \ThinkRank\SEO\Attachment_Lookup::describe((int) $image_id, (string) $image_url);
1795 1852 // SVGs (and other vector uploads) report 0x0 — emitting
1796 1853 // those as og:image dimensions is invalid, so skip them.
1797 - $og_width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0;
1798 - $og_height = isset($image_meta['height']) ? (int) $image_meta['height'] : 0;
1854 + $og_width = $image_file['width'];
1855 + $og_height = $image_file['height'];
1799 1856 if ($og_width > 0 && $og_height > 0) {
1800 1857 echo "<meta property=\"og:image:width\" content=\"" . esc_attr($og_width) . "\" />\n";
1801 1858 echo "<meta property=\"og:image:height\" content=\"" . esc_attr($og_height) . "\" />\n";
1802 1859 }
@@ -1801,9 +1858,9 @@
1801 1858 echo "<meta property=\"og:image:height\" content=\"" . esc_attr($og_height) . "\" />\n";
1802 1859 }
1803 1860 // Derive the real mime type instead of hardcoding image/jpeg,
1804 1861 // which mislabels PNG/WebP featured images.
1805 - $image_mime = get_post_mime_type($image_id);
1862 + $image_mime = $image_file['type'];
1806 1863 if ($image_mime) {
1807 1864 echo "<meta property=\"og:image:type\" content=\"" . esc_attr($image_mime) . "\" />\n";
1808 1865 }
1809 1866 }
@@ -1890,10 +1947,10 @@
1890 1947 // description and the metabox preview.
1891 1948 $social_data = $this->social_manager->get_output_data(
1892 1949 $social_context,
1893 1950 $this->current_post_id,
1894 - $this->get_effective_seo_title(),
1895 - $this->get_meta_description()
1951 + $this->get_social_fallback_title(),
1952 + $this->get_social_fallback_description()
1896 1953 );
1897 1954
1898 1955
1899 1956 // The Social Meta Manager ran, so it owns Twitter output. If Twitter
@@ -2489,9 +2546,9 @@
2489 2546 $separator_pattern = preg_quote($separator, '/');
2490 2547 $title = preg_replace('/\s*' . $separator_pattern . '\s*' . $separator_pattern . '\s*/', ' ' . $separator . ' ', $title);
2491 2548
2492 2549 // Remove leading/trailing separators
2493 - $title = trim($title, " \t\n\r\0\x0B" . $separator);
2550 + $title = \ThinkRank\Core\Seo_Text::trim_separator($title, $separator);
2494 2551
2495 2552 return $title;
2496 2553 }
2497 2554
@@ -2740,8 +2797,22 @@
2740 2797 if ($archive_description) {
2741 2798 return $archive_description;
2742 2799 }
2743 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 +
2744 2815 // Third priority: Site Identity default meta description
2745 2816 if ($this->site_identity_data && $this->site_identity_data['enabled']) {
2746 2817 $settings = $this->site_identity_manager->get_settings('site');
2747 2818 $default_description = $settings['default_meta_description'] ?? '';
@@ -2880,9 +2951,9 @@
2880 2951 *
2881 2952 * @return void
2882 2953 */
2883 2954 public function output_site_schema_markup(): void {
2884 - $has_schema_manager_output = false;
2955 + $has_site_entity_output = false;
2885 2956 $has_website_schema = false;
2886 2957
2887 2958 // The master switch on Essential SEO -> Schema Manager. Until #461 this
2888 2959 // was never read here, so turning schema off left every deployed entity
@@ -2902,9 +2973,9 @@
2902 2973 if (!empty($site_wide_schemas)) {
2903 2974 foreach ($site_wide_schemas as $schema_type => $schema_info) {
2904 2975 Schema_Graph::instance()->add_supporting($schema_info['data'], (string) $schema_type);
2905 2976 }
2906 - $has_schema_manager_output = true;
2977 + $has_site_entity_output = self::schemas_include_site_entity($site_wide_schemas);
2907 2978 $has_website_schema = isset($site_wide_schemas['WebSite']);
2908 2979 }
2909 2980 }
2910 2981
@@ -2930,10 +3001,9 @@
2930 3001
2931 3002 // PRIORITY 2: Also output page-specific schemas (Article, HowTo, FAQ, etc.) on individual posts/pages
2932 3003 if ($this->schema_manager && (is_single() || is_page())) {
2933 3004 $context_id = get_the_ID();
2934 - $context_type = get_post_type( $context_id );
2935 - $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 ) );
2936 3006
2937 3007 $page_specific_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
2938 3008
2939 3009 if (!empty($page_specific_schemas)) {
@@ -2954,12 +3024,27 @@
2954 3024 $context_type,
2955 3025 $context_id
2956 3026 );
2957 3027
3028 + // A deployed node is a snapshot from Deploy time and outranks
3029 + // the automatic node, so page and article types would publish
3030 + // a frozen excerpt instead of the description the head
3031 + // resolves. Give them the live one, as the automatic node has.
3032 + $context_post = get_post($context_id);
3033 +
2958 3034 foreach ($page_specific_schemas as $schema_type => $schema_info) {
2959 - Schema_Graph::instance()->add_primary($schema_info['data'], (string) $schema_type, 'schema_manager');
3035 + $node = $schema_info['data'];
3036 +
3037 + if ($this->global_seo_schema && $context_post instanceof \WP_Post) {
3038 + $node = $this->global_seo_schema->refresh_deployed_description($node, (string) $schema_type, $context_post);
3039 + }
3040 +
3041 + Schema_Graph::instance()->add_primary($node, (string) $schema_type, 'schema_manager');
2960 3042 }
2961 - $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 + }
2962 3047 }
2963 3048 }
2964 3049
2965 3050 // Absorb FAQ content from the post body (FAQ block / Elementor widget)
@@ -2971,10 +3056,17 @@
2971 3056 Schema_Graph::instance()->collect_post_faq($queried_post);
2972 3057 }
2973 3058 }
2974 3059
2975 - // Skip Site Identity fallback if any Schema Manager schemas were output
2976 - 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) {
2977 3069 return;
2978 3070 }
2979 3071
2980 3072 // PRIORITY 2: Fall back to Site Identity schemas (like basic Twitter Cards)
@@ -2996,8 +3088,67 @@
2996 3088 }
2997 3089 }
2998 3090
2999 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 + /**
3000 3151 * Generate the default WebSite schema for the homepage.
3001 3152 *
3002 3153 * Includes a SearchAction potentialAction so search engines can surface a
3003 3154 * sitelinks searchbox, mirroring what Rank Math/Yoast output by default.
@@ -3010,13 +3161,20 @@
3010 3161 $schema = [
3011 3162 '@context' => 'https://schema.org',
3012 3163 '@type' => 'WebSite',
3013 3164 '@id' => home_url('/#website'),
3014 - '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 + ),
3015 3169 'url' => home_url('/'),
3016 3170 ];
3017 3171
3018 3172 $description = !empty($settings['site_description']) ? $settings['site_description'] : get_bloginfo('description');
3173 + // The tagline is stored esc_html()'d by sanitize_option(), so a site
3174 + // called "Fish & Chips" published `&amp;` literally in its WebSite
3175 + // node; nothing decodes JSON-LD downstream.
3176 + $description = \ThinkRank\Core\Seo_Text::normalize_schema_text((string) $description);
3019 3177 if (!empty($description)) {
3020 3178 $schema['description'] = $description;
3021 3179 }
3022 3180
@@ -3031,29 +3189,18 @@
3031 3189
3032 3190 // The sitelinks searchbox switch was honoured only for a deployed
3033 3191 // WebSite row; this live fallback added potentialAction unconditionally,
3034 3192 // so website_enable_search = 0 still shipped the SearchAction (#688).
3035 - // Absent means not configured, which stays enabled.
3036 - $search_enabled = true;
3037 - if ($this->schema_manager) {
3038 - $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) : [];
3039 3197
3040 - if (array_key_exists('website_enable_search', $schema_settings)) {
3041 - $search_enabled = !empty($schema_settings['website_enable_search']);
3042 - }
3198 + $search_action = \ThinkRank\SEO\Schema_Builder::website_search_action($schema_settings);
3199 + if (null !== $search_action) {
3200 + $schema['potentialAction'] = $search_action;
3043 3201 }
3044 3202
3045 - if ($search_enabled) {
3046 - $schema['potentialAction'] = [
3047 - '@type' => 'SearchAction',
3048 - 'target' => [
3049 - '@type' => 'EntryPoint',
3050 - 'urlTemplate' => home_url('/?s={search_term_string}'),
3051 - ],
3052 - 'query-input' => 'required name=search_term_string',
3053 - ];
3054 - }
3055 -
3056 3203 return $schema;
3057 3204 }
3058 3205
3059 3206 /**
@@ -3092,8 +3239,13 @@
3092 3239 $settings['site_description'] ?? null,
3093 3240 get_bloginfo('description')
3094 3241 );
3095 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 +
3096 3248 if (empty($org_name)) {
3097 3249 return null;
3098 3250 }
3099 3251
@@ -3113,10 +3265,15 @@
3113 3265 $schema['description'] = $org_description;
3114 3266 }
3115 3267
3116 3268 // Add logo if available with proper ImageObject structure
3117 - // Priority: Schema Manager logo > Site Identity logo
3118 - $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 + );
3119 3276
3120 3277 if (!empty($logo_url)) {
3121 3278 $schema['logo'] = [
3122 3279 '@type' => 'ImageObject',
@@ -4321,12 +4478,16 @@
4321 4478
4322 4479 $schema_items = [];
4323 4480
4324 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).
4325 4486 $schema_items[] = [
4326 4487 '@type' => 'ListItem',
4327 4488 'position' => $item['position'],
4328 - 'name' => $item['title'],
4489 + 'name' => \ThinkRank\Core\Seo_Text::normalize_schema_name((string) $item['title']),
4329 4490 'item' => $item['url']
4330 4491 ];
4331 4492 }
4332 4493