PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.11.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.11.0
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 1.27.0 1.26.0 All 55 releases
← All changes | includes/seo/class-social-meta-manager.php +983 -223 1.0.0 → 2.11.0 View file →
@@ -14,8 +14,13 @@
14 14 declare(strict_types=1);
15 15
16 16 namespace ThinkRank\SEO;
17 17
18 +// Prevent direct access
19 +if (!defined('ABSPATH')) {
20 + exit;
21 +}
22 +
18 23 /**
19 24 * Social Meta Manager Class
20 25 *
21 26 * Generates and validates social media meta tags for all supported platforms.
@@ -69,9 +74,12 @@
69 74 'image_min_width' => 600,
70 75 'image_min_height' => 900,
71 76 'image_recommended_ratio' => 0.67, // 2:3 ratio
72 77 'title_max_length' => 100,
73 - '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
74 82 ],
75 83 'whatsapp' => [
76 84 'og_required' => ['og:title', 'og:type', 'og:image', 'og:url'],
77 85 'og_recommended' => ['og:description'],
@@ -125,20 +133,48 @@
125 133
126 134 $platform_spec = $this->supported_platforms[$platform];
127 135 $og_tags = [];
128 136
129 - // Determine OG type based on context
130 - $og_type = $this->determine_og_type($context, $data);
137 + // OG type: the type the user explicitly chose, otherwise derived from
138 + // the context. This used to call determine_og_type() unconditionally,
139 + // overwriting the value extract_social_content_data() had already read
140 + // from the setting — so the Content Type dropdown, the abilities API
141 + // and every imported og:type were silently discarded (#398).
142 + $og_type = ($data['type'] ?? '') !== ''
143 + ? $data['type']
144 + : $this->determine_og_type($context, $data);
131 145 $og_tags['og:type'] = $og_type;
132 146
133 147 // Required OG tags
134 - $og_tags['og:title'] = $this->optimize_title_for_platform($data['title'] ?? '', $platform);
135 - $og_tags['og:url'] = $data['url'] ?? $this->get_current_url();
148 + // og:title must render the full resolved Open Graph title. The 60-char
149 + // cap in optimize_title_for_platform() is an SEO-title recommendation for
150 + // search results and does not apply to the og:title social tag, so use the
151 + // resolved title verbatim (falling back to the site name when empty).
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 + );
157 + // `?:` rather than `??`: the key is always present, seeded as '', so the
158 + // null-coalesce could never reach the fallback. og:url was emitted empty
159 + // and then dropped by the !empty() guard in output_social_og_tags(),
160 + // which is why archives carried no og:url at all (#388).
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 + );
136 167
137 168 // Image handling with optimization
138 169 if (!empty($data['image'])) {
139 170 $optimized_image = $this->optimize_image_for_platform($data['image'], $platform);
140 171 $og_tags['og:image'] = $optimized_image['url'];
172 + // Emit og:image:secure_url for https images (parity with the basic
173 + // emitter), so crawlers that prefer the secure URL get it.
174 + if (!empty($optimized_image['url']) && strpos((string) $optimized_image['url'], 'https://') === 0) {
175 + $og_tags['og:image:secure_url'] = $optimized_image['url'];
176 + }
141 177 if (!empty($optimized_image['width'])) {
142 178 $og_tags['og:image:width'] = $optimized_image['width'];
143 179 }
144 180 if (!empty($optimized_image['height'])) {
@@ -190,19 +226,33 @@
190 226 // Determine card type based on content
191 227 $card_type = $this->determine_twitter_card_type($data, $context);
192 228 $twitter_tags['twitter:card'] = $card_type;
193 229
194 - // Required tags
195 - $twitter_tags['twitter:title'] = $this->optimize_title_for_platform($data['title'] ?? '', 'twitter');
230 + // Required tags. Prefer a per-post Twitter-specific title, falling back
231 + // to the resolved og:title/SEO title. Render it in full — the length cap
232 + // in optimize_title_for_platform() is an SEO-title recommendation for
233 + // search results, not a rule for the twitter:title social tag.
234 + $twitter_title = ($data['twitter_title'] ?? '') !== '' ? $data['twitter_title'] : ($data['title'] ?? '');
235 + $twitter_tags['twitter:title'] = \ThinkRank\Frontend\SEO_Manager::strip_title_tags(
236 + $twitter_title !== '' ? (string) $twitter_title : (string) get_bloginfo('name')
237 + );
196 238
197 - // Recommended tags
198 - if (!empty($data['description'])) {
199 - $twitter_tags['twitter:description'] = $this->optimize_description_for_platform($data['description'], 'twitter');
239 + // Recommended tags. Prefer a per-object Twitter-specific description,
240 + // falling back to the resolved OG/meta description — mirroring the
241 + // twitter:title cascade above. This lookup did not exist, so a Twitter
242 + // description saved on the Social tab persisted, read back, and was
243 + // then dropped in favour of the OG description (#406).
244 + $twitter_description = ($data['twitter_description'] ?? '') !== ''
245 + ? $data['twitter_description']
246 + : (string) ($data['description'] ?? '');
247 + if ($twitter_description !== '') {
248 + $twitter_tags['twitter:description'] = $this->optimize_description_for_platform($twitter_description, 'twitter');
200 249 }
201 250
202 - // Image handling
203 - if (!empty($data['image'])) {
204 - $optimized_image = $this->optimize_image_for_platform($data['image'], 'twitter');
251 + // Image handling - prioritize Twitter-specific image
252 + $twitter_image = !empty($data['twitter_image']) ? $data['twitter_image'] : ($data['image'] ?? '');
253 + if (!empty($twitter_image)) {
254 + $optimized_image = $this->optimize_image_for_platform($twitter_image, 'twitter');
205 255 $twitter_tags['twitter:image'] = $optimized_image['url'];
206 256 if (!empty($optimized_image['alt'])) {
207 257 $twitter_tags['twitter:image:alt'] = $optimized_image['alt'];
208 258 }
@@ -327,9 +377,8 @@
327 377 // Ensure score doesn't go below 0
328 378 $validation['score'] = max(0, $validation['score']);
329 379
330 380
331 -
332 381 // Validate general enabled setting (Social Media tab format)
333 382 if (isset($settings['enabled'])) {
334 383 // Convert string booleans to actual booleans
335 384 if (is_string($settings['enabled'])) {
@@ -512,43 +561,93 @@
512 561 }
513 562 }
514 563 }
515 564
516 - // Validate platform-specific settings
517 -
518 - // Validate Instagram verification
519 - if (isset($settings['instagram_verification']) && !empty($settings['instagram_verification'])) {
520 - if (!preg_match('/^[a-zA-Z0-9_-]{20,}$/', $settings['instagram_verification'])) {
521 - $validation['errors'][] = 'instagram_verification must be at least 20 characters (letters, numbers, underscore, dash)';
522 - $validation['valid'] = false;
565 + // Validate platform verification codes / IDs (Instagram, TikTok, YouTube,
566 + // WhatsApp, Facebook App ID, Pinterest) against the shared format rules.
567 + // Single source of truth — also enforced on the Social Platforms REST save
568 + // path via self::validate_platform_field(). See get_platform_field_validation_rules().
569 + foreach (self::get_platform_field_validation_rules() as $field_key => $rule) {
570 + if (isset($settings[$field_key]) && $settings[$field_key] !== '') {
571 + $error = self::validate_platform_field($field_key, $settings[$field_key]);
572 + if ($error !== null) {
573 + $validation['errors'][] = $error;
574 + $validation['valid'] = false;
575 + }
523 576 }
524 577 }
525 578
526 - // Validate TikTok verification
527 - if (isset($settings['tiktok_verification']) && !empty($settings['tiktok_verification'])) {
528 - if (!preg_match('/^[a-zA-Z0-9_-]{20,}$/', $settings['tiktok_verification'])) {
529 - $validation['errors'][] = 'tiktok_verification must be at least 20 characters (letters, numbers, underscore, dash)';
530 - $validation['valid'] = false;
531 - }
532 - }
579 + return $validation;
580 + }
533 581
534 - // Validate YouTube Channel ID
535 - if (isset($settings['youtube_channel_id']) && !empty($settings['youtube_channel_id'])) {
536 - if (!preg_match('/^UC[a-zA-Z0-9_-]{22}$/', $settings['youtube_channel_id'])) {
537 - $validation['errors'][] = 'youtube_channel_id must start with "UC" followed by 22 characters';
538 - $validation['valid'] = false;
539 - }
582 + /**
583 + * Format-validation rules for social platform verification codes / IDs.
584 + *
585 + * Single source of truth for the per-field format constraints, shared by
586 + * validate_settings() and the Social Platforms REST endpoint
587 + * (ThinkRank\API\Social_Platforms_Endpoint) so a value that is accepted on
588 + * one path is accepted on the other. These mirror the `pattern` entries in
589 + * get_settings_schema(); sanitization strips unsafe characters but does not
590 + * enforce shape, so these rules back it with real validation.
591 + *
592 + * @since 1.14.0
593 + *
594 + * @return array<string, array{pattern: string, message: string}> Map of field key => rule.
595 + */
596 + public static function get_platform_field_validation_rules(): array {
597 + return [
598 + 'facebook_app_id' => [
599 + 'pattern' => '/^[0-9]+$/',
600 + 'message' => 'Facebook App ID must contain digits only.',
601 + ],
602 + 'pinterest_site_verification' => [
603 + 'pattern' => '/^[a-f0-9]{32}$/',
604 + 'message' => 'Pinterest site verification must be a 32-character hexadecimal string.',
605 + ],
606 + 'instagram_verification' => [
607 + 'pattern' => '/^[a-zA-Z0-9_-]{20,}$/',
608 + 'message' => 'Instagram verification must be at least 20 characters (letters, numbers, underscore, dash).',
609 + ],
610 + 'tiktok_verification' => [
611 + 'pattern' => '/^[a-zA-Z0-9_-]{20,}$/',
612 + 'message' => 'TikTok verification must be at least 20 characters (letters, numbers, underscore, dash).',
613 + ],
614 + 'youtube_channel_id' => [
615 + 'pattern' => '/^UC[a-zA-Z0-9_-]{22}$/',
616 + 'message' => 'YouTube channel ID must start with "UC" followed by 22 characters (letters, numbers, underscore, dash).',
617 + ],
618 + 'whatsapp_business_id' => [
619 + 'pattern' => '/^[0-9]{10,15}$/',
620 + 'message' => 'WhatsApp Business ID must be 10-15 digits.',
621 + ],
622 + ];
623 + }
624 +
625 + /**
626 + * Validate a single social platform field value against its shared format rule.
627 + *
628 + * Returns null for fields with no format rule (e.g. facebook_admins) and for
629 + * empty values, so callers can validate an arbitrary settings map and only
630 + * act on genuine format violations.
631 + *
632 + * @since 1.14.0
633 + *
634 + * @param string $key Field key.
635 + * @param mixed $value Field value.
636 + * @return string|null Error message when the value violates the format, null otherwise.
637 + */
638 + public static function validate_platform_field(string $key, $value): ?string {
639 + $rules = self::get_platform_field_validation_rules();
640 +
641 + if (!isset($rules[$key]) || $value === '' || $value === null) {
642 + return null;
540 643 }
541 644
542 - // Validate WhatsApp Business ID
543 - if (isset($settings['whatsapp_business_id']) && !empty($settings['whatsapp_business_id'])) {
544 - if (!preg_match('/^[0-9]{10,15}$/', $settings['whatsapp_business_id'])) {
545 - $validation['errors'][] = 'whatsapp_business_id must be 10-15 digits';
546 - $validation['valid'] = false;
547 - }
645 + if (!preg_match($rules[$key]['pattern'], (string) $value)) {
646 + return $rules[$key]['message'];
548 647 }
549 648
550 - return $validation;
649 + return null;
551 650 }
552 651
553 652 /**
554 653 * Get detailed field validation breakdown
@@ -783,9 +882,9 @@
783 882 }
784 883
785 884 // Card Type validation
786 885 $valid_card_types = ['summary', 'summary_large_image', 'app', 'player'];
787 - if (!empty($settings['twitter_card_type']) && in_array($settings['twitter_card_type'], $valid_card_types)) {
886 + if (!empty($settings['twitter_card_type']) && in_array($settings['twitter_card_type'], $valid_card_types, true)) {
788 887 $field_details[] = [
789 888 'field' => 'twitter_card_type',
790 889 'label' => 'Twitter Card type is properly configured.',
791 890 'status' => 'valid',
@@ -838,32 +937,62 @@
838 937 return $field_details;
839 938 }
840 939
841 940
842 -
843 941 /**
844 942 * Get output data for frontend rendering (implements interface)
845 943 *
846 944 * @since 1.0.0
847 945 *
848 - * @param string $context_type The context type
849 - * @param int|null $context_id Optional. Context ID
946 + * @param string $context_type The context type
947 + * @param int|null $context_id Optional. Context ID
948 + * @param string|null $fallback_title Optional. Effective SEO title to
949 + * use when no per-post Open Graph
950 + * title override is set, so
951 + * rendered output mirrors the
952 + * document title and the Social
953 + * metabox preview.
954 + * @param string|null $fallback_description Optional. Effective meta
955 + * description, used as the
956 + * Open Graph description fallback
957 + * for the same parity reason.
850 958 * @return array Output data ready for frontend rendering
851 959 */
852 - public function get_output_data(string $context_type, ?int $context_id): array {
960 + public function get_output_data(string $context_type, ?int $context_id, ?string $fallback_title = null, ?string $fallback_description = null): array {
961 + // Memoize per request: the OG, Twitter and platform wp_head callbacks
962 + // each call this with the same arguments, so the extraction work +
963 + // Site_Identity_Manager instantiation would otherwise run three times.
964 + $cache_key = $context_type . ':' . ($context_id ?? 0) . ':' . md5((string) $fallback_title . '|' . (string) $fallback_description);
965 + if (isset($this->output_data_cache[$cache_key])) {
966 + return $this->output_data_cache[$cache_key];
967 + }
968 +
853 969 $settings = $this->get_settings($context_type, $context_id);
854 970 $output = [
855 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' => [],
856 976 'twitter_tags' => [],
857 977 'meta_tags' => [],
858 - 'enabled' => false
978 + 'platform_tags' => [],
979 + // Per-feature flags so each emitter can honor its own toggle. The
980 + // aggregate `enabled` (true if either is on) is kept for callers
981 + // that only care whether social output ran at all.
982 + 'og_enabled' => false,
983 + 'twitter_enabled' => false,
984 + 'enabled' => false,
859 985 ];
860 986
861 987 // Check if individual social features are enabled
862 - $og_enabled = $settings['enable_open_graph'] ?? $settings['og_enabled'] ?? false;
863 - $twitter_enabled = $settings['enable_twitter_cards'] ?? $settings['twitter_enabled'] ?? false;
988 + $og_enabled = !empty($settings['enable_open_graph'] ?? $settings['og_enabled'] ?? false);
989 + $twitter_enabled = !empty($settings['enable_twitter_cards'] ?? $settings['twitter_enabled'] ?? false);
990 + $output['og_enabled'] = $og_enabled;
991 + $output['twitter_enabled'] = $twitter_enabled;
864 992
865 - if (empty($og_enabled) && empty($twitter_enabled)) {
993 + if (!$og_enabled && !$twitter_enabled) {
994 + $this->output_data_cache[$cache_key] = $output;
866 995 return $output;
867 996 }
868 997
869 998 $output['enabled'] = true;
@@ -868,13 +997,12 @@
868 997
869 998 $output['enabled'] = true;
870 999
871 1000 // Extract content data
872 - $content_data = $this->extract_social_content_data($context_type, $context_id, $settings);
1001 + $content_data = $this->extract_social_content_data($context_type, $context_id, $settings, $fallback_title, $fallback_description);
873 1002
874 1003 // Generate Open Graph tags if enabled
875 - $og_enabled = $settings['enable_open_graph'] ?? $settings['og_enabled'] ?? false;
876 - if (!empty($og_enabled)) {
1004 + if ($og_enabled) {
877 1005 $output['og_tags'] = $this->generate_og_tags($content_data, $context_type);
878 1006
879 1007 // Add custom OG tags
880 1008 if (!empty($settings['custom_og_tags'])) {
@@ -879,13 +1007,22 @@
879 1007 // Add custom OG tags
880 1008 if (!empty($settings['custom_og_tags'])) {
881 1009 $output['og_tags'] = array_merge($output['og_tags'], $settings['custom_og_tags']);
882 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 + }
883 1021 }
884 1022
885 1023 // Generate Twitter Card tags if enabled
886 - $twitter_enabled = $settings['enable_twitter_cards'] ?? $settings['twitter_enabled'] ?? false;
887 - if (!empty($twitter_enabled)) {
1024 + if ($twitter_enabled) {
888 1025 $output['twitter_tags'] = $this->generate_twitter_tags($content_data, $context_type);
889 1026 }
890 1027
891 1028 // Generate platform-specific meta tags
@@ -893,12 +1030,111 @@
893 1030
894 1031 // Convert to meta tag format for HTML output
895 1032 $output['meta_tags'] = $this->convert_to_meta_tags($output['og_tags'], $output['twitter_tags'], $output['platform_tags']);
896 1033
1034 + $this->output_data_cache[$cache_key] = $output;
897 1035 return $output;
898 1036 }
899 1037
900 1038 /**
1039 + * Request-scoped memoization of get_output_data() keyed by context + title/desc.
1040 + *
1041 + * @var array<string, array>
1042 + */
1043 + private array $output_data_cache = [];
1044 +
1045 + /**
1046 + * Site-wide default image keys inherited by every other context.
1047 + *
1048 + * @since 1.24.1
1049 + * @var string[]
1050 + */
1051 + private const INHERITED_SITE_KEYS = [
1052 + 'default_og_image',
1053 + 'default_twitter_image',
1054 + 'default_image',
1055 + ];
1056 +
1057 + /**
1058 + * Site-wide switches with no per-context equivalent.
1059 + *
1060 + * Unlike INHERITED_SITE_KEYS above, these must inherit their site value
1061 + * even when it is FALSE. The empty()-guarded loop used for images can only
1062 + * ever propagate "on", which is right for an image URL (empty means "not
1063 + * configured") and wrong for a boolean, where false is a deliberate choice.
1064 + *
1065 + * Without this the master Open Graph / Twitter Cards switches were honoured
1066 + * on the homepage (mapped to the `site` context) and ignored on every post
1067 + * and page, which read as though the toggle had worked (#557).
1068 + *
1069 + * @since 2.2.0
1070 + * @var string[]
1071 + */
1072 + private const SITE_ONLY_KEYS = [
1073 + 'enable_open_graph',
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',
1080 + ];
1081 +
1082 + /**
1083 + * Get settings for a context, inheriting the site-wide default images
1084 + *
1085 + * Two corrections over the generic lookup:
1086 + *
1087 + * 1. Site-wide settings are always stored with context_id 0, but the
1088 + * frontend maps a homepage request to the `site` context while still
1089 + * passing the queried object ID (non-zero on a static front page). That
1090 + * looked up `site`/<page ID>, matched no row and silently fell back to
1091 + * the schema defaults, so a configured default OG image never rendered
1092 + * on a static front page. Normalize the ID away for `site`.
1093 + * 2. `default_og_image` / `default_twitter_image` only exist in the site
1094 + * context, so post/page and archive contexts had nothing to fall back to
1095 + * when a post had no featured image — og:image dropped to the site logo
1096 + * or vanished entirely. Inherit those keys when the context has no value
1097 + * of its own.
1098 + *
1099 + * @since 1.24.1
1100 + *
1101 + * @param string $context_type The context type
1102 + * @param int|null $context_id Optional. Context ID
1103 + * @return array Settings with site-wide image defaults applied
1104 + */
1105 + public function get_settings(string $context_type, ?int $context_id = null): array {
1106 + if ($context_type === 'site') {
1107 + $context_id = null;
1108 + }
1109 +
1110 + $settings = parent::get_settings($context_type, $context_id);
1111 +
1112 + if ($context_type === 'site') {
1113 + return $settings;
1114 + }
1115 +
1116 + $site_settings = parent::get_settings('site', null);
1117 +
1118 + // Site-wide switches: the site value is authoritative, false included.
1119 + // array_key_exists, not empty() — '' is how a disabled toggle is stored.
1120 + foreach (self::SITE_ONLY_KEYS as $key) {
1121 + if (array_key_exists($key, $site_settings)) {
1122 + $settings[$key] = $site_settings[$key];
1123 + }
1124 + }
1125 +
1126 + // Default images: inherit only when this context has none of its own.
1127 + foreach (self::INHERITED_SITE_KEYS as $key) {
1128 + if (empty($settings[$key]) && !empty($site_settings[$key])) {
1129 + $settings[$key] = $site_settings[$key];
1130 + }
1131 + }
1132 +
1133 + return $settings;
1134 + }
1135 +
1136 + /**
901 1137 * Get default settings for a context type (implements interface)
902 1138 *
903 1139 * @since 1.0.0
904 1140 *
@@ -915,12 +1151,21 @@
915 1151 'enable_open_graph' => true,
916 1152 'og_site_name' => $site_name,
917 1153 'og_description' => $site_description,
918 1154 'og_type' => 'website',
919 - 'og_locale' => 'en_US',
1155 + // Empty by design: og:locale is resolved from the site locale via
1156 + // get_og_locale(), which is where the thinkrank_og_locale filter (and
1157 + // therefore the WPML/Polylang/TranslatePress integration) applies. A
1158 + // hardcoded default was merged into every settings read, so the
1159 + // resolver was unreachable and every install advertised en_US.
1160 + 'og_locale' => '',
920 1161 'default_og_image' => '',
921 1162 'og_image_width' => 1200,
922 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,
923 1168
924 1169 // Twitter Cards settings
925 1170 'enable_twitter_cards' => true,
926 1171 'twitter_username' => '',
@@ -960,8 +1205,15 @@
960 1205 'fallback_to_excerpt' => true,
961 1206 'strip_html_tags' => true,
962 1207 'max_description_length' => 160,
963 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 +
964 1216 // Legacy format for backward compatibility (only when needed)
965 1217 'custom_og_tags' => [],
966 1218 'image_optimization' => true,
967 1219 'auto_generate' => true
@@ -1015,20 +1267,13 @@
1015 1267 'description' => 'Default description for Open Graph tags',
1016 1268 'maxLength' => 160,
1017 1269 'default' => get_bloginfo('description')
1018 1270 ],
1019 - 'og_type' => [
1020 - 'type' => 'string',
1021 - 'title' => 'Open Graph Type',
1022 - 'description' => 'The type of content for Open Graph',
1023 - 'enum' => ['website', 'article', 'book', 'profile'],
1024 - 'default' => 'website'
1025 - ],
1026 1271 'og_locale' => [
1027 1272 'type' => 'string',
1028 1273 'title' => 'Locale',
1029 - 'description' => 'The locale for Open Graph tags',
1030 - 'default' => 'en_US'
1274 + 'description' => 'Optional override for og:locale. Leave empty to follow the site language.',
1275 + 'default' => ''
1031 1276 ],
1032 1277 'default_og_image' => [
1033 1278 'type' => 'string',
1034 1279 'title' => 'Default Open Graph Image',
@@ -1245,15 +1490,22 @@
1245 1490 * @param int|null $context_id Optional. Context ID
1246 1491 * @param array $settings Social meta settings
1247 1492 * @return array Extracted content data
1248 1493 */
1249 - private function extract_social_content_data(string $context_type, ?int $context_id, array $settings): array {
1494 + private function extract_social_content_data(string $context_type, ?int $context_id, array $settings, ?string $fallback_title = null, ?string $fallback_description = null): array {
1250 1495 $data = [
1251 1496 'title' => '',
1252 1497 'description' => '',
1253 1498 'url' => '',
1254 1499 'image' => '',
1255 - 'type' => 'website',
1500 + 'twitter_title' => '', // Separate field for a Twitter-specific title
1501 + 'twitter_description' => '', // Separate field for a Twitter-specific description
1502 + 'twitter_image' => '', // Separate field for Twitter-specific images
1503 + // '' rather than 'website' means "derive it from the context".
1504 + // Seeding a concrete type here made an explicit choice
1505 + // indistinguishable from the shipped default (#398).
1506 + 'type' => '',
1507 + 'twitter_card_type' => '',
1256 1508 'author' => [],
1257 1509 'published_time' => '',
1258 1510 'modified_time' => '',
1259 1511 'site_name' => $settings['og_site_name'] ?? get_bloginfo('name')
@@ -1258,30 +1510,131 @@
1258 1510 'modified_time' => '',
1259 1511 'site_name' => $settings['og_site_name'] ?? get_bloginfo('name')
1260 1512 ];
1261 1513
1514 + // Explicit type choices, resolved once for whichever context this is.
1515 + $data['type'] = $this->configured_og_type($settings);
1516 + $data['twitter_card_type'] = $this->configured_twitter_card_type($settings);
1517 +
1262 1518 if ($context_type === 'site') {
1263 - // Site-wide data with Social Media tab settings priority
1264 - $data['title'] = $settings['og_site_name'] ?? get_bloginfo('name');
1519 + // Site-wide data with Social Media tab settings priority.
1520 + //
1521 + // og:title priority: an explicitly-configured OG Site Name wins;
1522 + // otherwise mirror the effective SEO <title> (what search shows),
1523 + // then the blogname. og_site_name is always present because it is
1524 + // merged from a default equal to the blogname, so a value matching
1525 + // the blogname is treated as "not explicitly overridden" and we fall
1526 + // through to the passed effective SEO title. (og:site_name itself is
1527 + // set separately from og_site_name and is unaffected.)
1528 + $blogname = get_bloginfo('name');
1529 + $og_site_name = $settings['og_site_name'] ?? '';
1530 + if ($og_site_name !== '' && $og_site_name !== $blogname) {
1531 + $data['title'] = $og_site_name;
1532 + } elseif ($fallback_title !== null && $fallback_title !== '') {
1533 + $data['title'] = $fallback_title;
1534 + } else {
1535 + $data['title'] = $blogname;
1536 + }
1265 1537 $data['description'] = $settings['og_description'] ?? get_bloginfo('description');
1266 - $data['url'] = home_url();
1267 - $data['type'] = $settings['og_type'] ?? 'website';
1268 - $data['locale'] = $settings['og_locale'] ?? null;
1538 + // Use home_url('/') so og:url matches the homepage canonical
1539 + // (class-seo-manager.php) and the WebSite schema, which both include
1540 + // the trailing slash. A bare home_url() would key a different URL in
1541 + // social caches than the canonical.
1542 + //
1543 + // Except when this is not the site home. detect_current_context()
1544 + // collapses is_home() && !is_front_page() into 'homepage', so a
1545 + // static posts page — and page 2 of any blog listing — advertised
1546 + // the site home as its og:url while its own canonical said
1547 + // otherwise (#397).
1548 + $data['url'] = self::current_home_url();
1269 1549
1270 - // Use configured images if available
1271 - if (!empty($settings['default_og_image'])) {
1272 - $data['image'] = $settings['default_og_image'];
1273 - }
1550 + // Leaving this null lets generate_og_tags() fall through to
1551 + // get_og_locale(), which is what every other context already does.
1552 + //
1553 + // A stored 'en_US' is deliberately treated as "not set". It was the
1554 + // hardcoded default on every install and there has never been a UI
1555 + // control for this field, so it cannot represent a deliberate choice
1556 + // — it is the old default persisted by an unrelated save of the
1557 + // Social Media tab. Honouring it would leave every already-saved
1558 + // site broken after this fix. Any other stored value is a genuine
1559 + // override and still wins; a site that really wants to force en_US
1560 + // can do so through the thinkrank_og_locale filter.
1561 + $stored_locale = trim((string) ($settings['og_locale'] ?? ''));
1562 + $data['locale'] = ('' !== $stored_locale && 'en_US' !== $stored_locale)
1563 + ? $stored_locale
1564 + : null;
1565 +
1566 + // Set Open Graph image with proper fallback
1567 + $data['image'] = $this->get_og_image_for_context($settings, null);
1568 +
1569 + // Set Twitter image with proper fallback
1570 + $data['twitter_image'] = $this->get_twitter_image_for_context($settings, null);
1274 1571 } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'], true)) {
1275 1572 // Post-specific data
1276 1573 $post = get_post($context_id);
1277 1574 if ($post) {
1278 - $data['title'] = get_the_title($post);
1279 - $data['description'] = $this->get_social_description($post);
1280 - $data['url'] = get_permalink($post);
1281 - $data['type'] = $settings['og_type'] ?? $this->determine_og_type($context_type, []);
1575 + // Per-post Open Graph overrides from the metabox Social tab take
1576 + // precedence over the derived title/description/image. These read
1577 + // the same meta keys the metabox save handler and the import
1578 + // migrator write to, so manual edits and migrated data flow
1579 + // through one path. Title/description may hold variable tags
1580 + // (e.g. %title%), resolved here to match output_basic_og_tags().
1581 + $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value(
1582 + (string) get_post_meta($post->ID, '_thinkrank_og_title', true),
1583 + $post->ID
1584 + );
1585 + $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value(
1586 + (string) get_post_meta($post->ID, '_thinkrank_og_description', true),
1587 + $post->ID
1588 + );
1589 + $og_image_override = get_post_meta($post->ID, '_thinkrank_og_image', true);
1590 +
1591 + // Per-post Twitter-specific title override (metabox Social tab).
1592 + // Twitter Cards fall back to the og:title when this is empty, so
1593 + // only capture it here; the fallback is applied in
1594 + // generate_twitter_tags().
1595 + $data['twitter_title'] = \ThinkRank\SEO\Pattern_Resolver::resolve_value(
1596 + (string) get_post_meta($post->ID, '_thinkrank_twitter_title', true),
1597 + $post->ID
1598 + );
1599 +
1600 + // Per-post Twitter-specific description. twitter:description
1601 + // falls back to the OG/meta description when this is empty, so
1602 + // only capture it here; generate_twitter_tags() applies the
1603 + // fallback. There was no counterpart to the title lookup above,
1604 + // so the stored value never entered $data at all (#406).
1605 + $data['twitter_description'] = \ThinkRank\SEO\Pattern_Resolver::resolve_value(
1606 + (string) get_post_meta($post->ID, '_thinkrank_twitter_description', true),
1607 + $post->ID
1608 + );
1609 +
1610 + // Title priority: per-post OG override > effective SEO title
1611 + // (document <title> / metabox preview) > post title.
1612 + if ($og_title_override !== '') {
1613 + $data['title'] = $og_title_override;
1614 + } elseif ($fallback_title !== null && $fallback_title !== '') {
1615 + $data['title'] = $fallback_title;
1616 + } else {
1617 + $data['title'] = get_the_title($post);
1618 + }
1619 + // Description priority: per-post OG override > effective meta
1620 + // description (meta tag / metabox preview) > derived excerpt.
1621 + if ($og_description_override !== '') {
1622 + $data['description'] = $og_description_override;
1623 + } elseif ($fallback_description !== null && $fallback_description !== '') {
1624 + $data['description'] = $fallback_description;
1625 + } else {
1626 + $data['description'] = $this->get_social_description($post);
1627 + }
1628 + // The canonical carries the <!--nextpage--> sub-page and the
1629 + // comment page; og:url said page 1 regardless, which is the
1630 + // same contradiction #397 fixed for the blog listing, one page
1631 + // type over (#397 review).
1632 + $data['url'] = self::with_singular_page((string) get_permalink($post));
1633 +
1282 1634 $data['published_time'] = get_the_date('c', $post);
1283 1635 $data['modified_time'] = get_the_modified_date('c', $post);
1636 + $data['post_id'] = $post->ID;
1284 1637
1285 1638 // Author data
1286 1639 $author_id = $post->post_author;
1287 1640 $data['author'] = [
@@ -1289,17 +1642,110 @@
1289 1642 'url' => get_author_posts_url($author_id),
1290 1643 'twitter' => get_the_author_meta('twitter', $author_id)
1291 1644 ];
1292 1645
1293 - // Featured image or default
1294 - $image_id = get_post_thumbnail_id($post);
1295 - if ($image_id) {
1296 - $image_data = wp_get_attachment_image_src($image_id, 'large');
1297 - $data['image'] = $image_data[0] ?? '';
1298 - } elseif (!empty($settings['default_image'])) {
1299 - $data['image'] = $settings['default_image'];
1646 + // Set Open Graph image: per-post override first, then the
1647 + // featured-image/default fallback chain.
1648 + $data['image'] = $og_image_override !== ''
1649 + ? $og_image_override
1650 + : $this->get_og_image_for_context($settings, $post);
1651 +
1652 + // Set Twitter image with proper fallback
1653 + $data['twitter_image'] = $this->get_twitter_image_for_context($settings, $post);
1654 + }
1655 + } else {
1656 + // Archive-style contexts (category, tag, author, search, date).
1657 + // They carry no object of their own, but the site-wide default
1658 + // image still applies — without this they fell through to the raw
1659 + // logo/site-icon fallback in generate_og_tags() and ignored a
1660 + // configured default OG image.
1661 + $data['image'] = $this->get_og_image_for_context($settings, null);
1662 + $data['twitter_image'] = $this->get_twitter_image_for_context($settings, null);
1663 +
1664 + // A term archive owns SEO values of its own, and the caller has
1665 + // already resolved them into the fallbacks — the same strings
1666 + // rendered as the document <title> and the description tag. Leaving
1667 + // title and description empty here published the bare site name as
1668 + // og:title on every archive and no og:description at all, so a term
1669 + // SEO title never reached a social surface (#386).
1670 + // Archives have a URL of their own. The seed leaves it '', and the
1671 + // og:url emitter could not fall through to get_current_url() while
1672 + // the key existed, so no archive carried an og:url at all (#388).
1673 + // A search archive's URL is the search link, not the bare request
1674 + // path — get_current_url() reads $wp->request, which is empty for a
1675 + // search served from the front page, so og:url pointed at the site
1676 + // home while the page was a search result.
1677 + // The non-search archive URL is the page's own canonical, so og:url
1678 + // and <link rel="canonical"> agree on the paginated page rather than
1679 + // both claiming page 1 (#397).
1680 + // `?:` keeps the #388 guarantee that an archive always carries an
1681 + // og:url: get_non_singular_canonical_url() returns '' for a view it
1682 + // has no canonical rule for, and the request URL is still better
1683 + // than no tag at all.
1684 + $data['url'] = is_search()
1685 + ? get_search_link()
1686 + : (self::current_archive_url() ?: $this->get_current_url());
1687 +
1688 + $term = get_queried_object();
1689 + $term_id = ($term instanceof \WP_Term) ? (int) $term->term_id : 0;
1690 +
1691 + $og_title_override = '';
1692 + $og_description_override = '';
1693 +
1694 + if ($term_id > 0) {
1695 + // Terms carry the same social override keys as posts — the
1696 + // abilities API and the metabox both write them.
1697 + $og_title_override = Pattern_Resolver::resolve_term_value(
1698 + (string) get_term_meta($term_id, '_thinkrank_og_title', true),
1699 + $term_id
1700 + );
1701 + $og_description_override = Pattern_Resolver::resolve_term_value(
1702 + (string) get_term_meta($term_id, '_thinkrank_og_description', true),
1703 + $term_id
1704 + );
1705 +
1706 + // Twitter Cards fall back to og:title when this is empty, so
1707 + // only capture it; generate_twitter_tags() applies the fallback.
1708 + $data['twitter_title'] = Pattern_Resolver::resolve_term_value(
1709 + (string) get_term_meta($term_id, '_thinkrank_twitter_title', true),
1710 + $term_id
1711 + );
1712 + $data['twitter_description'] = Pattern_Resolver::resolve_term_value(
1713 + (string) get_term_meta($term_id, '_thinkrank_twitter_description', true),
1714 + $term_id
1715 + );
1716 +
1717 + $term_og_image = (string) get_term_meta($term_id, '_thinkrank_og_image', true);
1718 + if ('' !== $term_og_image) {
1719 + $data['image'] = $term_og_image;
1300 1720 }
1721 +
1722 + $term_twitter_image = (string) get_term_meta($term_id, '_thinkrank_twitter_image', true);
1723 + if ('' !== $term_twitter_image) {
1724 + $data['twitter_image'] = $term_twitter_image;
1725 + }
1301 1726 }
1727 +
1728 + // Author, date and search archives have no ThinkRank-managed title
1729 + // to inherit — Author_Archives_Manager owns the author one, and the
1730 + // rest have no template — so the caller's fallback arrives empty and
1731 + // og:title used to collapse to the bare site name. Fall through to
1732 + // what the page itself is called (#388).
1733 + if ($og_title_override !== '') {
1734 + $data['title'] = $og_title_override;
1735 + } elseif ($fallback_title !== null && $fallback_title !== '') {
1736 + $data['title'] = $fallback_title;
1737 + } else {
1738 + $data['title'] = $this->archive_fallback_title();
1739 + }
1740 +
1741 + if ($og_description_override !== '') {
1742 + $data['description'] = $og_description_override;
1743 + } elseif ($fallback_description !== null && $fallback_description !== '') {
1744 + $data['description'] = $fallback_description;
1745 + } else {
1746 + $data['description'] = $this->archive_fallback_description($term_id);
1747 + }
1302 1748 }
1303 1749
1304 1750 return $data;
1305 1751 }
@@ -1304,8 +1750,172 @@
1304 1750 return $data;
1305 1751 }
1306 1752
1307 1753 /**
1754 + * The canonical URL of the archive currently being rendered.
1755 + *
1756 + * Deliberately the same value class-seo-manager.php puts in
1757 + * <link rel="canonical">: an og:url that disagrees with the canonical is
1758 + * the bug this is fixing, so the two read from one source.
1759 + *
1760 + * @since 2.0.1
1761 + *
1762 + * @return string Archive URL, '' when there is none (search, 404).
1763 + */
1764 + private static function current_archive_url(): string {
1765 + if (!class_exists('\ThinkRank\Frontend\SEO_Manager')) {
1766 + return '';
1767 + }
1768 +
1769 + return \ThinkRank\Frontend\SEO_Manager::get_non_singular_canonical_url();
1770 + }
1771 +
1772 + /**
1773 + * A permalink with the current sub-page or comment page appended.
1774 + *
1775 + * @since 2.0.1
1776 + *
1777 + * @param string $url Permalink.
1778 + * @return string
1779 + */
1780 + private static function with_singular_page(string $url): string {
1781 + if ('' === $url || !class_exists('\ThinkRank\Frontend\SEO_Manager')) {
1782 + return $url;
1783 + }
1784 +
1785 + return \ThinkRank\Frontend\SEO_Manager::with_singular_page($url);
1786 + }
1787 +
1788 + /**
1789 + * The URL of the page the 'site' context is actually being rendered for.
1790 + *
1791 + * home_url('/') for the front page, the posts page's own permalink when the
1792 + * site uses a static front page, and the paginated variant on page 2+.
1793 + *
1794 + * @since 2.0.1
1795 + *
1796 + * @return string
1797 + */
1798 + private static function current_home_url(): string {
1799 + $url = home_url('/');
1800 +
1801 + if (function_exists('is_home') && is_home() && !is_front_page()) {
1802 + $posts_page = (int) get_option('page_for_posts');
1803 +
1804 + if ($posts_page > 0) {
1805 + $permalink = get_permalink($posts_page);
1806 +
1807 + if (is_string($permalink) && '' !== $permalink) {
1808 + $url = $permalink;
1809 + }
1810 + }
1811 + }
1812 +
1813 + if (!class_exists('\ThinkRank\Frontend\SEO_Manager')) {
1814 + return $url;
1815 + }
1816 +
1817 + // A static front page is a singular view, so its page number lives in
1818 + // `page`, not `paged` — the canonical already reads it that way, and
1819 + // og:url has to agree or the two describe different URLs.
1820 + if (function_exists('is_singular') && is_singular()) {
1821 + return \ThinkRank\Frontend\SEO_Manager::with_singular_page($url);
1822 + }
1823 +
1824 + return \ThinkRank\Frontend\SEO_Manager::with_pagination(
1825 + $url,
1826 + \ThinkRank\Frontend\SEO_Manager::current_page_number()
1827 + );
1828 + }
1829 +
1830 + /**
1831 + * Get Open Graph image for context with proper fallback
1832 + *
1833 + * @since 1.0.0
1834 + *
1835 + * @param array $settings Social meta settings
1836 + * @param \WP_Post|null $post Optional. Post object for post-specific images
1837 + * @return string Image URL or empty string
1838 + */
1839 + private function get_og_image_for_context(array $settings, ?\WP_Post $post = null): string {
1840 + // For posts, check featured image first
1841 + if ($post) {
1842 + $image_id = get_post_thumbnail_id($post);
1843 + if ($image_id) {
1844 + $image_data = wp_get_attachment_image_src($image_id, 'large');
1845 + if (!empty($image_data[0])) {
1846 + return $image_data[0];
1847 + }
1848 + }
1849 + }
1850 +
1851 + // Fallback to configured Open Graph image
1852 + if (!empty($settings['default_og_image'])) {
1853 + return $settings['default_og_image'];
1854 + }
1855 +
1856 + // Final fallback to generic default image
1857 + if (!empty($settings['default_image'])) {
1858 + return $settings['default_image'];
1859 + }
1860 +
1861 + // Last-resort fallback to the site logo / site icon. Resolving it here
1862 + // (rather than late inside generate_og_tags()) writes it back to the
1863 + // shared $data['image'], so generate_twitter_tags() and
1864 + // determine_twitter_card_type() see the same image: twitter:image is
1865 + // emitted and the card is promoted to summary_large_image, and the
1866 + // image is routed through optimize_image_for_platform() so
1867 + // og:image:width/height/type/alt companions are produced.
1868 + return $this->get_default_social_image();
1869 + }
1870 +
1871 + /**
1872 + * Get Twitter image for context with proper fallback
1873 + *
1874 + * @since 1.0.0
1875 + *
1876 + * @param array $settings Social meta settings
1877 + * @param \WP_Post|null $post Optional. Post object for post-specific images
1878 + * @return string Image URL or empty string
1879 + */
1880 + private function get_twitter_image_for_context(array $settings, ?\WP_Post $post = null): string {
1881 + // For posts, check for post-specific Twitter image meta first (if implemented)
1882 + if ($post) {
1883 + // Check for post-specific Twitter image meta (future enhancement)
1884 + $post_twitter_image = get_post_meta($post->ID, '_thinkrank_twitter_image', true);
1885 + if (!empty($post_twitter_image)) {
1886 + return $post_twitter_image;
1887 + }
1888 +
1889 + // Check featured image as fallback for posts
1890 + $image_id = get_post_thumbnail_id($post);
1891 + if ($image_id) {
1892 + $image_data = wp_get_attachment_image_src($image_id, 'large');
1893 + if (!empty($image_data[0])) {
1894 + return $image_data[0];
1895 + }
1896 + }
1897 + }
1898 +
1899 + // Prioritize Twitter-specific default image
1900 + if (!empty($settings['default_twitter_image'])) {
1901 + return $settings['default_twitter_image'];
1902 + }
1903 +
1904 + // Fallback to Open Graph default image
1905 + if (!empty($settings['default_og_image'])) {
1906 + return $settings['default_og_image'];
1907 + }
1908 +
1909 + // Final fallback to generic default image
1910 + if (!empty($settings['default_image'])) {
1911 + return $settings['default_image'];
1912 + }
1913 +
1914 + return '';
1915 + }
1916 +
1917 + /**
1308 1918 * Get social media description for post
1309 1919 *
1310 1920 * @since 1.0.0
1311 1921 *
@@ -1312,15 +1922,33 @@
1312 1922 * @param \WP_Post $post Post object
1313 1923 * @return string Social media description
1314 1924 */
1315 1925 private function get_social_description(\WP_Post $post): string {
1316 - // Try excerpt first
1317 - $description = get_the_excerpt($post);
1926 + // A password-gated body must never become a social description. Core
1927 + // answers get_the_excerpt() with its "There is no excerpt because this
1928 + // is a protected post." placeholder rather than the body, so today the
1929 + // derive-from-content fallback below is unreachable here — but it is one
1930 + // core change away from leaking, and that placeholder sentence is not a
1931 + // description worth publishing to every crawler and unfurler either.
1932 + // An authored post_excerpt is written for public consumption, so it
1933 + // still stands (#363).
1934 + if (function_exists('post_password_required') && post_password_required($post)) {
1935 + return '' !== $post->post_excerpt ? $post->post_excerpt : get_bloginfo('description');
1936 + }
1318 1937
1319 - // If no excerpt, generate from content
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);
1945 +
1946 + // If no excerpt, generate from content. Shortcodes and block delimiters
1947 + // are removed the way core's wp_trim_excerpt() does, so a shortcode-built
1948 + // page does not publish its source as og:description (#387).
1320 1949 if (empty($description)) {
1321 - $content = wp_strip_all_tags($post->post_content);
1322 - $description = wp_trim_words($content, 30, '...');
1950 + $description = Pattern_Resolver::derive_excerpt(Builder_Content::visible_content($post), 30);
1323 1951 }
1324 1952
1325 1953 // If still empty, use site description
1326 1954 if (empty($description)) {
@@ -1345,18 +1973,8 @@
1345 1973 return 'article';
1346 1974 case 'product':
1347 1975 return 'product';
1348 1976 case 'page':
1349 - // Check if it's a video or music page
1350 - if (!empty($data['content'])) {
1351 - if (preg_match('/\b(video|youtube|vimeo)\b/i', $data['content'])) {
1352 - return 'video';
1353 - }
1354 - if (preg_match('/\b(music|song|album|artist)\b/i', $data['content'])) {
1355 - return 'music';
1356 - }
1357 - }
1358 - return 'website';
1359 1977 case 'site':
1360 1978 default:
1361 1979 return 'website';
1362 1980 }
@@ -1371,23 +1989,121 @@
1371 1989 * @param string $context Context type
1372 1990 * @return string Twitter Card type
1373 1991 */
1374 1992 private function determine_twitter_card_type(array $data, string $context): string {
1375 - // Check for video content
1376 - if (!empty($data['content']) && preg_match('/\b(video|youtube|vimeo)\b/i', $data['content'])) {
1377 - return 'player';
1993 + // An explicitly chosen card type wins. Without this the setting was
1994 + // inert and the `app` and `player` options in the UI could never be
1995 + // emitted at all (#398).
1996 + $chosen = (string) ($data['twitter_card_type'] ?? '');
1997 + if ('' !== $chosen) {
1998 + return $chosen;
1378 1999 }
1379 2000
1380 - // Check for app content
1381 - if (!empty($data['content']) && preg_match('/\b(app|download|install)\b/i', $data['content'])) {
1382 - return 'app';
2001 + // Use a large-image card when a Twitter image will actually be emitted.
2002 + // twitter:image resolves to the twitter-specific image first, then the OG
2003 + // image, so key the card type off the same precedence.
2004 + $twitter_image = !empty($data['twitter_image']) ? $data['twitter_image'] : ($data['image'] ?? '');
2005 + return !empty($twitter_image) ? 'summary_large_image' : 'summary';
2006 + }
2007 +
2008 + /**
2009 + * What an archive calls itself, for the og:title of last resort.
2010 + *
2011 + * Deliberately not wp_get_document_title(): that re-enters the
2012 + * pre_get_document_title filter this plugin short-circuits, so it would
2013 + * recurse. get_the_archive_title() wraps its subject in a <span>, hence the
2014 + * strip.
2015 + *
2016 + * @since 2.0.1
2017 + *
2018 + * @return string Archive title, or '' when there is nothing sensible to say.
2019 + */
2020 + private function archive_fallback_title(): string {
2021 + if (is_search()) {
2022 + /* translators: %s: search query. */
2023 + return trim(sprintf(__('Search Results for "%s"', 'thinkrank'), get_search_query()));
1383 2024 }
1384 2025
1385 - // Use large image if image is available, otherwise summary
1386 - return !empty($data['image']) ? 'summary_large_image' : 'summary';
2026 + if (!function_exists('get_the_archive_title')) {
2027 + return '';
2028 + }
2029 +
2030 + return trim(wp_strip_all_tags((string) get_the_archive_title()));
1387 2031 }
1388 2032
1389 2033 /**
2034 + * What an archive says about itself, for the og:description of last resort.
2035 + *
2036 + * @since 2.0.1
2037 + *
2038 + * @param int $term_id Queried term, or 0 when the archive is not a term.
2039 + * @return string Archive description, or '' when there is none.
2040 + */
2041 + private function archive_fallback_description(int $term_id): string {
2042 + if ($term_id > 0) {
2043 + $description = trim(wp_strip_all_tags((string) term_description($term_id)));
2044 +
2045 + if ('' !== $description) {
2046 + return $description;
2047 + }
2048 + }
2049 +
2050 + if (is_author()) {
2051 + $bio = trim(wp_strip_all_tags((string) get_the_author_meta('description', (int) get_query_var('author'))));
2052 +
2053 + if ('' !== $bio) {
2054 + return $bio;
2055 + }
2056 + }
2057 +
2058 + return '';
2059 + }
2060 +
2061 + /**
2062 + * The Open Graph type the user explicitly chose, or '' when they did not.
2063 + *
2064 + * `og_type` ships a default of 'website' that is merged into every settings
2065 + * read, so a stored 'website' cannot be told apart from "never touched" —
2066 + * the same trap the `og_locale` note above documents. Treating it as unset
2067 + * keeps determine_og_type() reachable, so a post is still `article`, while
2068 + * any other stored value is a genuine override and wins.
2069 + *
2070 + * @since 2.0.1
2071 + *
2072 + * @param array $settings Social meta settings.
2073 + * @return string The chosen type, or '' for "derive it".
2074 + */
2075 + private function configured_og_type(array $settings): string {
2076 + $type = trim((string) ($settings['og_type'] ?? ''));
2077 +
2078 + return ('' === $type || 'website' === $type) ? '' : $type;
2079 + }
2080 +
2081 + /**
2082 + * The Twitter card type the user explicitly chose, or '' when they did not.
2083 + *
2084 + * Same reasoning as configured_og_type(): the shipped default is
2085 + * 'summary_large_image', which is also what the automatic rule produces
2086 + * whenever an image is available, so it is treated as "not chosen" and the
2087 + * automatic rule stays in charge. `summary`, `app` and `player` are real
2088 + * choices and are honoured.
2089 + *
2090 + * @since 2.0.1
2091 + *
2092 + * @param array $settings Social meta settings.
2093 + * @return string The chosen card type, or '' for "derive it".
2094 + */
2095 + private function configured_twitter_card_type(array $settings): string {
2096 + $type = trim((string) ($settings['twitter_card_type'] ?? ''));
2097 +
2098 + if (!in_array($type, ['summary', 'app', 'player'], true)) {
2099 + return '';
2100 + }
2101 +
2102 + return $type;
2103 + }
2104 +
2105 + /**
1390 2106 * Optimize title for platform requirements
1391 2107 *
1392 2108 * @since 1.0.0
1393 2109 *
@@ -1401,20 +2117,19 @@
1401 2117 }
1402 2118
1403 2119 $max_length = $this->supported_platforms[$platform]['title_max_length'] ?? 60;
1404 2120
1405 - if (strlen($title) <= $max_length) {
2121 + // Multibyte-safe: byte-based substr() would split characters in
2122 + // CJK/Bengali/accented titles (WP ships an mbstring fallback).
2123 + if (mb_strlen($title) <= $max_length) {
1406 2124 return $title;
1407 2125 }
1408 2126
1409 - // Truncate at word boundary
1410 - $truncated = wp_trim_words($title, 10, '');
1411 - if (strlen($truncated) <= $max_length) {
1412 - return $truncated;
1413 - }
1414 -
1415 - // Hard truncate if necessary
1416 - return 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);
1417 2132 }
1418 2133
1419 2134 /**
1420 2135 * Optimize description for platform requirements
@@ -1431,20 +2146,20 @@
1431 2146 }
1432 2147
1433 2148 $max_length = $this->supported_platforms[$platform]['description_max_length'] ?? 160;
1434 2149
1435 - if (strlen($description) <= $max_length) {
2150 + // Multibyte-safe: byte-based substr() would split characters in
2151 + // CJK/Bengali/accented descriptions (WP ships an mbstring fallback).
2152 + if (mb_strlen($description) <= $max_length) {
1436 2153 return $description;
1437 2154 }
1438 2155
1439 - // Truncate at word boundary
1440 - $truncated = wp_trim_words($description, 25, '');
1441 - if (strlen($truncated) <= $max_length) {
1442 - return $truncated;
1443 - }
1444 -
1445 - // Hard truncate if necessary
1446 - return 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);
1447 2162 }
1448 2163
1449 2164 /**
1450 2165 * Optimize image for platform requirements
@@ -1477,37 +2192,45 @@
1477 2192 $image_alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
1478 2193 $mime_type = get_post_mime_type($attachment_id);
1479 2194
1480 2195 if ($image_meta && isset($image_meta['width'], $image_meta['height'])) {
1481 - $image_data['width'] = $image_meta['width'];
1482 - $image_data['height'] = $image_meta['height'];
2196 + $width = (int) $image_meta['width'];
2197 + $height = (int) $image_meta['height'];
2198 +
1483 2199 $image_data['alt'] = $image_alt ?: '';
1484 2200 $image_data['type'] = $mime_type ?: '';
1485 2201
1486 - // Validate against platform requirements
1487 - $platform_spec = $this->supported_platforms[$platform] ?? [];
1488 - $min_width = $platform_spec['image_min_width'] ?? 300;
1489 - $min_height = $platform_spec['image_min_height'] ?? 200;
2202 + // SVGs and other vector uploads store 0x0 metadata. Dimension
2203 + // checks are meaningless there and dividing by 0 is fatal.
2204 + if ($width > 0 && $height > 0) {
2205 + $image_data['width'] = $width;
2206 + $image_data['height'] = $height;
1490 2207
1491 - if ($image_meta['width'] >= $min_width && $image_meta['height'] >= $min_height) {
1492 - $image_data['valid'] = true;
1493 - } else {
1494 - $image_data['warnings'][] = "Image dimensions ({$image_meta['width']}x{$image_meta['height']}) are below recommended minimum ({$min_width}x{$min_height}) for {$platform}";
1495 - }
2208 + // Validate against platform requirements
2209 + $platform_spec = $this->supported_platforms[$platform] ?? [];
2210 + $min_width = $platform_spec['image_min_width'] ?? 300;
2211 + $min_height = $platform_spec['image_min_height'] ?? 200;
1496 2212
1497 - // Check aspect ratio if specified
1498 - if (isset($platform_spec['image_recommended_ratio'])) {
1499 - $actual_ratio = $image_meta['width'] / $image_meta['height'];
1500 - $recommended_ratio = $platform_spec['image_recommended_ratio'];
1501 - $ratio_tolerance = 0.1;
2213 + if ($width >= $min_width && $height >= $min_height) {
2214 + $image_data['valid'] = true;
2215 + } else {
2216 + $image_data['warnings'][] = "Image dimensions ({$width}x{$height}) are below recommended minimum ({$min_width}x{$min_height}) for {$platform}";
2217 + }
1502 2218
1503 - if (abs($actual_ratio - $recommended_ratio) > $ratio_tolerance) {
1504 - $image_data['warnings'][] = sprintf(
1505 - "Image aspect ratio (%.2f) differs from recommended ratio (%.2f) for %s",
1506 - $actual_ratio,
1507 - $recommended_ratio,
1508 - $platform
1509 - );
2219 + // Check aspect ratio if specified
2220 + if (isset($platform_spec['image_recommended_ratio'])) {
2221 + $actual_ratio = $width / $height;
2222 + $recommended_ratio = $platform_spec['image_recommended_ratio'];
2223 + $ratio_tolerance = 0.1;
2224 +
2225 + if (abs($actual_ratio - $recommended_ratio) > $ratio_tolerance) {
2226 + $image_data['warnings'][] = sprintf(
2227 + "Image aspect ratio (%.2f) differs from recommended ratio (%.2f) for %s",
2228 + $actual_ratio,
2229 + $recommended_ratio,
2230 + $platform
2231 + );
2232 + }
1510 2233 }
1511 2234 }
1512 2235 }
1513 2236 }
@@ -1551,26 +2274,45 @@
1551 2274 *
1552 2275 * @return string OG locale
1553 2276 */
1554 2277 private function get_og_locale(): string {
1555 - $locale = get_locale();
2278 + /**
2279 + * Filter the locale used for og:locale.
2280 + *
2281 + * Defaults to get_locale(), which only tracks the active language once
2282 + * that language's translation files are installed — on a multilingual
2283 + * site without them every translated URL still reports the default
2284 + * locale. The multilingual integration answers with the locale its
2285 + * provider reports for the language actually being viewed.
2286 + *
2287 + * @since 1.23.0
2288 + *
2289 + * @param string $locale Locale for the current request.
2290 + */
2291 + $locale = (string) apply_filters('thinkrank_og_locale', get_locale());
1556 2292
1557 - // Convert WordPress locale to OG locale format
2293 + // Convert WordPress locale to OG locale format for the few that differ
2294 + // from the xx_YY form (e.g. bare 'ja').
1558 2295 $og_locale_map = [
1559 - 'en_US' => 'en_US',
1560 - 'en_GB' => 'en_GB',
1561 - 'es_ES' => 'es_ES',
1562 - 'fr_FR' => 'fr_FR',
1563 - 'de_DE' => 'de_DE',
1564 - 'it_IT' => 'it_IT',
1565 - 'pt_BR' => 'pt_BR',
1566 - 'ru_RU' => 'ru_RU',
1567 2296 'ja' => 'ja_JP',
1568 - 'zh_CN' => 'zh_CN',
1569 - 'ko_KR' => 'ko_KR'
1570 2297 ];
1571 2298
1572 - return $og_locale_map[$locale] ?? 'en_US';
2299 + if (isset($og_locale_map[$locale])) {
2300 + return $og_locale_map[$locale];
2301 + }
2302 +
2303 + // Fall back to the actual site locale (normalized to xx_YY) rather than
2304 + // mislabeling every unmapped language as en_US.
2305 + if (preg_match('/^[a-z]{2,3}_[A-Z]{2}$/', $locale)) {
2306 + return $locale;
2307 + }
2308 +
2309 + // Bare language code (e.g. 'nl') → best-effort xx_XX.
2310 + if (preg_match('/^([a-z]{2,3})$/', $locale, $m)) {
2311 + return $m[1] . '_' . strtoupper($m[1]);
2312 + }
2313 +
2314 + return 'en_US';
1573 2315 }
1574 2316
1575 2317 /**
1576 2318 * Get current URL
@@ -1648,8 +2390,16 @@
1648 2390 }
1649 2391 if (!empty($data['modified_time'])) {
1650 2392 $og_tags['article:modified_time'] = $data['modified_time'];
1651 2393 }
2394 + // article:section — the post's primary category (parity with the
2395 + // basic emitter, which the active path previously omitted).
2396 + if (!empty($data['post_id'])) {
2397 + $categories = get_the_category((int) $data['post_id']);
2398 + if (!empty($categories) && !is_wp_error($categories)) {
2399 + $og_tags['article:section'] = $categories[0]->name;
2400 + }
2401 + }
1652 2402 break;
1653 2403 case 'product':
1654 2404 // Product-specific tags would be added here
1655 2405 // This could be extended with price, availability, etc.
@@ -1836,9 +2586,9 @@
1836 2586 if (isset($og_tags['og:description'])) {
1837 2587 $og_tags['og:description'] = $this->make_description_descriptive($og_tags['og:description']);
1838 2588 }
1839 2589 // Pinterest prefers article type for rich pins
1840 - if (in_array($og_tags['og:type'], ['website', 'blog'])) {
2590 + if (in_array($og_tags['og:type'], ['website', 'blog'], true)) {
1841 2591 $og_tags['og:type'] = 'article';
1842 2592 }
1843 2593 }
1844 2594 break;
@@ -1937,12 +2687,13 @@
1937 2687 private function make_description_catchy(string $description): string {
1938 2688 // TikTok prefers short, catchy descriptions
1939 2689 $catchy_words = ['viral', 'trending', 'must-see', 'epic', 'mind-blowing'];
1940 2690
1941 - // Limit to 100 characters for TikTok
1942 - if (strlen($description) > 100) {
1943 - $description = substr($description, 0, 97) . '...';
1944 - }
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);
1945 2696
1946 2697 if (!preg_match('/\b(' . implode('|', $catchy_words) . ')\b/i', $description)) {
1947 2698 $description = '🔥 ' . $description;
1948 2699 }
@@ -1961,29 +2712,13 @@
1961 2712 * @param string $context Context type
1962 2713 * @return array Updated Twitter tags
1963 2714 */
1964 2715 private function add_twitter_card_specific_tags(array $twitter_tags, string $card_type, array $data, string $context): array {
1965 - switch ($card_type) {
1966 - case 'player':
1967 - if (!empty($data['video_url'])) {
1968 - $twitter_tags['twitter:player'] = $data['video_url'];
1969 - $twitter_tags['twitter:player:width'] = $data['video_width'] ?? '1280';
1970 - $twitter_tags['twitter:player:height'] = $data['video_height'] ?? '720';
1971 - }
1972 - break;
1973 - case 'app':
1974 - if (!empty($data['app_name'])) {
1975 - $twitter_tags['twitter:app:name:iphone'] = $data['app_name'];
1976 - $twitter_tags['twitter:app:name:android'] = $data['app_name'];
1977 - }
1978 - if (!empty($data['app_id'])) {
1979 - $twitter_tags['twitter:app:id:iphone'] = $data['app_id'];
1980 - $twitter_tags['twitter:app:id:android'] = $data['app_id'];
1981 - }
1982 - break;
1983 - }
1984 -
1985 - return $twitter_tags;
2716 + // The content extractor only produces summary / summary_large_image
2717 + // cards, so the player/app card variants were dead code that read keys
2718 + // (video_url, app_name, …) the extractor never sets. Kept as a filterable
2719 + // extension point for add-ons that do populate richer card data.
2720 + return apply_filters('thinkrank_twitter_card_specific_tags', $twitter_tags, $card_type, $data, $context);
1986 2721 }
1987 2722
1988 2723 /**
1989 2724 * Generate Open Graph preview
@@ -2134,54 +2869,74 @@
2134 2869
2135 2870 /**
2136 2871 * Generate platform-specific meta tags
2137 2872 *
2873 + * Platform IDs and verification codes are owned by the Social Platforms tab
2874 + * (ThinkRank\API\Social_Platforms_Endpoint), which stores them in core
2875 + * Settings (wp_options) with the sensitive codes encrypted at rest. The
2876 + * social_meta settings table this manager normally reads no longer receives
2877 + * these values from the UI, so we resolve each key from core Settings first
2878 + * and fall back to any legacy value still present in the passed-in table
2879 + * settings — otherwise the verification tags would never render.
2880 + *
2138 2881 * @since 1.0.0
2139 2882 *
2140 - * @param array $settings Settings array
2883 + * @param array $settings Settings array (social_meta table) — legacy fallback.
2141 2884 * @return array Platform-specific meta tags
2142 2885 */
2143 2886 private function generate_platform_meta_tags(array $settings): array {
2144 2887 $platform_tags = [];
2145 2888
2146 - // Facebook meta tags
2147 - if (!empty($settings['facebook_app_id'])) {
2148 - $platform_tags['fb:app_id'] = $settings['facebook_app_id'];
2149 - }
2889 + $core = \ThinkRank\Core\Settings::instance();
2150 2890
2151 - if (!empty($settings['facebook_admins'])) {
2152 - $platform_tags['fb:admins'] = $settings['facebook_admins'];
2153 - }
2891 + // One query for all seven instead of one query each. They are
2892 + // autoload=off like every thinkrank_* option, so WordPress cannot
2893 + // batch them out of `alloptions`, and this runs on every anonymous
2894 + // front-end request — on most sites to discover that all seven are
2895 + // empty and no tag is emitted at all (#393).
2896 + $core->prime(array_keys(self::PLATFORM_META_KEYS));
2154 2897
2155 - // Pinterest site verification
2156 - if (!empty($settings['pinterest_site_verification'])) {
2157 - $platform_tags['pinterest-site-verification'] = $settings['pinterest_site_verification'];
2158 - }
2898 + // Core Settings (decrypted for sensitive keys) wins; the table value is a
2899 + // backward-compat fallback for installs that saved these before the UI
2900 + // moved to the Social Platforms tab.
2901 + $resolve = static function (string $key) use ($core, $settings): string {
2902 + $value = (string) $core->get($key, '');
2903 + if ('' === $value) {
2904 + $value = (string) ($settings[$key] ?? '');
2905 + }
2906 + return $value;
2907 + };
2159 2908
2160 - // Instagram verification (if enabled)
2161 - if (!empty($settings['instagram_verification'])) {
2162 - $platform_tags['instagram-site-verification'] = $settings['instagram_verification'];
2163 - }
2909 + foreach (self::PLATFORM_META_KEYS as $key => $meta_name) {
2910 + $value = $resolve($key);
2164 2911
2165 - // TikTok verification (if enabled)
2166 - if (!empty($settings['tiktok_verification'])) {
2167 - $platform_tags['tiktok-site-verification'] = $settings['tiktok_verification'];
2912 + if ('' !== $value) {
2913 + $platform_tags[$meta_name] = $value;
2914 + }
2168 2915 }
2169 2916
2170 - // YouTube channel verification (if enabled)
2171 - if (!empty($settings['youtube_channel_id'])) {
2172 - $platform_tags['youtube-channel-id'] = $settings['youtube_channel_id'];
2173 - }
2174 -
2175 - // WhatsApp Business verification (if enabled)
2176 - if (!empty($settings['whatsapp_business_id'])) {
2177 - $platform_tags['whatsapp-business-id'] = $settings['whatsapp_business_id'];
2178 - }
2179 -
2180 2917 return $platform_tags;
2181 2918 }
2182 2919
2183 2920 /**
2921 + * Platform verification settings, mapped to the meta name each is emitted
2922 + * under. One list so the batch primed in generate_platform_meta_tags() and
2923 + * the keys it then reads cannot drift apart.
2924 + *
2925 + * @since 2.1.0
2926 + * @var array<string,string>
2927 + */
2928 + private const PLATFORM_META_KEYS = [
2929 + 'facebook_app_id' => 'fb:app_id',
2930 + 'facebook_admins' => 'fb:admins',
2931 + 'pinterest_site_verification' => 'pinterest-site-verification',
2932 + 'instagram_verification' => 'instagram-site-verification',
2933 + 'tiktok_verification' => 'tiktok-site-verification',
2934 + 'youtube_channel_id' => 'youtube-channel-id',
2935 + 'whatsapp_business_id' => 'whatsapp-business-id',
2936 + ];
2937 +
2938 + /**
2184 2939 * Convert OG, Twitter, and Platform tags to HTML meta tags
2185 2940 *
2186 2941 * @since 1.0.0
2187 2942 *
@@ -2265,17 +3020,22 @@
2265 3020 // Get image metadata if it's a local attachment
2266 3021 $attachment_id = attachment_url_to_postid($image_url);
2267 3022 if ($attachment_id) {
2268 3023 $image_meta = wp_get_attachment_metadata($attachment_id);
2269 - if ($image_meta && isset($image_meta['width'], $image_meta['height'])) {
3024 + $meta_width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0;
3025 + $meta_height = isset($image_meta['height']) ? (int) $image_meta['height'] : 0;
3026 +
3027 + // SVGs and other vector uploads store 0x0 metadata — skip the
3028 + // dimension/ratio checks instead of dividing by 0.
3029 + if ($image_meta && $meta_width > 0 && $meta_height > 0) {
2270 3030 // Check minimum dimensions for major platforms
2271 3031 $min_width = 600; // Facebook minimum
2272 3032 $min_height = 315; // Facebook minimum
2273 3033
2274 - if ($image_meta['width'] >= $min_width && $image_meta['height'] >= $min_height) {
3034 + if ($meta_width >= $min_width && $meta_height >= $min_height) {
2275 3035 $validation['valid'] = true;
2276 3036 } else {
2277 - $validation['warnings'][] = "Image dimensions ({$image_meta['width']}x{$image_meta['height']}) are below recommended minimum ({$min_width}x{$min_height})";
3037 + $validation['warnings'][] = "Image dimensions ({$meta_width}x{$meta_height}) are below recommended minimum ({$min_width}x{$min_height})";
2278 3038 }
2279 3039
2280 3040 // Check file size
2281 3041 $file_path = get_attached_file($attachment_id);
@@ -2288,9 +3048,9 @@
2288 3048 }
2289 3049 }
2290 3050
2291 3051 // Check aspect ratio
2292 - $aspect_ratio = $image_meta['width'] / $image_meta['height'];
3052 + $aspect_ratio = $meta_width / $meta_height;
2293 3053 if ($aspect_ratio < 1.5 || $aspect_ratio > 2.5) {
2294 3054 $validation['suggestions'][] = 'Consider using an image with 1.91:1 aspect ratio for optimal display';
2295 3055 }
2296 3056 }