PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.12.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.12.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 trunk All 53 releases
← All changes | includes/seo/class-social-meta-manager.php +155 -74 2.0.2 → 2.12.0 View file →
@@ -74,9 +74,12 @@
74 74 'image_min_width' => 600,
75 75 'image_min_height' => 900,
76 76 'image_recommended_ratio' => 0.67, // 2:3 ratio
77 77 'title_max_length' => 100,
78 - 'description_max_length' => 500
78 + // Pinterest has no tag of its own: it reads og:description, which
79 + // the frontend caps at max_description_length. Previewing 500
80 + // promised up to 340 characters that are never emitted.
81 + 'description_max_length' => 160
79 82 ],
80 83 'whatsapp' => [
81 84 'og_required' => ['og:title', 'og:type', 'og:image', 'og:url'],
82 85 'og_recommended' => ['og:description'],
@@ -145,14 +148,23 @@
145 148 // og:title must render the full resolved Open Graph title. The 60-char
146 149 // cap in optimize_title_for_platform() is an SEO-title recommendation for
147 150 // search results and does not apply to the og:title social tag, so use the
148 151 // resolved title verbatim (falling back to the site name when empty).
149 - $og_tags['og:title'] = ($data['title'] ?? '') !== '' ? $data['title'] : get_bloginfo('name');
152 + // Stripped: a title carrying markup is attribute-escaped into this tag,
153 + // so the reader sees a literal `<em>` rather than emphasis (#640).
154 + $og_tags['og:title'] = \ThinkRank\Frontend\SEO_Manager::strip_title_tags(
155 + ($data['title'] ?? '') !== '' ? (string) $data['title'] : (string) get_bloginfo('name')
156 + );
150 157 // `?:` rather than `??`: the key is always present, seeded as '', so the
151 158 // null-coalesce could never reach the fallback. og:url was emitted empty
152 159 // and then dropped by the !empty() guard in output_social_og_tags(),
153 160 // which is why archives carried no og:url at all (#388).
154 - $og_tags['og:url'] = ($data['url'] ?? '') !== '' ? $data['url'] : $this->get_current_url();
161 + // Normalized for the site's scheme preference, so og:url and the
162 + // canonical can never disagree about http vs https (#638); the same
163 + // consistency #182 was about.
164 + $og_tags['og:url'] = \ThinkRank\SEO\Url_Scheme::apply(
165 + ($data['url'] ?? '') !== '' ? $data['url'] : $this->get_current_url()
166 + );
155 167
156 168 // Image handling with optimization
157 169 if (!empty($data['image'])) {
158 170 $optimized_image = $this->optimize_image_for_platform($data['image'], $platform);
@@ -219,9 +231,11 @@
219 231 // to the resolved og:title/SEO title. Render it in full — the length cap
220 232 // in optimize_title_for_platform() is an SEO-title recommendation for
221 233 // search results, not a rule for the twitter:title social tag.
222 234 $twitter_title = ($data['twitter_title'] ?? '') !== '' ? $data['twitter_title'] : ($data['title'] ?? '');
223 - $twitter_tags['twitter:title'] = $twitter_title !== '' ? $twitter_title : get_bloginfo('name');
235 + $twitter_tags['twitter:title'] = \ThinkRank\Frontend\SEO_Manager::strip_title_tags(
236 + $twitter_title !== '' ? (string) $twitter_title : (string) get_bloginfo('name')
237 + );
224 238
225 239 // Recommended tags. Prefer a per-object Twitter-specific description,
226 240 // falling back to the resolved OG/meta description — mirroring the
227 241 // twitter:title cascade above. This lookup did not exist, so a Twitter
@@ -954,8 +968,12 @@
954 968
955 969 $settings = $this->get_settings($context_type, $context_id);
956 970 $output = [
957 971 'og_tags' => [],
972 + // Secondary og:image entries. A separate list because $og_tags is
973 + // keyed by property name and so can hold exactly one 'og:image'
974 + // (#636); the emitter writes these straight after the primary.
975 + 'og_extra_images' => [],
958 976 'twitter_tags' => [],
959 977 'meta_tags' => [],
960 978 'platform_tags' => [],
961 979 // Per-feature flags so each emitter can honor its own toggle. The
@@ -989,8 +1007,18 @@
989 1007 // Add custom OG tags
990 1008 if (!empty($settings['custom_og_tags'])) {
991 1009 $output['og_tags'] = array_merge($output['og_tags'], $settings['custom_og_tags']);
992 1010 }
1011 +
1012 + // Alternatives to offer after the primary image. Collected from the
1013 + // resolved primary rather than re-deriving it, so the two can never
1014 + // disagree about which image is the main one.
1015 + if (!empty($settings['og_multiple_images'])) {
1016 + $output['og_extra_images'] = Social_Images::additional(
1017 + (int) $context_id,
1018 + (string) ($output['og_tags']['og:image'] ?? '')
1019 + );
1020 + }
993 1021 }
994 1022
995 1023 // Generate Twitter Card tags if enabled
996 1024 if ($twitter_enabled) {
@@ -1026,8 +1054,33 @@
1026 1054 'default_image',
1027 1055 ];
1028 1056
1029 1057 /**
1058 + * Site-wide switches with no per-context equivalent.
1059 + *
1060 + * Unlike INHERITED_SITE_KEYS above, these must inherit their site value
1061 + * even when it is FALSE. The empty()-guarded loop used for images can only
1062 + * ever propagate "on", which is right for an image URL (empty means "not
1063 + * configured") and wrong for a boolean, where false is a deliberate choice.
1064 + *
1065 + * Without this the master Open Graph / Twitter Cards switches were honoured
1066 + * on the homepage (mapped to the `site` context) and ignored on every post
1067 + * and page, which read as though the toggle had worked (#557).
1068 + *
1069 + * @since 2.2.0
1070 + * @var string[]
1071 + */
1072 + private const SITE_ONLY_KEYS = [
1073 + 'enable_open_graph',
1074 + 'enable_twitter_cards',
1075 + // Same shape as the two above and the same trap: a site-wide switch
1076 + // with no per-context equivalent. Left out, it read as on while the
1077 + // homepage rendered and as off on every post and page — which is where
1078 + // the alternatives it controls actually come from (#636).
1079 + 'og_multiple_images',
1080 + ];
1081 +
1082 + /**
1030 1083 * Get settings for a context, inheriting the site-wide default images
1031 1084 *
1032 1085 * Two corrections over the generic lookup:
1033 1086 *
@@ -1060,8 +1113,18 @@
1060 1113 return $settings;
1061 1114 }
1062 1115
1063 1116 $site_settings = parent::get_settings('site', null);
1117 +
1118 + // Site-wide switches: the site value is authoritative, false included.
1119 + // array_key_exists, not empty() — '' is how a disabled toggle is stored.
1120 + foreach (self::SITE_ONLY_KEYS as $key) {
1121 + if (array_key_exists($key, $site_settings)) {
1122 + $settings[$key] = $site_settings[$key];
1123 + }
1124 + }
1125 +
1126 + // Default images: inherit only when this context has none of its own.
1064 1127 foreach (self::INHERITED_SITE_KEYS as $key) {
1065 1128 if (empty($settings[$key]) && !empty($site_settings[$key])) {
1066 1129 $settings[$key] = $site_settings[$key];
1067 1130 }
@@ -1097,8 +1160,12 @@
1097 1160 'og_locale' => '',
1098 1161 'default_og_image' => '',
1099 1162 'og_image_width' => 1200,
1100 1163 'og_image_height' => 630,
1164 + // Offer alternative og:image tags after the primary one (#636).
1165 + // Off by default: a page that shares with one image today must
1166 + // keep sharing with that image after an update.
1167 + 'og_multiple_images' => false,
1101 1168
1102 1169 // Twitter Cards settings
1103 1170 'enable_twitter_cards' => true,
1104 1171 'twitter_username' => '',
@@ -1138,8 +1205,15 @@
1138 1205 'fallback_to_excerpt' => true,
1139 1206 'strip_html_tags' => true,
1140 1207 'max_description_length' => 160,
1141 1208
1209 + // oEmbed card (#637). All three default off: this rewrites what
1210 + // other people's sites display, so an upgrade must not silently
1211 + // change a card an existing embed has been showing for months.
1212 + 'oembed_use_seo_title' => false,
1213 + 'oembed_use_social_image' => false,
1214 + 'oembed_remove_author' => false,
1215 +
1142 1216 // Legacy format for backward compatibility (only when needed)
1143 1217 'custom_og_tags' => [],
1144 1218 'image_optimization' => true,
1145 1219 'auto_generate' => true
@@ -1768,8 +1842,12 @@
1768 1842 $image_id = get_post_thumbnail_id($post);
1769 1843 if ($image_id) {
1770 1844 $image_data = wp_get_attachment_image_src($image_id, 'large');
1771 1845 if (!empty($image_data[0])) {
1846 + // The ID is in hand here and gone once this returns a
1847 + // bare URL; say so rather than have the tag builders look
1848 + // it up again, twice, with a URL core cannot match (#847).
1849 + Attachment_Lookup::remember((string) $image_data[0], (int) $image_id);
1772 1850 return $image_data[0];
1773 1851 }
1774 1852 }
1775 1853 }
@@ -1816,8 +1894,12 @@
1816 1894 $image_id = get_post_thumbnail_id($post);
1817 1895 if ($image_id) {
1818 1896 $image_data = wp_get_attachment_image_src($image_id, 'large');
1819 1897 if (!empty($image_data[0])) {
1898 + // The ID is in hand here and gone once this returns a
1899 + // bare URL; say so rather than have the tag builders look
1900 + // it up again, twice, with a URL core cannot match (#847).
1901 + Attachment_Lookup::remember((string) $image_data[0], (int) $image_id);
1820 1902 return $image_data[0];
1821 1903 }
1822 1904 }
1823 1905 }
@@ -1860,16 +1942,21 @@
1860 1942 if (function_exists('post_password_required') && post_password_required($post)) {
1861 1943 return '' !== $post->post_excerpt ? $post->post_excerpt : get_bloginfo('description');
1862 1944 }
1863 1945
1864 - // Try excerpt first
1865 - $description = get_the_excerpt($post);
1946 + // Try excerpt first. On a Bricks page core would derive that excerpt
1947 + // from the `post_content` Bricks throws away, so the visible body is
1948 + // used instead — a hand-written excerpt still wins (#651).
1949 + $superseding = Builder_Content::superseding_excerpt_source($post);
1950 + $description = '' !== $superseding
1951 + ? Pattern_Resolver::derive_excerpt($superseding, 30)
1952 + : get_the_excerpt($post);
1866 1953
1867 1954 // If no excerpt, generate from content. Shortcodes and block delimiters
1868 1955 // are removed the way core's wp_trim_excerpt() does, so a shortcode-built
1869 1956 // page does not publish its source as og:description (#387).
1870 1957 if (empty($description)) {
1871 - $description = Pattern_Resolver::derive_excerpt((string) $post->post_content, 30);
1958 + $description = Pattern_Resolver::derive_excerpt(Builder_Content::visible_content($post), 30);
1872 1959 }
1873 1960
1874 1961 // If still empty, use site description
1875 1962 if (empty($description)) {
@@ -2044,16 +2131,13 @@
2044 2131 if (mb_strlen($title) <= $max_length) {
2045 2132 return $title;
2046 2133 }
2047 2134
2048 - // Truncate at word boundary
2049 - $truncated = wp_trim_words($title, 10, '');
2050 - if (mb_strlen($truncated) <= $max_length) {
2051 - return $truncated;
2052 - }
2053 -
2054 - // Hard truncate if necessary
2055 - return mb_substr($title, 0, $max_length - 3) . '...';
2135 + // Truncate at a word boundary where there is one, and hard-cut where
2136 + // there is not. This used to try wp_trim_words() first, which counts
2137 + // CHARACTERS on th/ja/zh_* — so it returned ~10 characters, passed the
2138 + // $max_length check below, and that was accepted as the title (#687).
2139 + return \ThinkRank\Core\Seo_Text::trim_to_length($title, $max_length);
2056 2140 }
2057 2141
2058 2142 /**
2059 2143 * Optimize description for platform requirements
@@ -2076,16 +2160,14 @@
2076 2160 if (mb_strlen($description) <= $max_length) {
2077 2161 return $description;
2078 2162 }
2079 2163
2080 - // Truncate at word boundary
2081 - $truncated = wp_trim_words($description, 25, '');
2082 - if (mb_strlen($truncated) <= $max_length) {
2083 - return $truncated;
2084 - }
2085 -
2086 - // Hard truncate if necessary
2087 - return mb_substr($description, 0, $max_length - 3) . '...';
2164 + // Truncate at a word boundary where there is one, and hard-cut where
2165 + // there is not. This used to try wp_trim_words() first, which counts
2166 + // words in English but CHARACTERS in th/ja/zh_* — so on those locales
2167 + // it returned ~25 characters, comfortably under $max_length, and that
2168 + // was accepted as the answer (#687).
2169 + return \ThinkRank\Core\Seo_Text::trim_to_length($description, $max_length);
2088 2170 }
2089 2171
2090 2172 /**
2091 2173 * Optimize image for platform requirements
@@ -2110,21 +2192,23 @@
2110 2192 if (empty($image_url)) {
2111 2193 return $image_data;
2112 2194 }
2113 2195
2114 - // Get image metadata
2115 - $attachment_id = attachment_url_to_postid($image_url);
2196 + // Get image metadata. The dimensions are those of the file this URL
2197 + // names — usually a generated size — not of the original upload, so
2198 + // the width and height published beside it describe the same image.
2199 + $attachment_id = Attachment_Lookup::id_from_url($image_url);
2116 2200 if ($attachment_id) {
2117 2201 $image_meta = wp_get_attachment_metadata($attachment_id);
2118 2202 $image_alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
2119 - $mime_type = get_post_mime_type($attachment_id);
2203 + $image_file = Attachment_Lookup::describe($attachment_id, $image_url);
2120 2204
2121 2205 if ($image_meta && isset($image_meta['width'], $image_meta['height'])) {
2122 - $width = (int) $image_meta['width'];
2123 - $height = (int) $image_meta['height'];
2206 + $width = $image_file['width'];
2207 + $height = $image_file['height'];
2124 2208
2125 2209 $image_data['alt'] = $image_alt ?: '';
2126 - $image_data['type'] = $mime_type ?: '';
2210 + $image_data['type'] = $image_file['type'];
2127 2211
2128 2212 // SVGs and other vector uploads store 0x0 metadata. Dimension
2129 2213 // checks are meaningless there and dividing by 0 is fatal.
2130 2214 if ($width > 0 && $height > 0) {
@@ -2176,8 +2260,9 @@
2176 2260 $custom_logo_id = get_theme_mod('custom_logo');
2177 2261 if ($custom_logo_id) {
2178 2262 $logo_data = wp_get_attachment_image_src($custom_logo_id, 'large');
2179 2263 if ($logo_data) {
2264 + Attachment_Lookup::remember((string) $logo_data[0], (int) $custom_logo_id);
2180 2265 return $logo_data[0];
2181 2266 }
2182 2267 }
2183 2268
@@ -2185,8 +2270,9 @@
2185 2270 $site_icon_id = get_option('site_icon');
2186 2271 if ($site_icon_id) {
2187 2272 $icon_data = wp_get_attachment_image_src($site_icon_id, 'large');
2188 2273 if ($icon_data) {
2274 + Attachment_Lookup::remember((string) $icon_data[0], (int) $site_icon_id);
2189 2275 return $icon_data[0];
2190 2276 }
2191 2277 }
2192 2278
@@ -2613,12 +2699,13 @@
2613 2699 private function make_description_catchy(string $description): string {
2614 2700 // TikTok prefers short, catchy descriptions
2615 2701 $catchy_words = ['viral', 'trending', 'must-see', 'epic', 'mind-blowing'];
2616 2702
2617 - // Limit to 100 characters for TikTok
2618 - if (strlen($description) > 100) {
2619 - $description = substr($description, 0, 97) . '...';
2620 - }
2703 + // Limit to 100 characters for TikTok. strlen()/substr() count BYTES,
2704 + // so this both fired three times too early on Thai/CJK text and cut
2705 + // mid-character, emitting a broken UTF-8 sequence rather than a short
2706 + // description (#687).
2707 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description, 100);
2621 2708
2622 2709 if (!preg_match('/\b(' . implode('|', $catchy_words) . ')\b/i', $description)) {
2623 2710 $description = '🔥 ' . $description;
2624 2711 }
@@ -2811,8 +2898,16 @@
2811 2898 private function generate_platform_meta_tags(array $settings): array {
2812 2899 $platform_tags = [];
2813 2900
2814 2901 $core = \ThinkRank\Core\Settings::instance();
2902 +
2903 + // One query for all seven instead of one query each. They are
2904 + // autoload=off like every thinkrank_* option, so WordPress cannot
2905 + // batch them out of `alloptions`, and this runs on every anonymous
2906 + // front-end request — on most sites to discover that all seven are
2907 + // empty and no tag is emitted at all (#393).
2908 + $core->prime(array_keys(self::PLATFORM_META_KEYS));
2909 +
2815 2910 // Core Settings (decrypted for sensitive keys) wins; the table value is a
2816 2911 // backward-compat fallback for installs that saved these before the UI
2817 2912 // moved to the Social Platforms tab.
2818 2913 $resolve = static function (string $key) use ($core, $settings): string {
@@ -2822,53 +2917,38 @@
2822 2917 }
2823 2918 return $value;
2824 2919 };
2825 2920
2826 - // Facebook meta tags
2827 - $facebook_app_id = $resolve('facebook_app_id');
2828 - if ('' !== $facebook_app_id) {
2829 - $platform_tags['fb:app_id'] = $facebook_app_id;
2830 - }
2921 + foreach (self::PLATFORM_META_KEYS as $key => $meta_name) {
2922 + $value = $resolve($key);
2831 2923
2832 - $facebook_admins = $resolve('facebook_admins');
2833 - if ('' !== $facebook_admins) {
2834 - $platform_tags['fb:admins'] = $facebook_admins;
2924 + if ('' !== $value) {
2925 + $platform_tags[$meta_name] = $value;
2926 + }
2835 2927 }
2836 2928
2837 - // Pinterest site verification
2838 - $pinterest = $resolve('pinterest_site_verification');
2839 - if ('' !== $pinterest) {
2840 - $platform_tags['pinterest-site-verification'] = $pinterest;
2841 - }
2842 -
2843 - // Instagram verification
2844 - $instagram = $resolve('instagram_verification');
2845 - if ('' !== $instagram) {
2846 - $platform_tags['instagram-site-verification'] = $instagram;
2847 - }
2848 -
2849 - // TikTok verification
2850 - $tiktok = $resolve('tiktok_verification');
2851 - if ('' !== $tiktok) {
2852 - $platform_tags['tiktok-site-verification'] = $tiktok;
2853 - }
2854 -
2855 - // YouTube channel verification
2856 - $youtube = $resolve('youtube_channel_id');
2857 - if ('' !== $youtube) {
2858 - $platform_tags['youtube-channel-id'] = $youtube;
2859 - }
2860 -
2861 - // WhatsApp Business verification
2862 - $whatsapp = $resolve('whatsapp_business_id');
2863 - if ('' !== $whatsapp) {
2864 - $platform_tags['whatsapp-business-id'] = $whatsapp;
2865 - }
2866 -
2867 2929 return $platform_tags;
2868 2930 }
2869 2931
2870 2932 /**
2933 + * Platform verification settings, mapped to the meta name each is emitted
2934 + * under. One list so the batch primed in generate_platform_meta_tags() and
2935 + * the keys it then reads cannot drift apart.
2936 + *
2937 + * @since 2.1.0
2938 + * @var array<string,string>
2939 + */
2940 + private const PLATFORM_META_KEYS = [
2941 + 'facebook_app_id' => 'fb:app_id',
2942 + 'facebook_admins' => 'fb:admins',
2943 + 'pinterest_site_verification' => 'pinterest-site-verification',
2944 + 'instagram_verification' => 'instagram-site-verification',
2945 + 'tiktok_verification' => 'tiktok-site-verification',
2946 + 'youtube_channel_id' => 'youtube-channel-id',
2947 + 'whatsapp_business_id' => 'whatsapp-business-id',
2948 + ];
2949 +
2950 + /**
2871 2951 * Convert OG, Twitter, and Platform tags to HTML meta tags
2872 2952 *
2873 2953 * @since 1.0.0
2874 2954 *
@@ -2949,13 +3029,14 @@
2949 3029 return $validation;
2950 3030 }
2951 3031
2952 3032 // Get image metadata if it's a local attachment
2953 - $attachment_id = attachment_url_to_postid($image_url);
3033 + $attachment_id = Attachment_Lookup::id_from_url($image_url);
2954 3034 if ($attachment_id) {
2955 3035 $image_meta = wp_get_attachment_metadata($attachment_id);
2956 - $meta_width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0;
2957 - $meta_height = isset($image_meta['height']) ? (int) $image_meta['height'] : 0;
3036 + $image_file = Attachment_Lookup::describe($attachment_id, $image_url);
3037 + $meta_width = $image_file['width'];
3038 + $meta_height = $image_file['height'];
2958 3039
2959 3040 // SVGs and other vector uploads store 0x0 metadata — skip the
2960 3041 // dimension/ratio checks instead of dividing by 0.
2961 3042 if ($image_meta && $meta_width > 0 && $meta_height > 0) {