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 +199 -86 2.0.1 → 2.14.2 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
@@ -509,12 +523,29 @@
509 523 $validation['valid'] = false;
510 524 }
511 525 }
512 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 +
513 544 // Validate default image
514 545 if (isset($settings['default_image']) && !empty($settings['default_image'])) {
515 - if (!filter_var($settings['default_image'], FILTER_VALIDATE_URL)) {
516 - $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';
517 548 $validation['valid'] = false;
518 549 } else {
519 550 // Check image dimensions and format
520 551 $image_validation = $this->validate_social_image($settings['default_image']);
@@ -752,9 +783,12 @@
752 783 }
753 784
754 785 // Default Image validation
755 786 if (!empty($settings['default_og_image'])) {
756 - 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'])) {
757 791 $field_details[] = [
758 792 'field' => 'default_og_image',
759 793 'label' => 'Default Open Graph image is configured.',
760 794 'status' => 'valid',
@@ -762,11 +796,11 @@
762 796 ];
763 797 } else {
764 798 $field_details[] = [
765 799 'field' => 'default_og_image',
766 - 'label' => 'Default Open Graph image URL appears invalid.',
767 - 'status' => 'warning',
768 - 'icon' => '⚠'
800 + 'label' => 'Default Open Graph image must be an http or https URL.',
801 + 'status' => 'error',
802 + 'icon' => '✗'
769 803 ];
770 804 }
771 805 } else {
772 806 $field_details[] = [
@@ -886,9 +920,9 @@
886 920 }
887 921
888 922 // Default Twitter Image validation
889 923 if (!empty($settings['default_twitter_image'])) {
890 - if (filter_var($settings['default_twitter_image'], FILTER_VALIDATE_URL)) {
924 + if (\ThinkRank\Core\Url_Validator::is_http_url($settings['default_twitter_image'])) {
891 925 $field_details[] = [
892 926 'field' => 'default_twitter_image',
893 927 'label' => 'Default Twitter Card image is configured.',
894 928 'status' => 'valid',
@@ -896,11 +930,11 @@
896 930 ];
897 931 } else {
898 932 $field_details[] = [
899 933 'field' => 'default_twitter_image',
900 - 'label' => 'Default Twitter Card image URL appears invalid.',
901 - 'status' => 'warning',
902 - 'icon' => '⚠'
934 + 'label' => 'Default Twitter Card image must be an http or https URL.',
935 + 'status' => 'error',
936 + 'icon' => '✗'
903 937 ];
904 938 }
905 939 } else {
906 940 $field_details[] = [
@@ -954,8 +988,12 @@
954 988
955 989 $settings = $this->get_settings($context_type, $context_id);
956 990 $output = [
957 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' => [],
958 996 'twitter_tags' => [],
959 997 'meta_tags' => [],
960 998 'platform_tags' => [],
961 999 // Per-feature flags so each emitter can honor its own toggle. The
@@ -989,8 +1027,18 @@
989 1027 // Add custom OG tags
990 1028 if (!empty($settings['custom_og_tags'])) {
991 1029 $output['og_tags'] = array_merge($output['og_tags'], $settings['custom_og_tags']);
992 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 + }
993 1041 }
994 1042
995 1043 // Generate Twitter Card tags if enabled
996 1044 if ($twitter_enabled) {
@@ -1026,8 +1074,33 @@
1026 1074 'default_image',
1027 1075 ];
1028 1076
1029 1077 /**
1078 + * Site-wide switches with no per-context equivalent.
1079 + *
1080 + * Unlike INHERITED_SITE_KEYS above, these must inherit their site value
1081 + * even when it is FALSE. The empty()-guarded loop used for images can only
1082 + * ever propagate "on", which is right for an image URL (empty means "not
1083 + * configured") and wrong for a boolean, where false is a deliberate choice.
1084 + *
1085 + * Without this the master Open Graph / Twitter Cards switches were honoured
1086 + * on the homepage (mapped to the `site` context) and ignored on every post
1087 + * and page, which read as though the toggle had worked (#557).
1088 + *
1089 + * @since 2.2.0
1090 + * @var string[]
1091 + */
1092 + private const SITE_ONLY_KEYS = [
1093 + 'enable_open_graph',
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',
1100 + ];
1101 +
1102 + /**
1030 1103 * Get settings for a context, inheriting the site-wide default images
1031 1104 *
1032 1105 * Two corrections over the generic lookup:
1033 1106 *
@@ -1060,8 +1133,18 @@
1060 1133 return $settings;
1061 1134 }
1062 1135
1063 1136 $site_settings = parent::get_settings('site', null);
1137 +
1138 + // Site-wide switches: the site value is authoritative, false included.
1139 + // array_key_exists, not empty() — '' is how a disabled toggle is stored.
1140 + foreach (self::SITE_ONLY_KEYS as $key) {
1141 + if (array_key_exists($key, $site_settings)) {
1142 + $settings[$key] = $site_settings[$key];
1143 + }
1144 + }
1145 +
1146 + // Default images: inherit only when this context has none of its own.
1064 1147 foreach (self::INHERITED_SITE_KEYS as $key) {
1065 1148 if (empty($settings[$key]) && !empty($site_settings[$key])) {
1066 1149 $settings[$key] = $site_settings[$key];
1067 1150 }
@@ -1097,8 +1180,12 @@
1097 1180 'og_locale' => '',
1098 1181 'default_og_image' => '',
1099 1182 'og_image_width' => 1200,
1100 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,
1101 1188
1102 1189 // Twitter Cards settings
1103 1190 'enable_twitter_cards' => true,
1104 1191 'twitter_username' => '',
@@ -1138,8 +1225,15 @@
1138 1225 'fallback_to_excerpt' => true,
1139 1226 'strip_html_tags' => true,
1140 1227 'max_description_length' => 160,
1141 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 +
1142 1236 // Legacy format for backward compatibility (only when needed)
1143 1237 'custom_og_tags' => [],
1144 1238 'image_optimization' => true,
1145 1239 'auto_generate' => true
@@ -1459,9 +1553,21 @@
1459 1553 $data['title'] = $fallback_title;
1460 1554 } else {
1461 1555 $data['title'] = $blogname;
1462 1556 }
1463 - $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 + }
1464 1570 // Use home_url('/') so og:url matches the homepage canonical
1465 1571 // (class-seo-manager.php) and the WebSite schema, which both include
1466 1572 // the trailing slash. A bare home_url() would key a different URL in
1467 1573 // social caches than the canonical.
@@ -1768,8 +1874,12 @@
1768 1874 $image_id = get_post_thumbnail_id($post);
1769 1875 if ($image_id) {
1770 1876 $image_data = wp_get_attachment_image_src($image_id, 'large');
1771 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);
1772 1882 return $image_data[0];
1773 1883 }
1774 1884 }
1775 1885 }
@@ -1816,8 +1926,12 @@
1816 1926 $image_id = get_post_thumbnail_id($post);
1817 1927 if ($image_id) {
1818 1928 $image_data = wp_get_attachment_image_src($image_id, 'large');
1819 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);
1820 1934 return $image_data[0];
1821 1935 }
1822 1936 }
1823 1937 }
@@ -1860,16 +1974,21 @@
1860 1974 if (function_exists('post_password_required') && post_password_required($post)) {
1861 1975 return '' !== $post->post_excerpt ? $post->post_excerpt : get_bloginfo('description');
1862 1976 }
1863 1977
1864 - // Try excerpt first
1865 - $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);
1866 1985
1867 1986 // If no excerpt, generate from content. Shortcodes and block delimiters
1868 1987 // are removed the way core's wp_trim_excerpt() does, so a shortcode-built
1869 1988 // page does not publish its source as og:description (#387).
1870 1989 if (empty($description)) {
1871 - $description = Pattern_Resolver::derive_excerpt((string) $post->post_content, 30);
1990 + $description = Pattern_Resolver::derive_excerpt(Builder_Content::visible_content($post), 30);
1872 1991 }
1873 1992
1874 1993 // If still empty, use site description
1875 1994 if (empty($description)) {
@@ -2044,16 +2163,13 @@
2044 2163 if (mb_strlen($title) <= $max_length) {
2045 2164 return $title;
2046 2165 }
2047 2166
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) . '...';
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);
2056 2172 }
2057 2173
2058 2174 /**
2059 2175 * Optimize description for platform requirements
@@ -2076,16 +2192,14 @@
2076 2192 if (mb_strlen($description) <= $max_length) {
2077 2193 return $description;
2078 2194 }
2079 2195
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) . '...';
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);
2088 2202 }
2089 2203
2090 2204 /**
2091 2205 * Optimize image for platform requirements
@@ -2110,21 +2224,23 @@
2110 2224 if (empty($image_url)) {
2111 2225 return $image_data;
2112 2226 }
2113 2227
2114 - // Get image metadata
2115 - $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);
2116 2232 if ($attachment_id) {
2117 2233 $image_meta = wp_get_attachment_metadata($attachment_id);
2118 2234 $image_alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
2119 - $mime_type = get_post_mime_type($attachment_id);
2235 + $image_file = Attachment_Lookup::describe($attachment_id, $image_url);
2120 2236
2121 2237 if ($image_meta && isset($image_meta['width'], $image_meta['height'])) {
2122 - $width = (int) $image_meta['width'];
2123 - $height = (int) $image_meta['height'];
2238 + $width = $image_file['width'];
2239 + $height = $image_file['height'];
2124 2240
2125 2241 $image_data['alt'] = $image_alt ?: '';
2126 - $image_data['type'] = $mime_type ?: '';
2242 + $image_data['type'] = $image_file['type'];
2127 2243
2128 2244 // SVGs and other vector uploads store 0x0 metadata. Dimension
2129 2245 // checks are meaningless there and dividing by 0 is fatal.
2130 2246 if ($width > 0 && $height > 0) {
@@ -2176,8 +2292,9 @@
2176 2292 $custom_logo_id = get_theme_mod('custom_logo');
2177 2293 if ($custom_logo_id) {
2178 2294 $logo_data = wp_get_attachment_image_src($custom_logo_id, 'large');
2179 2295 if ($logo_data) {
2296 + Attachment_Lookup::remember((string) $logo_data[0], (int) $custom_logo_id);
2180 2297 return $logo_data[0];
2181 2298 }
2182 2299 }
2183 2300
@@ -2185,8 +2302,9 @@
2185 2302 $site_icon_id = get_option('site_icon');
2186 2303 if ($site_icon_id) {
2187 2304 $icon_data = wp_get_attachment_image_src($site_icon_id, 'large');
2188 2305 if ($icon_data) {
2306 + Attachment_Lookup::remember((string) $icon_data[0], (int) $site_icon_id);
2189 2307 return $icon_data[0];
2190 2308 }
2191 2309 }
2192 2310
@@ -2613,12 +2731,13 @@
2613 2731 private function make_description_catchy(string $description): string {
2614 2732 // TikTok prefers short, catchy descriptions
2615 2733 $catchy_words = ['viral', 'trending', 'must-see', 'epic', 'mind-blowing'];
2616 2734
2617 - // Limit to 100 characters for TikTok
2618 - if (strlen($description) > 100) {
2619 - $description = substr($description, 0, 97) . '...';
2620 - }
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);
2621 2740
2622 2741 if (!preg_match('/\b(' . implode('|', $catchy_words) . ')\b/i', $description)) {
2623 2742 $description = '🔥 ' . $description;
2624 2743 }
@@ -2811,8 +2930,16 @@
2811 2930 private function generate_platform_meta_tags(array $settings): array {
2812 2931 $platform_tags = [];
2813 2932
2814 2933 $core = \ThinkRank\Core\Settings::instance();
2934 +
2935 + // One query for all seven instead of one query each. They are
2936 + // autoload=off like every thinkrank_* option, so WordPress cannot
2937 + // batch them out of `alloptions`, and this runs on every anonymous
2938 + // front-end request — on most sites to discover that all seven are
2939 + // empty and no tag is emitted at all (#393).
2940 + $core->prime(array_keys(self::PLATFORM_META_KEYS));
2941 +
2815 2942 // Core Settings (decrypted for sensitive keys) wins; the table value is a
2816 2943 // backward-compat fallback for installs that saved these before the UI
2817 2944 // moved to the Social Platforms tab.
2818 2945 $resolve = static function (string $key) use ($core, $settings): string {
@@ -2822,53 +2949,38 @@
2822 2949 }
2823 2950 return $value;
2824 2951 };
2825 2952
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 - }
2953 + foreach (self::PLATFORM_META_KEYS as $key => $meta_name) {
2954 + $value = $resolve($key);
2831 2955
2832 - $facebook_admins = $resolve('facebook_admins');
2833 - if ('' !== $facebook_admins) {
2834 - $platform_tags['fb:admins'] = $facebook_admins;
2956 + if ('' !== $value) {
2957 + $platform_tags[$meta_name] = $value;
2958 + }
2835 2959 }
2836 2960
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 2961 return $platform_tags;
2868 2962 }
2869 2963
2870 2964 /**
2965 + * Platform verification settings, mapped to the meta name each is emitted
2966 + * under. One list so the batch primed in generate_platform_meta_tags() and
2967 + * the keys it then reads cannot drift apart.
2968 + *
2969 + * @since 2.1.0
2970 + * @var array<string,string>
2971 + */
2972 + private const PLATFORM_META_KEYS = [
2973 + 'facebook_app_id' => 'fb:app_id',
2974 + 'facebook_admins' => 'fb:admins',
2975 + 'pinterest_site_verification' => 'pinterest-site-verification',
2976 + 'instagram_verification' => 'instagram-site-verification',
2977 + 'tiktok_verification' => 'tiktok-site-verification',
2978 + 'youtube_channel_id' => 'youtube-channel-id',
2979 + 'whatsapp_business_id' => 'whatsapp-business-id',
2980 + ];
2981 +
2982 + /**
2871 2983 * Convert OG, Twitter, and Platform tags to HTML meta tags
2872 2984 *
2873 2985 * @since 1.0.0
2874 2986 *
@@ -2943,19 +3055,20 @@
2943 3055 return $validation;
2944 3056 }
2945 3057
2946 3058 // Check if URL is valid
2947 - if (!filter_var($image_url, FILTER_VALIDATE_URL)) {
3059 + if (!\ThinkRank\Core\Url_Validator::is_http_url($image_url)) {
2948 3060 $validation['warnings'][] = 'Invalid image URL';
2949 3061 return $validation;
2950 3062 }
2951 3063
2952 3064 // Get image metadata if it's a local attachment
2953 - $attachment_id = attachment_url_to_postid($image_url);
3065 + $attachment_id = Attachment_Lookup::id_from_url($image_url);
2954 3066 if ($attachment_id) {
2955 3067 $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;
3068 + $image_file = Attachment_Lookup::describe($attachment_id, $image_url);
3069 + $meta_width = $image_file['width'];
3070 + $meta_height = $image_file['height'];
2958 3071
2959 3072 // SVGs and other vector uploads store 0x0 metadata — skip the
2960 3073 // dimension/ratio checks instead of dividing by 0.
2961 3074 if ($image_meta && $meta_width > 0 && $meta_height > 0) {