PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.13.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.13.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 1.28.0 1.27.0 1.26.0 1.25.0 All 54 releases
← All changes | includes/seo/class-social-meta-manager.php +103 -36 2.3.0 → 2.13.0 View file →
@@ -148,14 +148,23 @@
148 148 // og:title must render the full resolved Open Graph title. The 60-char
149 149 // cap in optimize_title_for_platform() is an SEO-title recommendation for
150 150 // search results and does not apply to the og:title social tag, so use the
151 151 // resolved title verbatim (falling back to the site name when empty).
152 - $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 + );
153 157 // `?:` rather than `??`: the key is always present, seeded as '', so the
154 158 // null-coalesce could never reach the fallback. og:url was emitted empty
155 159 // and then dropped by the !empty() guard in output_social_og_tags(),
156 160 // which is why archives carried no og:url at all (#388).
157 - $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 + );
158 167
159 168 // Image handling with optimization
160 169 if (!empty($data['image'])) {
161 170 $optimized_image = $this->optimize_image_for_platform($data['image'], $platform);
@@ -222,9 +231,11 @@
222 231 // to the resolved og:title/SEO title. Render it in full — the length cap
223 232 // in optimize_title_for_platform() is an SEO-title recommendation for
224 233 // search results, not a rule for the twitter:title social tag.
225 234 $twitter_title = ($data['twitter_title'] ?? '') !== '' ? $data['twitter_title'] : ($data['title'] ?? '');
226 - $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 + );
227 238
228 239 // Recommended tags. Prefer a per-object Twitter-specific description,
229 240 // falling back to the resolved OG/meta description — mirroring the
230 241 // twitter:title cascade above. This lookup did not exist, so a Twitter
@@ -957,8 +968,12 @@
957 968
958 969 $settings = $this->get_settings($context_type, $context_id);
959 970 $output = [
960 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' => [],
961 976 'twitter_tags' => [],
962 977 'meta_tags' => [],
963 978 'platform_tags' => [],
964 979 // Per-feature flags so each emitter can honor its own toggle. The
@@ -992,8 +1007,18 @@
992 1007 // Add custom OG tags
993 1008 if (!empty($settings['custom_og_tags'])) {
994 1009 $output['og_tags'] = array_merge($output['og_tags'], $settings['custom_og_tags']);
995 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 + }
996 1021 }
997 1022
998 1023 // Generate Twitter Card tags if enabled
999 1024 if ($twitter_enabled) {
@@ -1046,8 +1071,13 @@
1046 1071 */
1047 1072 private const SITE_ONLY_KEYS = [
1048 1073 'enable_open_graph',
1049 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',
1050 1080 ];
1051 1081
1052 1082 /**
1053 1083 * Get settings for a context, inheriting the site-wide default images
@@ -1130,8 +1160,12 @@
1130 1160 'og_locale' => '',
1131 1161 'default_og_image' => '',
1132 1162 'og_image_width' => 1200,
1133 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,
1134 1168
1135 1169 // Twitter Cards settings
1136 1170 'enable_twitter_cards' => true,
1137 1171 'twitter_username' => '',
@@ -1171,8 +1205,15 @@
1171 1205 'fallback_to_excerpt' => true,
1172 1206 'strip_html_tags' => true,
1173 1207 'max_description_length' => 160,
1174 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 +
1175 1216 // Legacy format for backward compatibility (only when needed)
1176 1217 'custom_og_tags' => [],
1177 1218 'image_optimization' => true,
1178 1219 'auto_generate' => true
@@ -1492,9 +1533,21 @@
1492 1533 $data['title'] = $fallback_title;
1493 1534 } else {
1494 1535 $data['title'] = $blogname;
1495 1536 }
1496 - $data['description'] = $settings['og_description'] ?? get_bloginfo('description');
1537 + // Same rule as the title above: the shipped default (the tagline)
1538 + // is not a choice, so the homepage's meta description wins over
1539 + // it. Without this an imported or hand-set homepage description
1540 + // printed as the meta description while og:/twitter:description
1541 + // kept the tagline (#897).
1542 + $og_description = (string) ($settings['og_description'] ?? '');
1543 + if ($og_description !== '' && $og_description !== get_bloginfo('description')) {
1544 + $data['description'] = $og_description;
1545 + } elseif ($fallback_description !== null && $fallback_description !== '') {
1546 + $data['description'] = $fallback_description;
1547 + } else {
1548 + $data['description'] = get_bloginfo('description');
1549 + }
1497 1550 // Use home_url('/') so og:url matches the homepage canonical
1498 1551 // (class-seo-manager.php) and the WebSite schema, which both include
1499 1552 // the trailing slash. A bare home_url() would key a different URL in
1500 1553 // social caches than the canonical.
@@ -1801,8 +1854,12 @@
1801 1854 $image_id = get_post_thumbnail_id($post);
1802 1855 if ($image_id) {
1803 1856 $image_data = wp_get_attachment_image_src($image_id, 'large');
1804 1857 if (!empty($image_data[0])) {
1858 + // The ID is in hand here and gone once this returns a
1859 + // bare URL; say so rather than have the tag builders look
1860 + // it up again, twice, with a URL core cannot match (#847).
1861 + Attachment_Lookup::remember((string) $image_data[0], (int) $image_id);
1805 1862 return $image_data[0];
1806 1863 }
1807 1864 }
1808 1865 }
@@ -1849,8 +1906,12 @@
1849 1906 $image_id = get_post_thumbnail_id($post);
1850 1907 if ($image_id) {
1851 1908 $image_data = wp_get_attachment_image_src($image_id, 'large');
1852 1909 if (!empty($image_data[0])) {
1910 + // The ID is in hand here and gone once this returns a
1911 + // bare URL; say so rather than have the tag builders look
1912 + // it up again, twice, with a URL core cannot match (#847).
1913 + Attachment_Lookup::remember((string) $image_data[0], (int) $image_id);
1853 1914 return $image_data[0];
1854 1915 }
1855 1916 }
1856 1917 }
@@ -1893,16 +1954,21 @@
1893 1954 if (function_exists('post_password_required') && post_password_required($post)) {
1894 1955 return '' !== $post->post_excerpt ? $post->post_excerpt : get_bloginfo('description');
1895 1956 }
1896 1957
1897 - // Try excerpt first
1898 - $description = get_the_excerpt($post);
1958 + // Try excerpt first. On a Bricks page core would derive that excerpt
1959 + // from the `post_content` Bricks throws away, so the visible body is
1960 + // used instead — a hand-written excerpt still wins (#651).
1961 + $superseding = Builder_Content::superseding_excerpt_source($post);
1962 + $description = '' !== $superseding
1963 + ? Pattern_Resolver::derive_excerpt($superseding, 30)
1964 + : get_the_excerpt($post);
1899 1965
1900 1966 // If no excerpt, generate from content. Shortcodes and block delimiters
1901 1967 // are removed the way core's wp_trim_excerpt() does, so a shortcode-built
1902 1968 // page does not publish its source as og:description (#387).
1903 1969 if (empty($description)) {
1904 - $description = Pattern_Resolver::derive_excerpt((string) $post->post_content, 30);
1970 + $description = Pattern_Resolver::derive_excerpt(Builder_Content::visible_content($post), 30);
1905 1971 }
1906 1972
1907 1973 // If still empty, use site description
1908 1974 if (empty($description)) {
@@ -2077,16 +2143,13 @@
2077 2143 if (mb_strlen($title) <= $max_length) {
2078 2144 return $title;
2079 2145 }
2080 2146
2081 - // Truncate at word boundary
2082 - $truncated = wp_trim_words($title, 10, '');
2083 - if (mb_strlen($truncated) <= $max_length) {
2084 - return $truncated;
2085 - }
2086 -
2087 - // Hard truncate if necessary
2088 - return mb_substr($title, 0, $max_length - 3) . '...';
2147 + // Truncate at a word boundary where there is one, and hard-cut where
2148 + // there is not. This used to try wp_trim_words() first, which counts
2149 + // CHARACTERS on th/ja/zh_* — so it returned ~10 characters, passed the
2150 + // $max_length check below, and that was accepted as the title (#687).
2151 + return \ThinkRank\Core\Seo_Text::trim_to_length($title, $max_length);
2089 2152 }
2090 2153
2091 2154 /**
2092 2155 * Optimize description for platform requirements
@@ -2109,16 +2172,14 @@
2109 2172 if (mb_strlen($description) <= $max_length) {
2110 2173 return $description;
2111 2174 }
2112 2175
2113 - // Truncate at word boundary
2114 - $truncated = wp_trim_words($description, 25, '');
2115 - if (mb_strlen($truncated) <= $max_length) {
2116 - return $truncated;
2117 - }
2118 -
2119 - // Hard truncate if necessary
2120 - return mb_substr($description, 0, $max_length - 3) . '...';
2176 + // Truncate at a word boundary where there is one, and hard-cut where
2177 + // there is not. This used to try wp_trim_words() first, which counts
2178 + // words in English but CHARACTERS in th/ja/zh_* — so on those locales
2179 + // it returned ~25 characters, comfortably under $max_length, and that
2180 + // was accepted as the answer (#687).
2181 + return \ThinkRank\Core\Seo_Text::trim_to_length($description, $max_length);
2121 2182 }
2122 2183
2123 2184 /**
2124 2185 * Optimize image for platform requirements
@@ -2143,21 +2204,23 @@
2143 2204 if (empty($image_url)) {
2144 2205 return $image_data;
2145 2206 }
2146 2207
2147 - // Get image metadata
2148 - $attachment_id = attachment_url_to_postid($image_url);
2208 + // Get image metadata. The dimensions are those of the file this URL
2209 + // names — usually a generated size — not of the original upload, so
2210 + // the width and height published beside it describe the same image.
2211 + $attachment_id = Attachment_Lookup::id_from_url($image_url);
2149 2212 if ($attachment_id) {
2150 2213 $image_meta = wp_get_attachment_metadata($attachment_id);
2151 2214 $image_alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
2152 - $mime_type = get_post_mime_type($attachment_id);
2215 + $image_file = Attachment_Lookup::describe($attachment_id, $image_url);
2153 2216
2154 2217 if ($image_meta && isset($image_meta['width'], $image_meta['height'])) {
2155 - $width = (int) $image_meta['width'];
2156 - $height = (int) $image_meta['height'];
2218 + $width = $image_file['width'];
2219 + $height = $image_file['height'];
2157 2220
2158 2221 $image_data['alt'] = $image_alt ?: '';
2159 - $image_data['type'] = $mime_type ?: '';
2222 + $image_data['type'] = $image_file['type'];
2160 2223
2161 2224 // SVGs and other vector uploads store 0x0 metadata. Dimension
2162 2225 // checks are meaningless there and dividing by 0 is fatal.
2163 2226 if ($width > 0 && $height > 0) {
@@ -2209,8 +2272,9 @@
2209 2272 $custom_logo_id = get_theme_mod('custom_logo');
2210 2273 if ($custom_logo_id) {
2211 2274 $logo_data = wp_get_attachment_image_src($custom_logo_id, 'large');
2212 2275 if ($logo_data) {
2276 + Attachment_Lookup::remember((string) $logo_data[0], (int) $custom_logo_id);
2213 2277 return $logo_data[0];
2214 2278 }
2215 2279 }
2216 2280
@@ -2218,8 +2282,9 @@
2218 2282 $site_icon_id = get_option('site_icon');
2219 2283 if ($site_icon_id) {
2220 2284 $icon_data = wp_get_attachment_image_src($site_icon_id, 'large');
2221 2285 if ($icon_data) {
2286 + Attachment_Lookup::remember((string) $icon_data[0], (int) $site_icon_id);
2222 2287 return $icon_data[0];
2223 2288 }
2224 2289 }
2225 2290
@@ -2646,12 +2711,13 @@
2646 2711 private function make_description_catchy(string $description): string {
2647 2712 // TikTok prefers short, catchy descriptions
2648 2713 $catchy_words = ['viral', 'trending', 'must-see', 'epic', 'mind-blowing'];
2649 2714
2650 - // Limit to 100 characters for TikTok
2651 - if (strlen($description) > 100) {
2652 - $description = substr($description, 0, 97) . '...';
2653 - }
2715 + // Limit to 100 characters for TikTok. strlen()/substr() count BYTES,
2716 + // so this both fired three times too early on Thai/CJK text and cut
2717 + // mid-character, emitting a broken UTF-8 sequence rather than a short
2718 + // description (#687).
2719 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description, 100);
2654 2720
2655 2721 if (!preg_match('/\b(' . implode('|', $catchy_words) . ')\b/i', $description)) {
2656 2722 $description = '🔥 ' . $description;
2657 2723 }
@@ -2975,13 +3041,14 @@
2975 3041 return $validation;
2976 3042 }
2977 3043
2978 3044 // Get image metadata if it's a local attachment
2979 - $attachment_id = attachment_url_to_postid($image_url);
3045 + $attachment_id = Attachment_Lookup::id_from_url($image_url);
2980 3046 if ($attachment_id) {
2981 3047 $image_meta = wp_get_attachment_metadata($attachment_id);
2982 - $meta_width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0;
2983 - $meta_height = isset($image_meta['height']) ? (int) $image_meta['height'] : 0;
3048 + $image_file = Attachment_Lookup::describe($attachment_id, $image_url);
3049 + $meta_width = $image_file['width'];
3050 + $meta_height = $image_file['height'];
2984 3051
2985 3052 // SVGs and other vector uploads store 0x0 metadata — skip the
2986 3053 // dimension/ratio checks instead of dividing by 0.
2987 3054 if ($image_meta && $meta_width > 0 && $meta_height > 0) {