| @@ -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 | } |