| @@ -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 | } |
| @@ -2082,16 +2163,13 @@ | ||
| 2082 | 2163 | if (mb_strlen($title) <= $max_length) { |
| 2083 | 2164 | return $title; |
| 2084 | 2165 | } |
| 2085 | 2166 | |
| 2086 | - // Truncate at word boundary | |
| 2087 | - $truncated = wp_trim_words($title, 10, ''); | |
| 2088 | - if (mb_strlen($truncated) <= $max_length) { | |
| 2089 | - return $truncated; | |
| 2090 | - } | |
| 2091 | - | |
| 2092 | - // Hard truncate if necessary | |
| 2093 | - 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); | |
| 2094 | 2172 | } |
| 2095 | 2173 | |
| 2096 | 2174 | /** |
| 2097 | 2175 | * Optimize description for platform requirements |
| @@ -2114,16 +2192,14 @@ | ||
| 2114 | 2192 | if (mb_strlen($description) <= $max_length) { |
| 2115 | 2193 | return $description; |
| 2116 | 2194 | } |
| 2117 | 2195 | |
| 2118 | - // Truncate at word boundary | |
| 2119 | - $truncated = wp_trim_words($description, 25, ''); | |
| 2120 | - if (mb_strlen($truncated) <= $max_length) { | |
| 2121 | - return $truncated; | |
| 2122 | - } | |
| 2123 | - | |
| 2124 | - // Hard truncate if necessary | |
| 2125 | - 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); | |
| 2126 | 2202 | } |
| 2127 | 2203 | |
| 2128 | 2204 | /** |
| 2129 | 2205 | * Optimize image for platform requirements |
| @@ -2148,21 +2224,23 @@ | ||
| 2148 | 2224 | if (empty($image_url)) { |
| 2149 | 2225 | return $image_data; |
| 2150 | 2226 | } |
| 2151 | 2227 | |
| 2152 | - // Get image metadata | |
| 2153 | - $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); | |
| 2154 | 2232 | if ($attachment_id) { |
| 2155 | 2233 | $image_meta = wp_get_attachment_metadata($attachment_id); |
| 2156 | 2234 | $image_alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true); |
| 2157 | - $mime_type = get_post_mime_type($attachment_id); | |
| 2235 | + $image_file = Attachment_Lookup::describe($attachment_id, $image_url); | |
| 2158 | 2236 | |
| 2159 | 2237 | if ($image_meta && isset($image_meta['width'], $image_meta['height'])) { |
| 2160 | - $width = (int) $image_meta['width']; | |
| 2161 | - $height = (int) $image_meta['height']; | |
| 2238 | + $width = $image_file['width']; | |
| 2239 | + $height = $image_file['height']; | |
| 2162 | 2240 | |
| 2163 | 2241 | $image_data['alt'] = $image_alt ?: ''; |
| 2164 | - $image_data['type'] = $mime_type ?: ''; | |
| 2242 | + $image_data['type'] = $image_file['type']; | |
| 2165 | 2243 | |
| 2166 | 2244 | // SVGs and other vector uploads store 0x0 metadata. Dimension |
| 2167 | 2245 | // checks are meaningless there and dividing by 0 is fatal. |
| 2168 | 2246 | if ($width > 0 && $height > 0) { |
| @@ -2214,8 +2292,9 @@ | ||
| 2214 | 2292 | $custom_logo_id = get_theme_mod('custom_logo'); |
| 2215 | 2293 | if ($custom_logo_id) { |
| 2216 | 2294 | $logo_data = wp_get_attachment_image_src($custom_logo_id, 'large'); |
| 2217 | 2295 | if ($logo_data) { |
| 2296 | + Attachment_Lookup::remember((string) $logo_data[0], (int) $custom_logo_id); | |
| 2218 | 2297 | return $logo_data[0]; |
| 2219 | 2298 | } |
| 2220 | 2299 | } |
| 2221 | 2300 | |
| @@ -2223,8 +2302,9 @@ | ||
| 2223 | 2302 | $site_icon_id = get_option('site_icon'); |
| 2224 | 2303 | if ($site_icon_id) { |
| 2225 | 2304 | $icon_data = wp_get_attachment_image_src($site_icon_id, 'large'); |
| 2226 | 2305 | if ($icon_data) { |
| 2306 | + Attachment_Lookup::remember((string) $icon_data[0], (int) $site_icon_id); | |
| 2227 | 2307 | return $icon_data[0]; |
| 2228 | 2308 | } |
| 2229 | 2309 | } |
| 2230 | 2310 | |
| @@ -2651,12 +2731,13 @@ | ||
| 2651 | 2731 | private function make_description_catchy(string $description): string { |
| 2652 | 2732 | // TikTok prefers short, catchy descriptions |
| 2653 | 2733 | $catchy_words = ['viral', 'trending', 'must-see', 'epic', 'mind-blowing']; |
| 2654 | 2734 | |
| 2655 | - // Limit to 100 characters for TikTok | |
| 2656 | - if (strlen($description) > 100) { | |
| 2657 | - $description = substr($description, 0, 97) . '...'; | |
| 2658 | - } | |
| 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); | |
| 2659 | 2740 | |
| 2660 | 2741 | if (!preg_match('/\b(' . implode('|', $catchy_words) . ')\b/i', $description)) { |
| 2661 | 2742 | $description = '🔥 ' . $description; |
| 2662 | 2743 | } |
| @@ -2974,19 +3055,20 @@ | ||
| 2974 | 3055 | return $validation; |
| 2975 | 3056 | } |
| 2976 | 3057 | |
| 2977 | 3058 | // Check if URL is valid |
| 2978 | - if (!filter_var($image_url, FILTER_VALIDATE_URL)) { | |
| 3059 | + if (!\ThinkRank\Core\Url_Validator::is_http_url($image_url)) { | |
| 2979 | 3060 | $validation['warnings'][] = 'Invalid image URL'; |
| 2980 | 3061 | return $validation; |
| 2981 | 3062 | } |
| 2982 | 3063 | |
| 2983 | 3064 | // Get image metadata if it's a local attachment |
| 2984 | - $attachment_id = attachment_url_to_postid($image_url); | |
| 3065 | + $attachment_id = Attachment_Lookup::id_from_url($image_url); | |
| 2985 | 3066 | if ($attachment_id) { |
| 2986 | 3067 | $image_meta = wp_get_attachment_metadata($attachment_id); |
| 2987 | - $meta_width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0; | |
| 2988 | - $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']; | |
| 2989 | 3071 | |
| 2990 | 3072 | // SVGs and other vector uploads store 0x0 metadata — skip the |
| 2991 | 3073 | // dimension/ratio checks instead of dividing by 0. |
| 2992 | 3074 | if ($image_meta && $meta_width > 0 && $meta_height > 0) { |