| @@ -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 |
| @@ -1893,16 +1934,21 @@ | ||
| 1893 | 1934 | if (function_exists('post_password_required') && post_password_required($post)) { |
| 1894 | 1935 | return '' !== $post->post_excerpt ? $post->post_excerpt : get_bloginfo('description'); |
| 1895 | 1936 | } |
| 1896 | 1937 | |
| 1897 | - // Try excerpt first | |
| 1898 | - $description = get_the_excerpt($post); | |
| 1938 | + // Try excerpt first. On a Bricks page core would derive that excerpt | |
| 1939 | + // from the `post_content` Bricks throws away, so the visible body is | |
| 1940 | + // used instead — a hand-written excerpt still wins (#651). | |
| 1941 | + $superseding = Builder_Content::superseding_excerpt_source($post); | |
| 1942 | + $description = '' !== $superseding | |
| 1943 | + ? Pattern_Resolver::derive_excerpt($superseding, 30) | |
| 1944 | + : get_the_excerpt($post); | |
| 1899 | 1945 | |
| 1900 | 1946 | // If no excerpt, generate from content. Shortcodes and block delimiters |
| 1901 | 1947 | // are removed the way core's wp_trim_excerpt() does, so a shortcode-built |
| 1902 | 1948 | // page does not publish its source as og:description (#387). |
| 1903 | 1949 | if (empty($description)) { |
| 1904 | - $description = Pattern_Resolver::derive_excerpt((string) $post->post_content, 30); | |
| 1950 | + $description = Pattern_Resolver::derive_excerpt(Builder_Content::visible_content($post), 30); | |
| 1905 | 1951 | } |
| 1906 | 1952 | |
| 1907 | 1953 | // If still empty, use site description |
| 1908 | 1954 | if (empty($description)) { |
| @@ -2077,16 +2123,13 @@ | ||
| 2077 | 2123 | if (mb_strlen($title) <= $max_length) { |
| 2078 | 2124 | return $title; |
| 2079 | 2125 | } |
| 2080 | 2126 | |
| 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) . '...'; | |
| 2127 | + // Truncate at a word boundary where there is one, and hard-cut where | |
| 2128 | + // there is not. This used to try wp_trim_words() first, which counts | |
| 2129 | + // CHARACTERS on th/ja/zh_* — so it returned ~10 characters, passed the | |
| 2130 | + // $max_length check below, and that was accepted as the title (#687). | |
| 2131 | + return \ThinkRank\Core\Seo_Text::trim_to_length($title, $max_length); | |
| 2089 | 2132 | } |
| 2090 | 2133 | |
| 2091 | 2134 | /** |
| 2092 | 2135 | * Optimize description for platform requirements |
| @@ -2109,16 +2152,14 @@ | ||
| 2109 | 2152 | if (mb_strlen($description) <= $max_length) { |
| 2110 | 2153 | return $description; |
| 2111 | 2154 | } |
| 2112 | 2155 | |
| 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) . '...'; | |
| 2156 | + // Truncate at a word boundary where there is one, and hard-cut where | |
| 2157 | + // there is not. This used to try wp_trim_words() first, which counts | |
| 2158 | + // words in English but CHARACTERS in th/ja/zh_* — so on those locales | |
| 2159 | + // it returned ~25 characters, comfortably under $max_length, and that | |
| 2160 | + // was accepted as the answer (#687). | |
| 2161 | + return \ThinkRank\Core\Seo_Text::trim_to_length($description, $max_length); | |
| 2121 | 2162 | } |
| 2122 | 2163 | |
| 2123 | 2164 | /** |
| 2124 | 2165 | * Optimize image for platform requirements |
| @@ -2646,12 +2687,13 @@ | ||
| 2646 | 2687 | private function make_description_catchy(string $description): string { |
| 2647 | 2688 | // TikTok prefers short, catchy descriptions |
| 2648 | 2689 | $catchy_words = ['viral', 'trending', 'must-see', 'epic', 'mind-blowing']; |
| 2649 | 2690 | |
| 2650 | - // Limit to 100 characters for TikTok | |
| 2651 | - if (strlen($description) > 100) { | |
| 2652 | - $description = substr($description, 0, 97) . '...'; | |
| 2653 | - } | |
| 2691 | + // Limit to 100 characters for TikTok. strlen()/substr() count BYTES, | |
| 2692 | + // so this both fired three times too early on Thai/CJK text and cut | |
| 2693 | + // mid-character, emitting a broken UTF-8 sequence rather than a short | |
| 2694 | + // description (#687). | |
| 2695 | + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description, 100); | |
| 2654 | 2696 | |
| 2655 | 2697 | if (!preg_match('/\b(' . implode('|', $catchy_words) . ')\b/i', $description)) { |
| 2656 | 2698 | $description = '🔥 ' . $description; |
| 2657 | 2699 | } |