PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
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 1.28.0 All 57 releases
← All changes | includes/seo/class-social-meta-manager.php +134 -47 2.3.0 → 2.14.2 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
@@ -512,12 +523,29 @@
512 523 $validation['valid'] = false;
513 524 }
514 525 }
515 526
527 + // The default Open Graph and Twitter images are checked in the field
528 + // details above only while their feature is switched on, but they are
529 + // stored (and shown in the admin preview's src) either way.
530 + $flagged = [];
531 + foreach ($field_details as $field) {
532 + if ('error' === ($field['status'] ?? '')) {
533 + $flagged[] = $field['field'] ?? '';
534 + }
535 + }
536 + foreach (['default_og_image' => 'Default Open Graph image', 'default_twitter_image' => 'Default Twitter Card image'] as $key => $label) {
537 + if (!empty($settings[$key]) && !in_array($key, $flagged, true)
538 + && !\ThinkRank\Core\Url_Validator::is_http_url($settings[$key])) {
539 + $validation['errors'][] = $label . ' must be an http or https URL.';
540 + $validation['valid'] = false;
541 + }
542 + }
543 +
516 544 // Validate default image
517 545 if (isset($settings['default_image']) && !empty($settings['default_image'])) {
518 - if (!filter_var($settings['default_image'], FILTER_VALIDATE_URL)) {
519 - $validation['errors'][] = 'default_image must be a valid URL';
546 + if (!\ThinkRank\Core\Url_Validator::is_http_url($settings['default_image'])) {
547 + $validation['errors'][] = 'default_image must be an http or https URL';
520 548 $validation['valid'] = false;
521 549 } else {
522 550 // Check image dimensions and format
523 551 $image_validation = $this->validate_social_image($settings['default_image']);
@@ -755,9 +783,12 @@
755 783 }
756 784
757 785 // Default Image validation
758 786 if (!empty($settings['default_og_image'])) {
759 - if (filter_var($settings['default_og_image'], FILTER_VALIDATE_URL)) {
787 + // http(s) only, and refused rather than warned about: the
788 + // value becomes og:image and the admin preview's src, and a
789 + // javascript: URL passed is_valid().
790 + if (\ThinkRank\Core\Url_Validator::is_http_url($settings['default_og_image'])) {
760 791 $field_details[] = [
761 792 'field' => 'default_og_image',
762 793 'label' => 'Default Open Graph image is configured.',
763 794 'status' => 'valid',
@@ -765,11 +796,11 @@
765 796 ];
766 797 } else {
767 798 $field_details[] = [
768 799 'field' => 'default_og_image',
769 - 'label' => 'Default Open Graph image URL appears invalid.',
770 - 'status' => 'warning',
771 - 'icon' => '⚠'
800 + 'label' => 'Default Open Graph image must be an http or https URL.',
801 + 'status' => 'error',
802 + 'icon' => '✗'
772 803 ];
773 804 }
774 805 } else {
775 806 $field_details[] = [
@@ -889,9 +920,9 @@
889 920 }
890 921
891 922 // Default Twitter Image validation
892 923 if (!empty($settings['default_twitter_image'])) {
893 - if (filter_var($settings['default_twitter_image'], FILTER_VALIDATE_URL)) {
924 + if (\ThinkRank\Core\Url_Validator::is_http_url($settings['default_twitter_image'])) {
894 925 $field_details[] = [
895 926 'field' => 'default_twitter_image',
896 927 'label' => 'Default Twitter Card image is configured.',
897 928 'status' => 'valid',
@@ -899,11 +930,11 @@
899 930 ];
900 931 } else {
901 932 $field_details[] = [
902 933 'field' => 'default_twitter_image',
903 - 'label' => 'Default Twitter Card image URL appears invalid.',
904 - 'status' => 'warning',
905 - 'icon' => '⚠'
934 + 'label' => 'Default Twitter Card image must be an http or https URL.',
935 + 'status' => 'error',
936 + 'icon' => '✗'
906 937 ];
907 938 }
908 939 } else {
909 940 $field_details[] = [
@@ -957,8 +988,12 @@
957 988
958 989 $settings = $this->get_settings($context_type, $context_id);
959 990 $output = [
960 991 'og_tags' => [],
992 + // Secondary og:image entries. A separate list because $og_tags is
993 + // keyed by property name and so can hold exactly one 'og:image'
994 + // (#636); the emitter writes these straight after the primary.
995 + 'og_extra_images' => [],
961 996 'twitter_tags' => [],
962 997 'meta_tags' => [],
963 998 'platform_tags' => [],
964 999 // Per-feature flags so each emitter can honor its own toggle. The
@@ -992,8 +1027,18 @@
992 1027 // Add custom OG tags
993 1028 if (!empty($settings['custom_og_tags'])) {
994 1029 $output['og_tags'] = array_merge($output['og_tags'], $settings['custom_og_tags']);
995 1030 }
1031 +
1032 + // Alternatives to offer after the primary image. Collected from the
1033 + // resolved primary rather than re-deriving it, so the two can never
1034 + // disagree about which image is the main one.
1035 + if (!empty($settings['og_multiple_images'])) {
1036 + $output['og_extra_images'] = Social_Images::additional(
1037 + (int) $context_id,
1038 + (string) ($output['og_tags']['og:image'] ?? '')
1039 + );
1040 + }
996 1041 }
997 1042
998 1043 // Generate Twitter Card tags if enabled
999 1044 if ($twitter_enabled) {
@@ -1046,8 +1091,13 @@
1046 1091 */
1047 1092 private const SITE_ONLY_KEYS = [
1048 1093 'enable_open_graph',
1049 1094 'enable_twitter_cards',
1095 + // Same shape as the two above and the same trap: a site-wide switch
1096 + // with no per-context equivalent. Left out, it read as on while the
1097 + // homepage rendered and as off on every post and page — which is where
1098 + // the alternatives it controls actually come from (#636).
1099 + 'og_multiple_images',
1050 1100 ];
1051 1101
1052 1102 /**
1053 1103 * Get settings for a context, inheriting the site-wide default images
@@ -1130,8 +1180,12 @@
1130 1180 'og_locale' => '',
1131 1181 'default_og_image' => '',
1132 1182 'og_image_width' => 1200,
1133 1183 'og_image_height' => 630,
1184 + // Offer alternative og:image tags after the primary one (#636).
1185 + // Off by default: a page that shares with one image today must
1186 + // keep sharing with that image after an update.
1187 + 'og_multiple_images' => false,
1134 1188
1135 1189 // Twitter Cards settings
1136 1190 'enable_twitter_cards' => true,
1137 1191 'twitter_username' => '',
@@ -1171,8 +1225,15 @@
1171 1225 'fallback_to_excerpt' => true,
1172 1226 'strip_html_tags' => true,
1173 1227 'max_description_length' => 160,
1174 1228
1229 + // oEmbed card (#637). All three default off: this rewrites what
1230 + // other people's sites display, so an upgrade must not silently
1231 + // change a card an existing embed has been showing for months.
1232 + 'oembed_use_seo_title' => false,
1233 + 'oembed_use_social_image' => false,
1234 + 'oembed_remove_author' => false,
1235 +
1175 1236 // Legacy format for backward compatibility (only when needed)
1176 1237 'custom_og_tags' => [],
1177 1238 'image_optimization' => true,
1178 1239 'auto_generate' => true
@@ -1492,9 +1553,21 @@
1492 1553 $data['title'] = $fallback_title;
1493 1554 } else {
1494 1555 $data['title'] = $blogname;
1495 1556 }
1496 - $data['description'] = $settings['og_description'] ?? get_bloginfo('description');
1557 + // Same rule as the title above: the shipped default (the tagline)
1558 + // is not a choice, so the homepage's meta description wins over
1559 + // it. Without this an imported or hand-set homepage description
1560 + // printed as the meta description while og:/twitter:description
1561 + // kept the tagline (#897).
1562 + $og_description = (string) ($settings['og_description'] ?? '');
1563 + if ($og_description !== '' && $og_description !== get_bloginfo('description')) {
1564 + $data['description'] = $og_description;
1565 + } elseif ($fallback_description !== null && $fallback_description !== '') {
1566 + $data['description'] = $fallback_description;
1567 + } else {
1568 + $data['description'] = get_bloginfo('description');
1569 + }
1497 1570 // Use home_url('/') so og:url matches the homepage canonical
1498 1571 // (class-seo-manager.php) and the WebSite schema, which both include
1499 1572 // the trailing slash. A bare home_url() would key a different URL in
1500 1573 // social caches than the canonical.
@@ -1801,8 +1874,12 @@
1801 1874 $image_id = get_post_thumbnail_id($post);
1802 1875 if ($image_id) {
1803 1876 $image_data = wp_get_attachment_image_src($image_id, 'large');
1804 1877 if (!empty($image_data[0])) {
1878 + // The ID is in hand here and gone once this returns a
1879 + // bare URL; say so rather than have the tag builders look
1880 + // it up again, twice, with a URL core cannot match (#847).
1881 + Attachment_Lookup::remember((string) $image_data[0], (int) $image_id);
1805 1882 return $image_data[0];
1806 1883 }
1807 1884 }
1808 1885 }
@@ -1849,8 +1926,12 @@
1849 1926 $image_id = get_post_thumbnail_id($post);
1850 1927 if ($image_id) {
1851 1928 $image_data = wp_get_attachment_image_src($image_id, 'large');
1852 1929 if (!empty($image_data[0])) {
1930 + // The ID is in hand here and gone once this returns a
1931 + // bare URL; say so rather than have the tag builders look
1932 + // it up again, twice, with a URL core cannot match (#847).
1933 + Attachment_Lookup::remember((string) $image_data[0], (int) $image_id);
1853 1934 return $image_data[0];
1854 1935 }
1855 1936 }
1856 1937 }
@@ -1893,16 +1974,21 @@
1893 1974 if (function_exists('post_password_required') && post_password_required($post)) {
1894 1975 return '' !== $post->post_excerpt ? $post->post_excerpt : get_bloginfo('description');
1895 1976 }
1896 1977
1897 - // Try excerpt first
1898 - $description = get_the_excerpt($post);
1978 + // Try excerpt first. On a Bricks page core would derive that excerpt
1979 + // from the `post_content` Bricks throws away, so the visible body is
1980 + // used instead — a hand-written excerpt still wins (#651).
1981 + $superseding = Builder_Content::superseding_excerpt_source($post);
1982 + $description = '' !== $superseding
1983 + ? Pattern_Resolver::derive_excerpt($superseding, 30)
1984 + : get_the_excerpt($post);
1899 1985
1900 1986 // If no excerpt, generate from content. Shortcodes and block delimiters
1901 1987 // are removed the way core's wp_trim_excerpt() does, so a shortcode-built
1902 1988 // page does not publish its source as og:description (#387).
1903 1989 if (empty($description)) {
1904 - $description = Pattern_Resolver::derive_excerpt((string) $post->post_content, 30);
1990 + $description = Pattern_Resolver::derive_excerpt(Builder_Content::visible_content($post), 30);
1905 1991 }
1906 1992
1907 1993 // If still empty, use site description
1908 1994 if (empty($description)) {
@@ -2077,16 +2163,13 @@
2077 2163 if (mb_strlen($title) <= $max_length) {
2078 2164 return $title;
2079 2165 }
2080 2166
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) . '...';
2167 + // Truncate at a word boundary where there is one, and hard-cut where
2168 + // there is not. This used to try wp_trim_words() first, which counts
2169 + // CHARACTERS on th/ja/zh_* — so it returned ~10 characters, passed the
2170 + // $max_length check below, and that was accepted as the title (#687).
2171 + return \ThinkRank\Core\Seo_Text::trim_to_length($title, $max_length);
2089 2172 }
2090 2173
2091 2174 /**
2092 2175 * Optimize description for platform requirements
@@ -2109,16 +2192,14 @@
2109 2192 if (mb_strlen($description) <= $max_length) {
2110 2193 return $description;
2111 2194 }
2112 2195
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) . '...';
2196 + // Truncate at a word boundary where there is one, and hard-cut where
2197 + // there is not. This used to try wp_trim_words() first, which counts
2198 + // words in English but CHARACTERS in th/ja/zh_* — so on those locales
2199 + // it returned ~25 characters, comfortably under $max_length, and that
2200 + // was accepted as the answer (#687).
2201 + return \ThinkRank\Core\Seo_Text::trim_to_length($description, $max_length);
2121 2202 }
2122 2203
2123 2204 /**
2124 2205 * Optimize image for platform requirements
@@ -2143,21 +2224,23 @@
2143 2224 if (empty($image_url)) {
2144 2225 return $image_data;
2145 2226 }
2146 2227
2147 - // Get image metadata
2148 - $attachment_id = attachment_url_to_postid($image_url);
2228 + // Get image metadata. The dimensions are those of the file this URL
2229 + // names — usually a generated size — not of the original upload, so
2230 + // the width and height published beside it describe the same image.
2231 + $attachment_id = Attachment_Lookup::id_from_url($image_url);
2149 2232 if ($attachment_id) {
2150 2233 $image_meta = wp_get_attachment_metadata($attachment_id);
2151 2234 $image_alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
2152 - $mime_type = get_post_mime_type($attachment_id);
2235 + $image_file = Attachment_Lookup::describe($attachment_id, $image_url);
2153 2236
2154 2237 if ($image_meta && isset($image_meta['width'], $image_meta['height'])) {
2155 - $width = (int) $image_meta['width'];
2156 - $height = (int) $image_meta['height'];
2238 + $width = $image_file['width'];
2239 + $height = $image_file['height'];
2157 2240
2158 2241 $image_data['alt'] = $image_alt ?: '';
2159 - $image_data['type'] = $mime_type ?: '';
2242 + $image_data['type'] = $image_file['type'];
2160 2243
2161 2244 // SVGs and other vector uploads store 0x0 metadata. Dimension
2162 2245 // checks are meaningless there and dividing by 0 is fatal.
2163 2246 if ($width > 0 && $height > 0) {
@@ -2209,8 +2292,9 @@
2209 2292 $custom_logo_id = get_theme_mod('custom_logo');
2210 2293 if ($custom_logo_id) {
2211 2294 $logo_data = wp_get_attachment_image_src($custom_logo_id, 'large');
2212 2295 if ($logo_data) {
2296 + Attachment_Lookup::remember((string) $logo_data[0], (int) $custom_logo_id);
2213 2297 return $logo_data[0];
2214 2298 }
2215 2299 }
2216 2300
@@ -2218,8 +2302,9 @@
2218 2302 $site_icon_id = get_option('site_icon');
2219 2303 if ($site_icon_id) {
2220 2304 $icon_data = wp_get_attachment_image_src($site_icon_id, 'large');
2221 2305 if ($icon_data) {
2306 + Attachment_Lookup::remember((string) $icon_data[0], (int) $site_icon_id);
2222 2307 return $icon_data[0];
2223 2308 }
2224 2309 }
2225 2310
@@ -2646,12 +2731,13 @@
2646 2731 private function make_description_catchy(string $description): string {
2647 2732 // TikTok prefers short, catchy descriptions
2648 2733 $catchy_words = ['viral', 'trending', 'must-see', 'epic', 'mind-blowing'];
2649 2734
2650 - // Limit to 100 characters for TikTok
2651 - if (strlen($description) > 100) {
2652 - $description = substr($description, 0, 97) . '...';
2653 - }
2735 + // Limit to 100 characters for TikTok. strlen()/substr() count BYTES,
2736 + // so this both fired three times too early on Thai/CJK text and cut
2737 + // mid-character, emitting a broken UTF-8 sequence rather than a short
2738 + // description (#687).
2739 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description, 100);
2654 2740
2655 2741 if (!preg_match('/\b(' . implode('|', $catchy_words) . ')\b/i', $description)) {
2656 2742 $description = '🔥 ' . $description;
2657 2743 }
@@ -2969,19 +3055,20 @@
2969 3055 return $validation;
2970 3056 }
2971 3057
2972 3058 // Check if URL is valid
2973 - if (!filter_var($image_url, FILTER_VALIDATE_URL)) {
3059 + if (!\ThinkRank\Core\Url_Validator::is_http_url($image_url)) {
2974 3060 $validation['warnings'][] = 'Invalid image URL';
2975 3061 return $validation;
2976 3062 }
2977 3063
2978 3064 // Get image metadata if it's a local attachment
2979 - $attachment_id = attachment_url_to_postid($image_url);
3065 + $attachment_id = Attachment_Lookup::id_from_url($image_url);
2980 3066 if ($attachment_id) {
2981 3067 $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;
3068 + $image_file = Attachment_Lookup::describe($attachment_id, $image_url);
3069 + $meta_width = $image_file['width'];
3070 + $meta_height = $image_file['height'];
2984 3071
2985 3072 // SVGs and other vector uploads store 0x0 metadata — skip the
2986 3073 // dimension/ratio checks instead of dividing by 0.
2987 3074 if ($image_meta && $meta_width > 0 && $meta_height > 0) {