[ 'og_required' => ['og:title', 'og:type', 'og:image', 'og:url'], 'og_recommended' => ['og:description', 'og:site_name', 'og:locale'], 'og_optional' => ['og:updated_time', 'og:see_also', 'og:video', 'og:audio'], 'image_min_width' => 600, 'image_min_height' => 315, 'image_recommended_ratio' => 1.91, 'title_max_length' => 60, 'description_max_length' => 160 ], 'twitter' => [ 'card_types' => ['summary', 'summary_large_image', 'app', 'player'], 'required' => ['twitter:card', 'twitter:title'], 'recommended' => ['twitter:description', 'twitter:image', 'twitter:site'], 'optional' => ['twitter:creator', 'twitter:player', 'twitter:app:name'], 'image_min_width' => 300, 'image_min_height' => 157, 'image_max_size' => 5242880, // 5MB 'title_max_length' => 70, 'description_max_length' => 200 ], 'linkedin' => [ 'og_required' => ['og:title', 'og:type', 'og:image', 'og:url'], 'og_recommended' => ['og:description'], 'image_min_width' => 1200, 'image_min_height' => 627, 'image_recommended_ratio' => 1.91, 'title_max_length' => 70, 'description_max_length' => 160 ], 'pinterest' => [ 'required' => ['og:title', 'og:type', 'og:image', 'og:url'], 'recommended' => ['og:description', 'og:site_name'], 'image_min_width' => 600, 'image_min_height' => 900, 'image_recommended_ratio' => 0.67, // 2:3 ratio 'title_max_length' => 100, // Pinterest has no tag of its own: it reads og:description, which // the frontend caps at max_description_length. Previewing 500 // promised up to 340 characters that are never emitted. 'description_max_length' => 160 ], 'whatsapp' => [ 'og_required' => ['og:title', 'og:type', 'og:image', 'og:url'], 'og_recommended' => ['og:description'], 'image_min_width' => 300, 'image_min_height' => 200, 'title_max_length' => 65, 'description_max_length' => 160 ] ]; /** * Open Graph types with their specifications * * @since 1.0.0 * @var array */ private array $og_types = [ 'website' => ['og:title', 'og:type', 'og:image', 'og:url'], 'article' => ['og:title', 'og:type', 'og:image', 'og:url', 'article:author', 'article:published_time'], 'product' => ['og:title', 'og:type', 'og:image', 'og:url', 'product:price:amount', 'product:price:currency'], 'video' => ['og:title', 'og:type', 'og:image', 'og:url', 'og:video', 'video:duration'], 'music' => ['og:title', 'og:type', 'og:image', 'og:url', 'music:duration', 'music:album'], 'book' => ['og:title', 'og:type', 'og:image', 'og:url', 'book:author', 'book:isbn'], 'profile' => ['og:title', 'og:type', 'og:image', 'og:url', 'profile:first_name', 'profile:last_name'] ]; /** * Constructor * * @since 1.0.0 */ public function __construct() { parent::__construct('social_meta'); } /** * Generate Open Graph tags for content * * @since 1.0.0 * * @param array $data Content data for OG generation * @param string $context Context type ('site', 'post', 'page', etc.) * @param string $platform Target platform ('facebook', 'linkedin', etc.) * @return array Generated Open Graph tags */ public function generate_og_tags(array $data, string $context = 'site', string $platform = 'facebook'): array { $platform = strtolower($platform); if (!isset($this->supported_platforms[$platform])) { $platform = 'facebook'; // Default fallback } $platform_spec = $this->supported_platforms[$platform]; $og_tags = []; // OG type: the type the user explicitly chose, otherwise derived from // the context. This used to call determine_og_type() unconditionally, // overwriting the value extract_social_content_data() had already read // from the setting — so the Content Type dropdown, the abilities API // and every imported og:type were silently discarded (#398). $og_type = ($data['type'] ?? '') !== '' ? $data['type'] : $this->determine_og_type($context, $data); $og_tags['og:type'] = $og_type; // Required OG tags // og:title must render the full resolved Open Graph title. The 60-char // cap in optimize_title_for_platform() is an SEO-title recommendation for // search results and does not apply to the og:title social tag, so use the // resolved title verbatim (falling back to the site name when empty). // Stripped: a title carrying markup is attribute-escaped into this tag, // so the reader sees a literal `<em>` rather than emphasis (#640). $og_tags['og:title'] = \ThinkRank\Frontend\SEO_Manager::strip_title_tags( ($data['title'] ?? '') !== '' ? (string) $data['title'] : (string) get_bloginfo('name') ); // `?:` rather than `??`: the key is always present, seeded as '', so the // null-coalesce could never reach the fallback. og:url was emitted empty // and then dropped by the !empty() guard in output_social_og_tags(), // which is why archives carried no og:url at all (#388). // Normalized for the site's scheme preference, so og:url and the // canonical can never disagree about http vs https (#638); the same // consistency #182 was about. $og_tags['og:url'] = \ThinkRank\SEO\Url_Scheme::apply( ($data['url'] ?? '') !== '' ? $data['url'] : $this->get_current_url() ); // Image handling with optimization if (!empty($data['image'])) { $optimized_image = $this->optimize_image_for_platform($data['image'], $platform); $og_tags['og:image'] = $optimized_image['url']; // Emit og:image:secure_url for https images (parity with the basic // emitter), so crawlers that prefer the secure URL get it. if (!empty($optimized_image['url']) && strpos((string) $optimized_image['url'], 'https://') === 0) { $og_tags['og:image:secure_url'] = $optimized_image['url']; } if (!empty($optimized_image['width'])) { $og_tags['og:image:width'] = $optimized_image['width']; } if (!empty($optimized_image['height'])) { $og_tags['og:image:height'] = $optimized_image['height']; } if (!empty($optimized_image['type'])) { $og_tags['og:image:type'] = $optimized_image['type']; } if (!empty($optimized_image['alt'])) { $og_tags['og:image:alt'] = $optimized_image['alt']; } } else { // Fallback to site default image $default_image = $this->get_default_social_image(); if ($default_image) { $og_tags['og:image'] = $default_image; } } // Recommended OG tags if (!empty($data['description'])) { $og_tags['og:description'] = $this->optimize_description_for_platform($data['description'], $platform); } $og_tags['og:site_name'] = $data['site_name'] ?? get_bloginfo('name'); $og_tags['og:locale'] = $data['locale'] ?? $this->get_og_locale(); // Context-specific OG tags $og_tags = $this->add_context_specific_og_tags($og_tags, $context, $data, $og_type); // Platform-specific optimizations $og_tags = $this->apply_platform_specific_optimizations($og_tags, $platform, $data); return $og_tags; } /** * Generate Twitter Card tags for content * * @since 1.0.0 * * @param array $data Content data for Twitter Card generation * @param string $context Context type ('site', 'post', 'page', etc.) * @return array Generated Twitter Card tags */ public function generate_twitter_tags(array $data, string $context = 'site'): array { $twitter_tags = []; // Determine card type based on content $card_type = $this->determine_twitter_card_type($data, $context); $twitter_tags['twitter:card'] = $card_type; // Required tags. Prefer a per-post Twitter-specific title, falling back // to the resolved og:title/SEO title. Render it in full — the length cap // in optimize_title_for_platform() is an SEO-title recommendation for // search results, not a rule for the twitter:title social tag. $twitter_title = ($data['twitter_title'] ?? '') !== '' ? $data['twitter_title'] : ($data['title'] ?? ''); $twitter_tags['twitter:title'] = \ThinkRank\Frontend\SEO_Manager::strip_title_tags( $twitter_title !== '' ? (string) $twitter_title : (string) get_bloginfo('name') ); // Recommended tags. Prefer a per-object Twitter-specific description, // falling back to the resolved OG/meta description — mirroring the // twitter:title cascade above. This lookup did not exist, so a Twitter // description saved on the Social tab persisted, read back, and was // then dropped in favour of the OG description (#406). $twitter_description = ($data['twitter_description'] ?? '') !== '' ? $data['twitter_description'] : (string) ($data['description'] ?? ''); if ($twitter_description !== '') { $twitter_tags['twitter:description'] = $this->optimize_description_for_platform($twitter_description, 'twitter'); } // Image handling - prioritize Twitter-specific image $twitter_image = !empty($data['twitter_image']) ? $data['twitter_image'] : ($data['image'] ?? ''); if (!empty($twitter_image)) { $optimized_image = $this->optimize_image_for_platform($twitter_image, 'twitter'); $twitter_tags['twitter:image'] = $optimized_image['url']; if (!empty($optimized_image['alt'])) { $twitter_tags['twitter:image:alt'] = $optimized_image['alt']; } } // Site and creator information $twitter_site = $this->get_twitter_site_handle(); if ($twitter_site) { $twitter_tags['twitter:site'] = $twitter_site; } $twitter_creator = $this->get_twitter_creator_handle(); if ($twitter_creator) { $twitter_tags['twitter:creator'] = $twitter_creator; } elseif (!empty($data['author']['twitter'])) { // Fallback to author data if available $twitter_tags['twitter:creator'] = $data['author']['twitter']; } // Card-specific tags $twitter_tags = $this->add_twitter_card_specific_tags($twitter_tags, $card_type, $data, $context); return $twitter_tags; } /** * Optimize social image for platform requirements * * @since 1.0.0 * * @param string $image_url Image URL to optimize * @param string $platform Target platform * @return array Optimized image data */ public function optimize_social_image(string $image_url, string $platform): array { return $this->optimize_image_for_platform($image_url, $platform); } /** * Generate social media preview data * * @since 1.0.0 * * @param array $data Content data * @param string $platform Target platform * @return array Preview data for the platform */ public function preview_social_post(array $data, string $platform): array { $preview = [ 'platform' => $platform, 'valid' => false, 'preview_url' => '', 'title' => '', 'description' => '', 'image' => '', 'warnings' => [], 'suggestions' => [] ]; switch ($platform) { case 'facebook': case 'linkedin': $og_tags = $this->generate_og_tags($data, 'post', $platform); $preview = $this->generate_og_preview($og_tags, $platform, $preview); break; case 'twitter': $twitter_tags = $this->generate_twitter_tags($data, 'post'); $preview = $this->generate_twitter_preview($twitter_tags, $preview); break; case 'pinterest': $og_tags = $this->generate_og_tags($data, 'post', $platform); $preview = $this->generate_pinterest_preview($og_tags, $preview); break; default: $preview['warnings'][] = 'Unsupported platform for preview generation'; } return $preview; } /** * Validate SEO settings (implements interface) * * @since 1.0.0 * * @param array $settings Settings array to validate * @param string $context Optional context for focused validation * @return array Validation results */ public function validate_settings(array $settings, string $context = 'all'): array { $validation = [ 'valid' => true, 'errors' => [], 'warnings' => [], 'suggestions' => [], 'score' => 100 ]; // Add detailed field validation breakdown $field_details = $this->get_detailed_field_validation($settings, $context); $validation['field_details'] = $field_details; // Convert field details to validation format foreach ($field_details as $field) { switch ($field['status']) { case 'error': $validation['errors'][] = $field['label']; $validation['valid'] = false; $validation['score'] -= 15; break; case 'warning': $validation['warnings'][] = $field['label']; $validation['score'] -= 8; break; case 'suggestion': $validation['suggestions'][] = $field['label']; $validation['score'] -= 3; break; } } // Ensure score doesn't go below 0 $validation['score'] = max(0, $validation['score']); // Validate general enabled setting (Social Media tab format) if (isset($settings['enabled'])) { // Convert string booleans to actual booleans if (is_string($settings['enabled'])) { $settings['enabled'] = filter_var($settings['enabled'], FILTER_VALIDATE_BOOLEAN); } if (!is_bool($settings['enabled'])) { $validation['errors'][] = 'enabled must be a boolean value'; $validation['valid'] = false; } } // Validate Open Graph settings (Social Media tab format) if (isset($settings['enable_open_graph'])) { // Convert string booleans to actual booleans if (is_string($settings['enable_open_graph'])) { $settings['enable_open_graph'] = filter_var($settings['enable_open_graph'], FILTER_VALIDATE_BOOLEAN); } if (!is_bool($settings['enable_open_graph'])) { $validation['errors'][] = 'enable_open_graph must be a boolean value'; $validation['valid'] = false; } } // Validate Twitter Cards settings (Social Media tab format) if (isset($settings['enable_twitter_cards'])) { // Convert string booleans to actual booleans if (is_string($settings['enable_twitter_cards'])) { $settings['enable_twitter_cards'] = filter_var($settings['enable_twitter_cards'], FILTER_VALIDATE_BOOLEAN); } if (!is_bool($settings['enable_twitter_cards'])) { $validation['errors'][] = 'enable_twitter_cards must be a boolean value'; $validation['valid'] = false; } } // Validate Twitter settings (only when relevant) if ($context === 'all' || $context === 'twitter' || $context === 'twitter-cards') { // Validate Twitter username format if (isset($settings['twitter_username']) && !empty($settings['twitter_username'])) { $username = trim($settings['twitter_username']); // Remove @ if present (we store without @, add @ in output) $clean_username = ltrim($username, '@'); if (empty($clean_username)) { $validation['errors'][] = 'twitter_username cannot be empty'; $validation['valid'] = false; } elseif (strlen($clean_username) > 15) { $validation['errors'][] = 'twitter_username must be 15 characters or less'; $validation['valid'] = false; } elseif (!preg_match('/^[A-Za-z0-9_]+$/', $clean_username)) { $validation['errors'][] = 'twitter_username can only contain letters, numbers, and underscores'; $validation['valid'] = false; } // Update the setting to store without @ for consistency $settings['twitter_username'] = $clean_username; } // Validate Twitter creator format if (isset($settings['twitter_creator']) && !empty($settings['twitter_creator'])) { $creator = trim($settings['twitter_creator']); // Remove @ if present (we store without @, add @ in output) $clean_creator = ltrim($creator, '@'); if (empty($clean_creator)) { $validation['errors'][] = 'twitter_creator cannot be empty'; $validation['valid'] = false; } elseif (strlen($clean_creator) > 15) { $validation['errors'][] = 'twitter_creator must be 15 characters or less'; $validation['valid'] = false; } elseif (!preg_match('/^[A-Za-z0-9_]+$/', $clean_creator)) { $validation['errors'][] = 'twitter_creator can only contain letters, numbers, and underscores'; $validation['valid'] = false; } // Update the setting to store without @ for consistency $settings['twitter_creator'] = $clean_creator; } // Validate Twitter card type if (isset($settings['twitter_card_type'])) { $valid_types = ['summary', 'summary_large_image', 'app', 'player']; if (!in_array($settings['twitter_card_type'], $valid_types, true)) { $validation['errors'][] = 'twitter_card_type must be one of: ' . implode(', ', $valid_types); $validation['valid'] = false; } } } // Validate OG type if (isset($settings['og_type'])) { $valid_types = ['website', 'article', 'book', 'profile', 'music.song', 'music.album', 'video.movie', 'video.episode']; if (!in_array($settings['og_type'], $valid_types, true)) { $validation['warnings'][] = 'og_type should be one of the standard Open Graph types for best compatibility'; } } // Validate image dimensions if (isset($settings['og_image_width']) && (!is_numeric($settings['og_image_width']) || $settings['og_image_width'] < 200)) { $validation['warnings'][] = 'og_image_width should be at least 200 pixels for optimal social sharing'; } if (isset($settings['og_image_height']) && (!is_numeric($settings['og_image_height']) || $settings['og_image_height'] < 200)) { $validation['warnings'][] = 'og_image_height should be at least 200 pixels for optimal social sharing'; } // Validate description length if (isset($settings['max_description_length']) && (!is_numeric($settings['max_description_length']) || $settings['max_description_length'] < 50 || $settings['max_description_length'] > 300)) { $validation['warnings'][] = 'max_description_length should be between 50 and 300 characters'; } // Legacy validation for backward compatibility (only when legacy fields are actually present) if (isset($settings['og_enabled'])) { // Convert string booleans to actual booleans if (is_string($settings['og_enabled'])) { $settings['og_enabled'] = filter_var($settings['og_enabled'], FILTER_VALIDATE_BOOLEAN); } if (!is_bool($settings['og_enabled'])) { $validation['errors'][] = 'og_enabled must be a boolean value'; $validation['valid'] = false; } } if (isset($settings['twitter_enabled'])) { // Convert string booleans to actual booleans if (is_string($settings['twitter_enabled'])) { $settings['twitter_enabled'] = filter_var($settings['twitter_enabled'], FILTER_VALIDATE_BOOLEAN); } if (!is_bool($settings['twitter_enabled'])) { $validation['errors'][] = 'twitter_enabled must be a boolean value'; $validation['valid'] = false; } } // Validate Twitter card type if (isset($settings['twitter_card_type'])) { $valid_card_types = $this->supported_platforms['twitter']['card_types']; if (!in_array($settings['twitter_card_type'], $valid_card_types, true)) { $validation['errors'][] = 'Invalid Twitter card type. Must be one of: ' . implode(', ', $valid_card_types); $validation['valid'] = false; } } // Validate default image if (isset($settings['default_image']) && !empty($settings['default_image'])) { if (!filter_var($settings['default_image'], FILTER_VALIDATE_URL)) { $validation['errors'][] = 'default_image must be a valid URL'; $validation['valid'] = false; } else { // Check image dimensions and format $image_validation = $this->validate_social_image($settings['default_image']); if (!$image_validation['valid']) { $validation['warnings'] = array_merge($validation['warnings'], $image_validation['warnings']); } } } // Validate Twitter site handle if (isset($settings['twitter_site']) && !empty($settings['twitter_site'])) { if (!preg_match('/^@[a-zA-Z0-9_]{1,15}$/', $settings['twitter_site'])) { $validation['errors'][] = 'twitter_site must be a valid Twitter handle (e.g., @username)'; $validation['valid'] = false; } } // Validate custom OG tags if (isset($settings['custom_og_tags']) && !empty($settings['custom_og_tags'])) { if (!is_array($settings['custom_og_tags'])) { $validation['errors'][] = 'custom_og_tags must be an array'; $validation['valid'] = false; } else { foreach ($settings['custom_og_tags'] as $property => $content) { if (!is_string($property) || !is_string($content)) { $validation['errors'][] = 'Custom OG tags must have string property names and content'; $validation['valid'] = false; break; } } } } // Validate platform verification codes / IDs (Instagram, TikTok, YouTube, // WhatsApp, Facebook App ID, Pinterest) against the shared format rules. // Single source of truth — also enforced on the Social Platforms REST save // path via self::validate_platform_field(). See get_platform_field_validation_rules(). foreach (self::get_platform_field_validation_rules() as $field_key => $rule) { if (isset($settings[$field_key]) && $settings[$field_key] !== '') { $error = self::validate_platform_field($field_key, $settings[$field_key]); if ($error !== null) { $validation['errors'][] = $error; $validation['valid'] = false; } } } return $validation; } /** * Format-validation rules for social platform verification codes / IDs. * * Single source of truth for the per-field format constraints, shared by * validate_settings() and the Social Platforms REST endpoint * (ThinkRank\API\Social_Platforms_Endpoint) so a value that is accepted on * one path is accepted on the other. These mirror the `pattern` entries in * get_settings_schema(); sanitization strips unsafe characters but does not * enforce shape, so these rules back it with real validation. * * @since 1.14.0 * * @return array Map of field key => rule. */ public static function get_platform_field_validation_rules(): array { return [ 'facebook_app_id' => [ 'pattern' => '/^[0-9]+$/', 'message' => 'Facebook App ID must contain digits only.', ], 'pinterest_site_verification' => [ 'pattern' => '/^[a-f0-9]{32}$/', 'message' => 'Pinterest site verification must be a 32-character hexadecimal string.', ], 'instagram_verification' => [ 'pattern' => '/^[a-zA-Z0-9_-]{20,}$/', 'message' => 'Instagram verification must be at least 20 characters (letters, numbers, underscore, dash).', ], 'tiktok_verification' => [ 'pattern' => '/^[a-zA-Z0-9_-]{20,}$/', 'message' => 'TikTok verification must be at least 20 characters (letters, numbers, underscore, dash).', ], 'youtube_channel_id' => [ 'pattern' => '/^UC[a-zA-Z0-9_-]{22}$/', 'message' => 'YouTube channel ID must start with "UC" followed by 22 characters (letters, numbers, underscore, dash).', ], 'whatsapp_business_id' => [ 'pattern' => '/^[0-9]{10,15}$/', 'message' => 'WhatsApp Business ID must be 10-15 digits.', ], ]; } /** * Validate a single social platform field value against its shared format rule. * * Returns null for fields with no format rule (e.g. facebook_admins) and for * empty values, so callers can validate an arbitrary settings map and only * act on genuine format violations. * * @since 1.14.0 * * @param string $key Field key. * @param mixed $value Field value. * @return string|null Error message when the value violates the format, null otherwise. */ public static function validate_platform_field(string $key, $value): ?string { $rules = self::get_platform_field_validation_rules(); if (!isset($rules[$key]) || $value === '' || $value === null) { return null; } if (!preg_match($rules[$key]['pattern'], (string) $value)) { return $rules[$key]['message']; } return null; } /** * Get detailed field validation breakdown * * @since 1.0.0 * * @param array $settings Settings array to validate * @param string $context Context for specific validation * @return array Detailed field validation results */ private function get_detailed_field_validation(array $settings, string $context = 'all'): array { $field_details = []; // Return tab-specific validation based on context switch ($context) { case 'open-graph': return $this->validate_open_graph_fields($settings); case 'twitter-cards': return $this->validate_twitter_fields($settings); default: // For 'all' or unknown context, return only Social Media fields (Open Graph + Twitter) $field_details = array_merge($field_details, $this->validate_open_graph_fields($settings)); $field_details = array_merge($field_details, $this->validate_twitter_fields($settings)); return $field_details; } } /** * Validate Open Graph fields * * @since 1.0.0 * * @param array $settings Settings array to validate * @return array Open Graph field validation results */ private function validate_open_graph_fields(array $settings): array { $field_details = []; // Open Graph Enabled if (!empty($settings['enable_open_graph'])) { $field_details[] = [ 'field' => 'enable_open_graph', 'label' => 'Open Graph is enabled for social media sharing.', 'status' => 'valid', 'icon' => '✓' ]; // Site Name validation if (!empty($settings['og_site_name'])) { if (strlen($settings['og_site_name']) <= 60) { $field_details[] = [ 'field' => 'og_site_name', 'label' => 'Site name is properly configured for Open Graph.', 'status' => 'valid', 'icon' => '✓' ]; } else { $field_details[] = [ 'field' => 'og_site_name', 'label' => 'Site name is longer than 60 characters, may be truncated.', 'status' => 'warning', 'icon' => '⚠' ]; } } else { $field_details[] = [ 'field' => 'og_site_name', 'label' => 'Site name is required for Open Graph.', 'status' => 'error', 'icon' => '✗' ]; } // Description validation if (!empty($settings['og_description'])) { $length = strlen($settings['og_description']); if ($length >= 120 && $length <= 160) { $field_details[] = [ 'field' => 'og_description', 'label' => 'Description is properly configured for social sharing.', 'status' => 'valid', 'icon' => '✓' ]; } else { $field_details[] = [ 'field' => 'og_description', 'label' => 'Description length could be optimized (120-160 characters recommended).', 'status' => 'warning', 'icon' => '⚠' ]; } } else { $field_details[] = [ 'field' => 'og_description', 'label' => 'Description is recommended for better social sharing.', 'status' => 'warning', 'icon' => '⚠' ]; } // Content Type validation if (!empty($settings['og_type'])) { $field_details[] = [ 'field' => 'og_type', 'label' => 'Content type is configured for Open Graph.', 'status' => 'valid', 'icon' => '✓' ]; } else { $field_details[] = [ 'field' => 'og_type', 'label' => 'Content type should be specified.', 'status' => 'suggestion', 'icon' => '⚠' ]; } // Default Image validation if (!empty($settings['default_og_image'])) { if (filter_var($settings['default_og_image'], FILTER_VALIDATE_URL)) { $field_details[] = [ 'field' => 'default_og_image', 'label' => 'Default Open Graph image is configured.', 'status' => 'valid', 'icon' => '✓' ]; } else { $field_details[] = [ 'field' => 'default_og_image', 'label' => 'Default Open Graph image URL appears invalid.', 'status' => 'warning', 'icon' => '⚠' ]; } } else { $field_details[] = [ 'field' => 'default_og_image', 'label' => 'Default image is recommended for social sharing.', 'status' => 'suggestion', 'icon' => '⚠' ]; } } else { $field_details[] = [ 'field' => 'enable_open_graph', 'label' => 'Open Graph is disabled. Enable for better social media sharing.', 'status' => 'suggestion', 'icon' => '⚠' ]; } return $field_details; } /** * Validate Twitter fields * * @since 1.0.0 * * @param array $settings Settings array to validate * @return array Twitter field validation results */ private function validate_twitter_fields(array $settings): array { $field_details = []; // Twitter Cards Enabled if (!empty($settings['enable_twitter_cards'])) { $field_details[] = [ 'field' => 'enable_twitter_cards', 'label' => 'Twitter Cards are enabled for Twitter sharing.', 'status' => 'valid', 'icon' => '✓' ]; // Twitter Username validation if (!empty($settings['twitter_username'])) { $username = trim($settings['twitter_username']); $clean_username = ltrim($username, '@'); if (strlen($clean_username) <= 15 && preg_match('/^[A-Za-z0-9_]+$/', $clean_username)) { $field_details[] = [ 'field' => 'twitter_username', 'label' => 'Twitter username is properly formatted.', 'status' => 'valid', 'icon' => '✓' ]; } else { $field_details[] = [ 'field' => 'twitter_username', 'label' => 'Twitter username format is invalid (max 15 chars, letters/numbers/underscore only).', 'status' => 'error', 'icon' => '✗' ]; } } else { $field_details[] = [ 'field' => 'twitter_username', 'label' => 'Twitter username recommended for better attribution.', 'status' => 'suggestion', 'icon' => '⚠' ]; } // Twitter Creator validation if (!empty($settings['twitter_creator'])) { $creator = trim($settings['twitter_creator']); $clean_creator = ltrim($creator, '@'); if (strlen($clean_creator) <= 15 && preg_match('/^[A-Za-z0-9_]+$/', $clean_creator)) { $field_details[] = [ 'field' => 'twitter_creator', 'label' => 'Twitter creator is properly formatted.', 'status' => 'valid', 'icon' => '✓' ]; } else { $field_details[] = [ 'field' => 'twitter_creator', 'label' => 'Twitter creator format is invalid (max 15 chars, letters/numbers/underscore only).', 'status' => 'error', 'icon' => '✗' ]; } } else { $field_details[] = [ 'field' => 'twitter_creator', 'label' => 'Twitter creator recommended for content attribution.', 'status' => 'suggestion', 'icon' => '⚠' ]; } // Card Type validation $valid_card_types = ['summary', 'summary_large_image', 'app', 'player']; if (!empty($settings['twitter_card_type']) && in_array($settings['twitter_card_type'], $valid_card_types, true)) { $field_details[] = [ 'field' => 'twitter_card_type', 'label' => 'Twitter Card type is properly configured.', 'status' => 'valid', 'icon' => '✓' ]; } else { $field_details[] = [ 'field' => 'twitter_card_type', 'label' => 'Valid Twitter Card type is required.', 'status' => 'error', 'icon' => '✗' ]; } // Default Twitter Image validation if (!empty($settings['default_twitter_image'])) { if (filter_var($settings['default_twitter_image'], FILTER_VALIDATE_URL)) { $field_details[] = [ 'field' => 'default_twitter_image', 'label' => 'Default Twitter Card image is configured.', 'status' => 'valid', 'icon' => '✓' ]; } else { $field_details[] = [ 'field' => 'default_twitter_image', 'label' => 'Default Twitter Card image URL appears invalid.', 'status' => 'warning', 'icon' => '⚠' ]; } } else { $field_details[] = [ 'field' => 'default_twitter_image', 'label' => 'Default image recommended for Twitter sharing.', 'status' => 'suggestion', 'icon' => '⚠' ]; } } else { $field_details[] = [ 'field' => 'enable_twitter_cards', 'label' => 'Twitter Cards are disabled. Enable for better Twitter sharing.', 'status' => 'suggestion', 'icon' => '⚠' ]; } return $field_details; } /** * Get output data for frontend rendering (implements interface) * * @since 1.0.0 * * @param string $context_type The context type * @param int|null $context_id Optional. Context ID * @param string|null $fallback_title Optional. Effective SEO title to * use when no per-post Open Graph * title override is set, so * rendered output mirrors the * document title and the Social * metabox preview. * @param string|null $fallback_description Optional. Effective meta * description, used as the * Open Graph description fallback * for the same parity reason. * @return array Output data ready for frontend rendering */ public function get_output_data(string $context_type, ?int $context_id, ?string $fallback_title = null, ?string $fallback_description = null): array { // Memoize per request: the OG, Twitter and platform wp_head callbacks // each call this with the same arguments, so the extraction work + // Site_Identity_Manager instantiation would otherwise run three times. $cache_key = $context_type . ':' . ($context_id ?? 0) . ':' . md5((string) $fallback_title . '|' . (string) $fallback_description); if (isset($this->output_data_cache[$cache_key])) { return $this->output_data_cache[$cache_key]; } $settings = $this->get_settings($context_type, $context_id); $output = [ 'og_tags' => [], // Secondary og:image entries. A separate list because $og_tags is // keyed by property name and so can hold exactly one 'og:image' // (#636); the emitter writes these straight after the primary. 'og_extra_images' => [], 'twitter_tags' => [], 'meta_tags' => [], 'platform_tags' => [], // Per-feature flags so each emitter can honor its own toggle. The // aggregate `enabled` (true if either is on) is kept for callers // that only care whether social output ran at all. 'og_enabled' => false, 'twitter_enabled' => false, 'enabled' => false, ]; // Check if individual social features are enabled $og_enabled = !empty($settings['enable_open_graph'] ?? $settings['og_enabled'] ?? false); $twitter_enabled = !empty($settings['enable_twitter_cards'] ?? $settings['twitter_enabled'] ?? false); $output['og_enabled'] = $og_enabled; $output['twitter_enabled'] = $twitter_enabled; if (!$og_enabled && !$twitter_enabled) { $this->output_data_cache[$cache_key] = $output; return $output; } $output['enabled'] = true; // Extract content data $content_data = $this->extract_social_content_data($context_type, $context_id, $settings, $fallback_title, $fallback_description); // Generate Open Graph tags if enabled if ($og_enabled) { $output['og_tags'] = $this->generate_og_tags($content_data, $context_type); // Add custom OG tags if (!empty($settings['custom_og_tags'])) { $output['og_tags'] = array_merge($output['og_tags'], $settings['custom_og_tags']); } // Alternatives to offer after the primary image. Collected from the // resolved primary rather than re-deriving it, so the two can never // disagree about which image is the main one. if (!empty($settings['og_multiple_images'])) { $output['og_extra_images'] = Social_Images::additional( (int) $context_id, (string) ($output['og_tags']['og:image'] ?? '') ); } } // Generate Twitter Card tags if enabled if ($twitter_enabled) { $output['twitter_tags'] = $this->generate_twitter_tags($content_data, $context_type); } // Generate platform-specific meta tags $output['platform_tags'] = $this->generate_platform_meta_tags($settings); // Convert to meta tag format for HTML output $output['meta_tags'] = $this->convert_to_meta_tags($output['og_tags'], $output['twitter_tags'], $output['platform_tags']); $this->output_data_cache[$cache_key] = $output; return $output; } /** * Request-scoped memoization of get_output_data() keyed by context + title/desc. * * @var array */ private array $output_data_cache = []; /** * Site-wide default image keys inherited by every other context. * * @since 1.24.1 * @var string[] */ private const INHERITED_SITE_KEYS = [ 'default_og_image', 'default_twitter_image', 'default_image', ]; /** * Site-wide switches with no per-context equivalent. * * Unlike INHERITED_SITE_KEYS above, these must inherit their site value * even when it is FALSE. The empty()-guarded loop used for images can only * ever propagate "on", which is right for an image URL (empty means "not * configured") and wrong for a boolean, where false is a deliberate choice. * * Without this the master Open Graph / Twitter Cards switches were honoured * on the homepage (mapped to the `site` context) and ignored on every post * and page, which read as though the toggle had worked (#557). * * @since 2.2.0 * @var string[] */ private const SITE_ONLY_KEYS = [ 'enable_open_graph', 'enable_twitter_cards', // Same shape as the two above and the same trap: a site-wide switch // with no per-context equivalent. Left out, it read as on while the // homepage rendered and as off on every post and page — which is where // the alternatives it controls actually come from (#636). 'og_multiple_images', ]; /** * Get settings for a context, inheriting the site-wide default images * * Two corrections over the generic lookup: * * 1. Site-wide settings are always stored with context_id 0, but the * frontend maps a homepage request to the `site` context while still * passing the queried object ID (non-zero on a static front page). That * looked up `site`/, matched no row and silently fell back to * the schema defaults, so a configured default OG image never rendered * on a static front page. Normalize the ID away for `site`. * 2. `default_og_image` / `default_twitter_image` only exist in the site * context, so post/page and archive contexts had nothing to fall back to * when a post had no featured image — og:image dropped to the site logo * or vanished entirely. Inherit those keys when the context has no value * of its own. * * @since 1.24.1 * * @param string $context_type The context type * @param int|null $context_id Optional. Context ID * @return array Settings with site-wide image defaults applied */ public function get_settings(string $context_type, ?int $context_id = null): array { if ($context_type === 'site') { $context_id = null; } $settings = parent::get_settings($context_type, $context_id); if ($context_type === 'site') { return $settings; } $site_settings = parent::get_settings('site', null); // Site-wide switches: the site value is authoritative, false included. // array_key_exists, not empty() — '' is how a disabled toggle is stored. foreach (self::SITE_ONLY_KEYS as $key) { if (array_key_exists($key, $site_settings)) { $settings[$key] = $site_settings[$key]; } } // Default images: inherit only when this context has none of its own. foreach (self::INHERITED_SITE_KEYS as $key) { if (empty($settings[$key]) && !empty($site_settings[$key])) { $settings[$key] = $site_settings[$key]; } } return $settings; } /** * Get default settings for a context type (implements interface) * * @since 1.0.0 * * @param string $context_type The context type to get defaults for * @return array Default settings array */ public function get_default_settings(string $context_type): array { // Get WordPress site defaults $site_name = get_bloginfo('name'); $site_description = get_bloginfo('description'); $defaults = [ // Open Graph settings 'enable_open_graph' => true, 'og_site_name' => $site_name, 'og_description' => $site_description, 'og_type' => 'website', // Empty by design: og:locale is resolved from the site locale via // get_og_locale(), which is where the thinkrank_og_locale filter (and // therefore the WPML/Polylang/TranslatePress integration) applies. A // hardcoded default was merged into every settings read, so the // resolver was unreachable and every install advertised en_US. 'og_locale' => '', 'default_og_image' => '', 'og_image_width' => 1200, 'og_image_height' => 630, // Offer alternative og:image tags after the primary one (#636). // Off by default: a page that shares with one image today must // keep sharing with that image after an update. 'og_multiple_images' => false, // Twitter Cards settings 'enable_twitter_cards' => true, 'twitter_username' => '', 'twitter_creator' => '', 'twitter_card_type' => 'summary_large_image', 'default_twitter_image' => '', // Facebook settings 'facebook_app_id' => '', 'facebook_admins' => '', // LinkedIn settings 'enable_linkedin' => false, // Pinterest settings 'enable_pinterest' => false, 'pinterest_site_verification' => '', // Instagram settings 'enable_instagram' => false, 'instagram_verification' => '', // TikTok settings 'enable_tiktok' => false, 'tiktok_verification' => '', // YouTube settings 'enable_youtube' => false, 'youtube_channel_id' => '', // WhatsApp Business settings 'enable_whatsapp' => false, 'whatsapp_business_id' => '', // Advanced settings 'auto_generate_descriptions' => true, 'fallback_to_excerpt' => true, 'strip_html_tags' => true, 'max_description_length' => 160, // oEmbed card (#637). All three default off: this rewrites what // other people's sites display, so an upgrade must not silently // change a card an existing embed has been showing for months. 'oembed_use_seo_title' => false, 'oembed_use_social_image' => false, 'oembed_remove_author' => false, // Legacy format for backward compatibility (only when needed) 'custom_og_tags' => [], 'image_optimization' => true, 'auto_generate' => true ]; // Context-specific defaults switch ($context_type) { case 'site': $defaults['og_type'] = 'website'; break; case 'post': $defaults['og_type'] = 'article'; break; case 'page': $defaults['og_type'] = 'website'; break; case 'product': $defaults['og_type'] = 'product'; break; } return $defaults; } /** * Get settings schema definition (implements interface) * * @since 1.0.0 * * @param string $context_type The context type to get schema for * @return array Settings schema definition */ public function get_settings_schema(string $context_type): array { return [ 'enable_open_graph' => [ 'type' => 'boolean', 'title' => 'Enable Open Graph', 'description' => 'Generate Open Graph meta tags for social media sharing', 'default' => true ], 'og_site_name' => [ 'type' => 'string', 'title' => 'Site Name', 'description' => 'The name of your website for Open Graph', 'maxLength' => 60, 'default' => get_bloginfo('name') ], 'og_description' => [ 'type' => 'string', 'title' => 'Site Description', 'description' => 'Default description for Open Graph tags', 'maxLength' => 160, 'default' => get_bloginfo('description') ], 'og_locale' => [ 'type' => 'string', 'title' => 'Locale', 'description' => 'Optional override for og:locale. Leave empty to follow the site language.', 'default' => '' ], 'default_og_image' => [ 'type' => 'string', 'title' => 'Default Open Graph Image', 'description' => 'Default image URL for Open Graph tags', 'format' => 'uri', 'default' => '' ], 'og_image_width' => [ 'type' => 'integer', 'title' => 'Image Width', 'description' => 'Default width for Open Graph images', 'minimum' => 200, 'default' => 1200 ], 'og_image_height' => [ 'type' => 'integer', 'title' => 'Image Height', 'description' => 'Default height for Open Graph images', 'minimum' => 200, 'default' => 630 ], 'enable_twitter_cards' => [ 'type' => 'boolean', 'title' => 'Enable Twitter Cards', 'description' => 'Generate Twitter Card meta tags for Twitter sharing', 'default' => true ], 'twitter_username' => [ 'type' => 'string', 'title' => 'Twitter Username', 'description' => 'Twitter username for the site (without @, e.g., username)', 'pattern' => '^[a-zA-Z0-9_]{1,15}$', 'default' => '' ], 'twitter_creator' => [ 'type' => 'string', 'title' => 'Twitter Creator', 'description' => 'Content creator Twitter username (without @, e.g., creator_username)', 'pattern' => '^[a-zA-Z0-9_]{1,15}$', 'default' => '' ], 'twitter_card_type' => [ 'type' => 'string', 'title' => 'Twitter Card Type', 'description' => 'The type of Twitter Card to generate', 'enum' => $this->supported_platforms['twitter']['card_types'], 'default' => 'summary_large_image' ], 'og_type' => [ 'type' => 'string', 'title' => 'Open Graph Type', 'description' => 'The Open Graph type for this content', 'enum' => array_keys($this->og_types), 'default' => $this->get_default_settings($context_type)['og_type'] ], 'default_image' => [ 'type' => 'string', 'title' => 'Default Social Image', 'description' => 'Default image URL for social media sharing', 'format' => 'uri', 'default' => '' ], 'twitter_site' => [ 'type' => 'string', 'title' => 'Twitter Site Handle', 'description' => 'Twitter handle for the site (e.g., @username)', 'pattern' => '^@[a-zA-Z0-9_]{1,15}$', 'default' => '' ], 'default_twitter_image' => [ 'type' => 'string', 'title' => 'Default Twitter Image', 'description' => 'Default image URL for Twitter Cards', 'format' => 'uri', 'default' => '' ], 'facebook_app_id' => [ 'type' => 'string', 'title' => 'Facebook App ID', 'description' => 'Facebook App ID for analytics and insights', 'pattern' => '^[0-9]+$', 'default' => '' ], 'facebook_admins' => [ 'type' => 'string', 'title' => 'Facebook Admins', 'description' => 'Comma-separated list of Facebook admin user IDs', 'default' => '' ], 'enable_linkedin' => [ 'type' => 'boolean', 'title' => 'Enable LinkedIn', 'description' => 'Enable LinkedIn-specific optimizations', 'default' => false ], 'enable_pinterest' => [ 'type' => 'boolean', 'title' => 'Enable Pinterest', 'description' => 'Enable Pinterest-specific optimizations', 'default' => false ], 'pinterest_site_verification' => [ 'type' => 'string', 'title' => 'Pinterest Site Verification', 'description' => 'Pinterest site verification meta tag content', 'pattern' => '^[a-f0-9]{32}$', 'default' => '' ], 'enable_instagram' => [ 'type' => 'boolean', 'title' => 'Enable Instagram', 'description' => 'Enable Instagram-specific optimizations', 'default' => false ], 'instagram_verification' => [ 'type' => 'string', 'title' => 'Instagram Site Verification', 'description' => 'Instagram Business account verification code', 'pattern' => '^[a-zA-Z0-9_-]{20,}$', 'default' => '' ], 'enable_tiktok' => [ 'type' => 'boolean', 'title' => 'Enable TikTok', 'description' => 'Enable TikTok-specific optimizations', 'default' => false ], 'tiktok_verification' => [ 'type' => 'string', 'title' => 'TikTok Site Verification', 'description' => 'TikTok for Business verification code', 'pattern' => '^[a-zA-Z0-9_-]{20,}$', 'default' => '' ], 'enable_youtube' => [ 'type' => 'boolean', 'title' => 'Enable YouTube', 'description' => 'Enable YouTube-specific optimizations', 'default' => false ], 'youtube_channel_id' => [ 'type' => 'string', 'title' => 'YouTube Channel ID', 'description' => 'YouTube channel ID for content attribution', 'pattern' => '^UC[a-zA-Z0-9_-]{22}$', 'default' => '' ], 'enable_whatsapp' => [ 'type' => 'boolean', 'title' => 'Enable WhatsApp Business', 'description' => 'Enable WhatsApp Business optimizations', 'default' => false ], 'whatsapp_business_id' => [ 'type' => 'string', 'title' => 'WhatsApp Business ID', 'description' => 'WhatsApp Business account ID', 'pattern' => '^[0-9]{10,15}$', 'default' => '' ], 'auto_generate_descriptions' => [ 'type' => 'boolean', 'title' => 'Auto-generate Descriptions', 'description' => 'Automatically generate descriptions from content', 'default' => true ], 'fallback_to_excerpt' => [ 'type' => 'boolean', 'title' => 'Fallback to Excerpt', 'description' => 'Use post excerpt as fallback for descriptions', 'default' => true ], 'strip_html_tags' => [ 'type' => 'boolean', 'title' => 'Strip HTML Tags', 'description' => 'Remove HTML tags from generated descriptions', 'default' => true ], 'max_description_length' => [ 'type' => 'integer', 'title' => 'Max Description Length', 'description' => 'Maximum length for generated descriptions', 'minimum' => 50, 'maximum' => 300, 'default' => 160 ], 'custom_og_tags' => [ 'type' => 'object', 'title' => 'Custom Open Graph Tags', 'description' => 'Additional custom Open Graph meta tags', 'default' => [] ], 'image_optimization' => [ 'type' => 'boolean', 'title' => 'Enable Image Optimization', 'description' => 'Optimize images for social media platforms', 'default' => true ], 'auto_generate' => [ 'type' => 'boolean', 'title' => 'Auto-generate Tags', 'description' => 'Automatically generate social meta tags from content', 'default' => true ] ]; } /** * Extract content data for social meta generation * * @since 1.0.0 * * @param string $context_type The context type * @param int|null $context_id Optional. Context ID * @param array $settings Social meta settings * @return array Extracted content data */ private function extract_social_content_data(string $context_type, ?int $context_id, array $settings, ?string $fallback_title = null, ?string $fallback_description = null): array { $data = [ 'title' => '', 'description' => '', 'url' => '', 'image' => '', 'twitter_title' => '', // Separate field for a Twitter-specific title 'twitter_description' => '', // Separate field for a Twitter-specific description 'twitter_image' => '', // Separate field for Twitter-specific images // '' rather than 'website' means "derive it from the context". // Seeding a concrete type here made an explicit choice // indistinguishable from the shipped default (#398). 'type' => '', 'twitter_card_type' => '', 'author' => [], 'published_time' => '', 'modified_time' => '', 'site_name' => $settings['og_site_name'] ?? get_bloginfo('name') ]; // Explicit type choices, resolved once for whichever context this is. $data['type'] = $this->configured_og_type($settings); $data['twitter_card_type'] = $this->configured_twitter_card_type($settings); if ($context_type === 'site') { // Site-wide data with Social Media tab settings priority. // // og:title priority: an explicitly-configured OG Site Name wins; // otherwise mirror the effective SEO (what search shows), // then the blogname. og_site_name is always present because it is // merged from a default equal to the blogname, so a value matching // the blogname is treated as "not explicitly overridden" and we fall // through to the passed effective SEO title. (og:site_name itself is // set separately from og_site_name and is unaffected.) $blogname = get_bloginfo('name'); $og_site_name = $settings['og_site_name'] ?? ''; if ($og_site_name !== '' && $og_site_name !== $blogname) { $data['title'] = $og_site_name; } elseif ($fallback_title !== null && $fallback_title !== '') { $data['title'] = $fallback_title; } else { $data['title'] = $blogname; } $data['description'] = $settings['og_description'] ?? get_bloginfo('description'); // Use home_url('/') so og:url matches the homepage canonical // (class-seo-manager.php) and the WebSite schema, which both include // the trailing slash. A bare home_url() would key a different URL in // social caches than the canonical. // // Except when this is not the site home. detect_current_context() // collapses is_home() && !is_front_page() into 'homepage', so a // static posts page — and page 2 of any blog listing — advertised // the site home as its og:url while its own canonical said // otherwise (#397). $data['url'] = self::current_home_url(); // Leaving this null lets generate_og_tags() fall through to // get_og_locale(), which is what every other context already does. // // A stored 'en_US' is deliberately treated as "not set". It was the // hardcoded default on every install and there has never been a UI // control for this field, so it cannot represent a deliberate choice // — it is the old default persisted by an unrelated save of the // Social Media tab. Honouring it would leave every already-saved // site broken after this fix. Any other stored value is a genuine // override and still wins; a site that really wants to force en_US // can do so through the thinkrank_og_locale filter. $stored_locale = trim((string) ($settings['og_locale'] ?? '')); $data['locale'] = ('' !== $stored_locale && 'en_US' !== $stored_locale) ? $stored_locale : null; // Set Open Graph image with proper fallback $data['image'] = $this->get_og_image_for_context($settings, null); // Set Twitter image with proper fallback $data['twitter_image'] = $this->get_twitter_image_for_context($settings, null); } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'], true)) { // Post-specific data $post = get_post($context_id); if ($post) { // Per-post Open Graph overrides from the metabox Social tab take // precedence over the derived title/description/image. These read // the same meta keys the metabox save handler and the import // migrator write to, so manual edits and migrated data flow // through one path. Title/description may hold variable tags // (e.g. %title%), resolved here to match output_basic_og_tags(). $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value( (string) get_post_meta($post->ID, '_thinkrank_og_title', true), $post->ID ); $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value( (string) get_post_meta($post->ID, '_thinkrank_og_description', true), $post->ID ); $og_image_override = get_post_meta($post->ID, '_thinkrank_og_image', true); // Per-post Twitter-specific title override (metabox Social tab). // Twitter Cards fall back to the og:title when this is empty, so // only capture it here; the fallback is applied in // generate_twitter_tags(). $data['twitter_title'] = \ThinkRank\SEO\Pattern_Resolver::resolve_value( (string) get_post_meta($post->ID, '_thinkrank_twitter_title', true), $post->ID ); // Per-post Twitter-specific description. twitter:description // falls back to the OG/meta description when this is empty, so // only capture it here; generate_twitter_tags() applies the // fallback. There was no counterpart to the title lookup above, // so the stored value never entered $data at all (#406). $data['twitter_description'] = \ThinkRank\SEO\Pattern_Resolver::resolve_value( (string) get_post_meta($post->ID, '_thinkrank_twitter_description', true), $post->ID ); // Title priority: per-post OG override > effective SEO title // (document <title> / metabox preview) > post title. if ($og_title_override !== '') { $data['title'] = $og_title_override; } elseif ($fallback_title !== null && $fallback_title !== '') { $data['title'] = $fallback_title; } else { $data['title'] = get_the_title($post); } // Description priority: per-post OG override > effective meta // description (meta tag / metabox preview) > derived excerpt. if ($og_description_override !== '') { $data['description'] = $og_description_override; } elseif ($fallback_description !== null && $fallback_description !== '') { $data['description'] = $fallback_description; } else { $data['description'] = $this->get_social_description($post); } // The canonical carries the <!--nextpage--> sub-page and the // comment page; og:url said page 1 regardless, which is the // same contradiction #397 fixed for the blog listing, one page // type over (#397 review). $data['url'] = self::with_singular_page((string) get_permalink($post)); $data['published_time'] = get_the_date('c', $post); $data['modified_time'] = get_the_modified_date('c', $post); $data['post_id'] = $post->ID; // Author data $author_id = $post->post_author; $data['author'] = [ 'name' => get_the_author_meta('display_name', $author_id), 'url' => get_author_posts_url($author_id), 'twitter' => get_the_author_meta('twitter', $author_id) ]; // Set Open Graph image: per-post override first, then the // featured-image/default fallback chain. $data['image'] = $og_image_override !== '' ? $og_image_override : $this->get_og_image_for_context($settings, $post); // Set Twitter image with proper fallback $data['twitter_image'] = $this->get_twitter_image_for_context($settings, $post); } } else { // Archive-style contexts (category, tag, author, search, date). // They carry no object of their own, but the site-wide default // image still applies — without this they fell through to the raw // logo/site-icon fallback in generate_og_tags() and ignored a // configured default OG image. $data['image'] = $this->get_og_image_for_context($settings, null); $data['twitter_image'] = $this->get_twitter_image_for_context($settings, null); // A term archive owns SEO values of its own, and the caller has // already resolved them into the fallbacks — the same strings // rendered as the document <title> and the description tag. Leaving // title and description empty here published the bare site name as // og:title on every archive and no og:description at all, so a term // SEO title never reached a social surface (#386). // Archives have a URL of their own. The seed leaves it '', and the // og:url emitter could not fall through to get_current_url() while // the key existed, so no archive carried an og:url at all (#388). // A search archive's URL is the search link, not the bare request // path — get_current_url() reads $wp->request, which is empty for a // search served from the front page, so og:url pointed at the site // home while the page was a search result. // The non-search archive URL is the page's own canonical, so og:url // and <link rel="canonical"> agree on the paginated page rather than // both claiming page 1 (#397). // `?:` keeps the #388 guarantee that an archive always carries an // og:url: get_non_singular_canonical_url() returns '' for a view it // has no canonical rule for, and the request URL is still better // than no tag at all. $data['url'] = is_search() ? get_search_link() : (self::current_archive_url() ?: $this->get_current_url()); $term = get_queried_object(); $term_id = ($term instanceof \WP_Term) ? (int) $term->term_id : 0; $og_title_override = ''; $og_description_override = ''; if ($term_id > 0) { // Terms carry the same social override keys as posts — the // abilities API and the metabox both write them. $og_title_override = Pattern_Resolver::resolve_term_value( (string) get_term_meta($term_id, '_thinkrank_og_title', true), $term_id ); $og_description_override = Pattern_Resolver::resolve_term_value( (string) get_term_meta($term_id, '_thinkrank_og_description', true), $term_id ); // Twitter Cards fall back to og:title when this is empty, so // only capture it; generate_twitter_tags() applies the fallback. $data['twitter_title'] = Pattern_Resolver::resolve_term_value( (string) get_term_meta($term_id, '_thinkrank_twitter_title', true), $term_id ); $data['twitter_description'] = Pattern_Resolver::resolve_term_value( (string) get_term_meta($term_id, '_thinkrank_twitter_description', true), $term_id ); $term_og_image = (string) get_term_meta($term_id, '_thinkrank_og_image', true); if ('' !== $term_og_image) { $data['image'] = $term_og_image; } $term_twitter_image = (string) get_term_meta($term_id, '_thinkrank_twitter_image', true); if ('' !== $term_twitter_image) { $data['twitter_image'] = $term_twitter_image; } } // Author, date and search archives have no ThinkRank-managed title // to inherit — Author_Archives_Manager owns the author one, and the // rest have no template — so the caller's fallback arrives empty and // og:title used to collapse to the bare site name. Fall through to // what the page itself is called (#388). if ($og_title_override !== '') { $data['title'] = $og_title_override; } elseif ($fallback_title !== null && $fallback_title !== '') { $data['title'] = $fallback_title; } else { $data['title'] = $this->archive_fallback_title(); } if ($og_description_override !== '') { $data['description'] = $og_description_override; } elseif ($fallback_description !== null && $fallback_description !== '') { $data['description'] = $fallback_description; } else { $data['description'] = $this->archive_fallback_description($term_id); } } return $data; } /** * The canonical URL of the archive currently being rendered. * * Deliberately the same value class-seo-manager.php puts in * <link rel="canonical">: an og:url that disagrees with the canonical is * the bug this is fixing, so the two read from one source. * * @since 2.0.1 * * @return string Archive URL, '' when there is none (search, 404). */ private static function current_archive_url(): string { if (!class_exists('\ThinkRank\Frontend\SEO_Manager')) { return ''; } return \ThinkRank\Frontend\SEO_Manager::get_non_singular_canonical_url(); } /** * A permalink with the current sub-page or comment page appended. * * @since 2.0.1 * * @param string $url Permalink. * @return string */ private static function with_singular_page(string $url): string { if ('' === $url || !class_exists('\ThinkRank\Frontend\SEO_Manager')) { return $url; } return \ThinkRank\Frontend\SEO_Manager::with_singular_page($url); } /** * The URL of the page the 'site' context is actually being rendered for. * * home_url('/') for the front page, the posts page's own permalink when the * site uses a static front page, and the paginated variant on page 2+. * * @since 2.0.1 * * @return string */ private static function current_home_url(): string { $url = home_url('/'); if (function_exists('is_home') && is_home() && !is_front_page()) { $posts_page = (int) get_option('page_for_posts'); if ($posts_page > 0) { $permalink = get_permalink($posts_page); if (is_string($permalink) && '' !== $permalink) { $url = $permalink; } } } if (!class_exists('\ThinkRank\Frontend\SEO_Manager')) { return $url; } // A static front page is a singular view, so its page number lives in // `page`, not `paged` — the canonical already reads it that way, and // og:url has to agree or the two describe different URLs. if (function_exists('is_singular') && is_singular()) { return \ThinkRank\Frontend\SEO_Manager::with_singular_page($url); } return \ThinkRank\Frontend\SEO_Manager::with_pagination( $url, \ThinkRank\Frontend\SEO_Manager::current_page_number() ); } /** * Get Open Graph image for context with proper fallback * * @since 1.0.0 * * @param array $settings Social meta settings * @param \WP_Post|null $post Optional. Post object for post-specific images * @return string Image URL or empty string */ private function get_og_image_for_context(array $settings, ?\WP_Post $post = null): string { // For posts, check featured image first if ($post) { $image_id = get_post_thumbnail_id($post); if ($image_id) { $image_data = wp_get_attachment_image_src($image_id, 'large'); if (!empty($image_data[0])) { return $image_data[0]; } } } // Fallback to configured Open Graph image if (!empty($settings['default_og_image'])) { return $settings['default_og_image']; } // Final fallback to generic default image if (!empty($settings['default_image'])) { return $settings['default_image']; } // Last-resort fallback to the site logo / site icon. Resolving it here // (rather than late inside generate_og_tags()) writes it back to the // shared $data['image'], so generate_twitter_tags() and // determine_twitter_card_type() see the same image: twitter:image is // emitted and the card is promoted to summary_large_image, and the // image is routed through optimize_image_for_platform() so // og:image:width/height/type/alt companions are produced. return $this->get_default_social_image(); } /** * Get Twitter image for context with proper fallback * * @since 1.0.0 * * @param array $settings Social meta settings * @param \WP_Post|null $post Optional. Post object for post-specific images * @return string Image URL or empty string */ private function get_twitter_image_for_context(array $settings, ?\WP_Post $post = null): string { // For posts, check for post-specific Twitter image meta first (if implemented) if ($post) { // Check for post-specific Twitter image meta (future enhancement) $post_twitter_image = get_post_meta($post->ID, '_thinkrank_twitter_image', true); if (!empty($post_twitter_image)) { return $post_twitter_image; } // Check featured image as fallback for posts $image_id = get_post_thumbnail_id($post); if ($image_id) { $image_data = wp_get_attachment_image_src($image_id, 'large'); if (!empty($image_data[0])) { return $image_data[0]; } } } // Prioritize Twitter-specific default image if (!empty($settings['default_twitter_image'])) { return $settings['default_twitter_image']; } // Fallback to Open Graph default image if (!empty($settings['default_og_image'])) { return $settings['default_og_image']; } // Final fallback to generic default image if (!empty($settings['default_image'])) { return $settings['default_image']; } return ''; } /** * Get social media description for post * * @since 1.0.0 * * @param \WP_Post $post Post object * @return string Social media description */ private function get_social_description(\WP_Post $post): string { // A password-gated body must never become a social description. Core // answers get_the_excerpt() with its "There is no excerpt because this // is a protected post." placeholder rather than the body, so today the // derive-from-content fallback below is unreachable here — but it is one // core change away from leaking, and that placeholder sentence is not a // description worth publishing to every crawler and unfurler either. // An authored post_excerpt is written for public consumption, so it // still stands (#363). if (function_exists('post_password_required') && post_password_required($post)) { return '' !== $post->post_excerpt ? $post->post_excerpt : get_bloginfo('description'); } // Try excerpt first. On a Bricks page core would derive that excerpt // from the `post_content` Bricks throws away, so the visible body is // used instead — a hand-written excerpt still wins (#651). $superseding = Builder_Content::superseding_excerpt_source($post); $description = '' !== $superseding ? Pattern_Resolver::derive_excerpt($superseding, 30) : get_the_excerpt($post); // If no excerpt, generate from content. Shortcodes and block delimiters // are removed the way core's wp_trim_excerpt() does, so a shortcode-built // page does not publish its source as og:description (#387). if (empty($description)) { $description = Pattern_Resolver::derive_excerpt(Builder_Content::visible_content($post), 30); } // If still empty, use site description if (empty($description)) { $description = get_bloginfo('description'); } return $description; } /** * Determine Open Graph type based on context * * @since 1.0.0 * * @param string $context Context type * @param array $data Content data * @return string OG type */ private function determine_og_type(string $context, array $data): string { switch ($context) { case 'post': return 'article'; case 'product': return 'product'; case 'page': case 'site': default: return 'website'; } } /** * Determine Twitter Card type based on content * * @since 1.0.0 * * @param array $data Content data * @param string $context Context type * @return string Twitter Card type */ private function determine_twitter_card_type(array $data, string $context): string { // An explicitly chosen card type wins. Without this the setting was // inert and the `app` and `player` options in the UI could never be // emitted at all (#398). $chosen = (string) ($data['twitter_card_type'] ?? ''); if ('' !== $chosen) { return $chosen; } // Use a large-image card when a Twitter image will actually be emitted. // twitter:image resolves to the twitter-specific image first, then the OG // image, so key the card type off the same precedence. $twitter_image = !empty($data['twitter_image']) ? $data['twitter_image'] : ($data['image'] ?? ''); return !empty($twitter_image) ? 'summary_large_image' : 'summary'; } /** * What an archive calls itself, for the og:title of last resort. * * Deliberately not wp_get_document_title(): that re-enters the * pre_get_document_title filter this plugin short-circuits, so it would * recurse. get_the_archive_title() wraps its subject in a <span>, hence the * strip. * * @since 2.0.1 * * @return string Archive title, or '' when there is nothing sensible to say. */ private function archive_fallback_title(): string { if (is_search()) { /* translators: %s: search query. */ return trim(sprintf(__('Search Results for "%s"', 'thinkrank'), get_search_query())); } if (!function_exists('get_the_archive_title')) { return ''; } return trim(wp_strip_all_tags((string) get_the_archive_title())); } /** * What an archive says about itself, for the og:description of last resort. * * @since 2.0.1 * * @param int $term_id Queried term, or 0 when the archive is not a term. * @return string Archive description, or '' when there is none. */ private function archive_fallback_description(int $term_id): string { if ($term_id > 0) { $description = trim(wp_strip_all_tags((string) term_description($term_id))); if ('' !== $description) { return $description; } } if (is_author()) { $bio = trim(wp_strip_all_tags((string) get_the_author_meta('description', (int) get_query_var('author')))); if ('' !== $bio) { return $bio; } } return ''; } /** * The Open Graph type the user explicitly chose, or '' when they did not. * * `og_type` ships a default of 'website' that is merged into every settings * read, so a stored 'website' cannot be told apart from "never touched" — * the same trap the `og_locale` note above documents. Treating it as unset * keeps determine_og_type() reachable, so a post is still `article`, while * any other stored value is a genuine override and wins. * * @since 2.0.1 * * @param array $settings Social meta settings. * @return string The chosen type, or '' for "derive it". */ private function configured_og_type(array $settings): string { $type = trim((string) ($settings['og_type'] ?? '')); return ('' === $type || 'website' === $type) ? '' : $type; } /** * The Twitter card type the user explicitly chose, or '' when they did not. * * Same reasoning as configured_og_type(): the shipped default is * 'summary_large_image', which is also what the automatic rule produces * whenever an image is available, so it is treated as "not chosen" and the * automatic rule stays in charge. `summary`, `app` and `player` are real * choices and are honoured. * * @since 2.0.1 * * @param array $settings Social meta settings. * @return string The chosen card type, or '' for "derive it". */ private function configured_twitter_card_type(array $settings): string { $type = trim((string) ($settings['twitter_card_type'] ?? '')); if (!in_array($type, ['summary', 'app', 'player'], true)) { return ''; } return $type; } /** * Optimize title for platform requirements * * @since 1.0.0 * * @param string $title Original title * @param string $platform Target platform * @return string Optimized title */ private function optimize_title_for_platform(string $title, string $platform): string { if (empty($title)) { return get_bloginfo('name'); } $max_length = $this->supported_platforms[$platform]['title_max_length'] ?? 60; // Multibyte-safe: byte-based substr() would split characters in // CJK/Bengali/accented titles (WP ships an mbstring fallback). if (mb_strlen($title) <= $max_length) { return $title; } // Truncate at a word boundary where there is one, and hard-cut where // there is not. This used to try wp_trim_words() first, which counts // CHARACTERS on th/ja/zh_* — so it returned ~10 characters, passed the // $max_length check below, and that was accepted as the title (#687). return \ThinkRank\Core\Seo_Text::trim_to_length($title, $max_length); } /** * Optimize description for platform requirements * * @since 1.0.0 * * @param string $description Original description * @param string $platform Target platform * @return string Optimized description */ private function optimize_description_for_platform(string $description, string $platform): string { if (empty($description)) { return get_bloginfo('description'); } $max_length = $this->supported_platforms[$platform]['description_max_length'] ?? 160; // Multibyte-safe: byte-based substr() would split characters in // CJK/Bengali/accented descriptions (WP ships an mbstring fallback). if (mb_strlen($description) <= $max_length) { return $description; } // Truncate at a word boundary where there is one, and hard-cut where // there is not. This used to try wp_trim_words() first, which counts // words in English but CHARACTERS in th/ja/zh_* — so on those locales // it returned ~25 characters, comfortably under $max_length, and that // was accepted as the answer (#687). return \ThinkRank\Core\Seo_Text::trim_to_length($description, $max_length); } /** * Optimize image for platform requirements * * @since 1.0.0 * * @param string $image_url Image URL * @param string $platform Target platform * @return array Optimized image data */ private function optimize_image_for_platform(string $image_url, string $platform): array { $image_data = [ 'url' => $image_url, 'width' => '', 'height' => '', 'alt' => '', 'type' => '', 'valid' => false, 'warnings' => [] ]; if (empty($image_url)) { return $image_data; } // Get image metadata $attachment_id = attachment_url_to_postid($image_url); if ($attachment_id) { $image_meta = wp_get_attachment_metadata($attachment_id); $image_alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true); $mime_type = get_post_mime_type($attachment_id); if ($image_meta && isset($image_meta['width'], $image_meta['height'])) { $width = (int) $image_meta['width']; $height = (int) $image_meta['height']; $image_data['alt'] = $image_alt ?: ''; $image_data['type'] = $mime_type ?: ''; // SVGs and other vector uploads store 0x0 metadata. Dimension // checks are meaningless there and dividing by 0 is fatal. if ($width > 0 && $height > 0) { $image_data['width'] = $width; $image_data['height'] = $height; // Validate against platform requirements $platform_spec = $this->supported_platforms[$platform] ?? []; $min_width = $platform_spec['image_min_width'] ?? 300; $min_height = $platform_spec['image_min_height'] ?? 200; if ($width >= $min_width && $height >= $min_height) { $image_data['valid'] = true; } else { $image_data['warnings'][] = "Image dimensions ({$width}x{$height}) are below recommended minimum ({$min_width}x{$min_height}) for {$platform}"; } // Check aspect ratio if specified if (isset($platform_spec['image_recommended_ratio'])) { $actual_ratio = $width / $height; $recommended_ratio = $platform_spec['image_recommended_ratio']; $ratio_tolerance = 0.1; if (abs($actual_ratio - $recommended_ratio) > $ratio_tolerance) { $image_data['warnings'][] = sprintf( "Image aspect ratio (%.2f) differs from recommended ratio (%.2f) for %s", $actual_ratio, $recommended_ratio, $platform ); } } } } } return $image_data; } /** * Get default social image * * @since 1.0.0 * * @return string Default social image URL */ private function get_default_social_image(): string { // Try custom logo first $custom_logo_id = get_theme_mod('custom_logo'); if ($custom_logo_id) { $logo_data = wp_get_attachment_image_src($custom_logo_id, 'large'); if ($logo_data) { return $logo_data[0]; } } // Try site icon $site_icon_id = get_option('site_icon'); if ($site_icon_id) { $icon_data = wp_get_attachment_image_src($site_icon_id, 'large'); if ($icon_data) { return $icon_data[0]; } } return ''; } /** * Get Open Graph locale * * @since 1.0.0 * * @return string OG locale */ private function get_og_locale(): string { /** * Filter the locale used for og:locale. * * Defaults to get_locale(), which only tracks the active language once * that language's translation files are installed — on a multilingual * site without them every translated URL still reports the default * locale. The multilingual integration answers with the locale its * provider reports for the language actually being viewed. * * @since 1.23.0 * * @param string $locale Locale for the current request. */ $locale = (string) apply_filters('thinkrank_og_locale', get_locale()); // Convert WordPress locale to OG locale format for the few that differ // from the xx_YY form (e.g. bare 'ja'). $og_locale_map = [ 'ja' => 'ja_JP', ]; if (isset($og_locale_map[$locale])) { return $og_locale_map[$locale]; } // Fall back to the actual site locale (normalized to xx_YY) rather than // mislabeling every unmapped language as en_US. if (preg_match('/^[a-z]{2,3}_[A-Z]{2}$/', $locale)) { return $locale; } // Bare language code (e.g. 'nl') → best-effort xx_XX. if (preg_match('/^([a-z]{2,3})$/', $locale, $m)) { return $m[1] . '_' . strtoupper($m[1]); } return 'en_US'; } /** * Get current URL * * @since 1.0.0 * * @return string Current URL */ private function get_current_url(): string { if (is_admin()) { return home_url(); } global $wp; return home_url(add_query_arg([], $wp->request)); } /** * Get Twitter site handle * * @since 1.0.0 * * @return string Twitter site handle */ private function get_twitter_site_handle(): string { $settings = $this->get_settings('site'); $username = $settings['twitter_username'] ?? ''; if (empty($username)) { return ''; } // Ensure username starts with @ for meta tag output return '@' . ltrim($username, '@'); } /** * Get Twitter creator handle * * @since 1.0.0 * * @return string Twitter creator handle */ private function get_twitter_creator_handle(): string { $settings = $this->get_settings('site'); $creator = $settings['twitter_creator'] ?? ''; if (empty($creator)) { return ''; } // Ensure creator starts with @ for meta tag output return '@' . ltrim($creator, '@'); } /** * Add context-specific Open Graph tags * * @since 1.0.0 * * @param array $og_tags OG tags array * @param string $context Context type * @param array $data Content data * @param string $og_type OG type * @return array Updated OG tags */ private function add_context_specific_og_tags(array $og_tags, string $context, array $data, string $og_type): array { switch ($og_type) { case 'article': if (!empty($data['author']['name'])) { $og_tags['article:author'] = $data['author']['name']; } if (!empty($data['published_time'])) { $og_tags['article:published_time'] = $data['published_time']; } if (!empty($data['modified_time'])) { $og_tags['article:modified_time'] = $data['modified_time']; } // article:section — the post's primary category (parity with the // basic emitter, which the active path previously omitted). if (!empty($data['post_id'])) { $categories = get_the_category((int) $data['post_id']); if (!empty($categories) && !is_wp_error($categories)) { $og_tags['article:section'] = $categories[0]->name; } } break; case 'product': // Product-specific tags would be added here // This could be extended with price, availability, etc. break; } // Add local business Open Graph tags if business data is available $og_tags = $this->add_local_business_og_tags($og_tags, $data); return $og_tags; } /** * Add local business Open Graph tags * * @since 1.0.0 * * @param array $og_tags OG tags array * @param array $data Content data * @return array Updated OG tags with local business information */ private function add_local_business_og_tags(array $og_tags, array $data): array { // Get business data from Site Identity settings $business_data = $this->get_local_business_data(); if (empty($business_data) || empty($business_data['business_name'])) { return $og_tags; } // Add business contact data Open Graph tags if (!empty($business_data['business_address'])) { $og_tags['business:contact_data:street_address'] = $business_data['business_address']; } if (!empty($business_data['business_city'])) { $og_tags['business:contact_data:locality'] = $business_data['business_city']; } if (!empty($business_data['business_state'])) { $og_tags['business:contact_data:region'] = $business_data['business_state']; } if (!empty($business_data['business_postal_code'])) { $og_tags['business:contact_data:postal_code'] = $business_data['business_postal_code']; } if (!empty($business_data['business_country'])) { $og_tags['business:contact_data:country_name'] = $business_data['business_country']; } if (!empty($business_data['business_phone'])) { $og_tags['business:contact_data:phone_number'] = $business_data['business_phone']; } if (!empty($business_data['business_email'])) { $og_tags['business:contact_data:email'] = $business_data['business_email']; } // Add business hours if available if (!empty($business_data['business_hours']) && is_array($business_data['business_hours'])) { $formatted_hours = $this->format_business_hours_for_og($business_data['business_hours']); if (!empty($formatted_hours)) { $og_tags['business:hours'] = $formatted_hours; } } // Add business website if (!empty($business_data['business_website'])) { $og_tags['business:contact_data:website'] = $business_data['business_website']; } // Add coordinates if available if (!empty($business_data['business_latitude']) && !empty($business_data['business_longitude'])) { $og_tags['place:location:latitude'] = $business_data['business_latitude']; $og_tags['place:location:longitude'] = $business_data['business_longitude']; } return $og_tags; } /** * Get local business data from Site Identity settings * * @since 1.0.0 * * @return array Business data array */ private function get_local_business_data(): array { // Try to get Site Identity Manager if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) { require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-site-identity-manager.php'; } $site_identity_manager = new \ThinkRank\SEO\Site_Identity_Manager(); $settings = $site_identity_manager->get_settings('site'); // Only return data if local SEO is enabled if (empty($settings['local_seo_enabled'])) { return []; } return [ 'business_name' => $settings['business_name'] ?? '', 'business_address' => $settings['business_address'] ?? '', 'business_city' => $settings['business_city'] ?? '', 'business_state' => $settings['business_state'] ?? '', 'business_postal_code' => $settings['business_postal_code'] ?? '', 'business_country' => $settings['business_country'] ?? '', 'business_phone' => $settings['business_phone'] ?? '', 'business_email' => $settings['business_email'] ?? '', 'business_hours' => $settings['business_hours'] ?? [], 'business_website' => $settings['business_website'] ?? home_url(), 'business_latitude' => $settings['business_latitude'] ?? '', 'business_longitude' => $settings['business_longitude'] ?? '' ]; } /** * Format business hours for Open Graph tags * * @since 1.0.0 * * @param array $business_hours Business hours array * @return string Formatted hours string */ private function format_business_hours_for_og(array $business_hours): string { $formatted_days = []; $day_names = [ 'monday' => 'Monday', 'tuesday' => 'Tuesday', 'wednesday' => 'Wednesday', 'thursday' => 'Thursday', 'friday' => 'Friday', 'saturday' => 'Saturday', 'sunday' => 'Sunday' ]; foreach ($day_names as $day => $display_name) { if (isset($business_hours[$day]) && !empty($business_hours[$day])) { $day_data = $business_hours[$day]; if (!empty($day_data['closed']) || empty($day_data['open']) || empty($day_data['close'])) { $formatted_days[] = $display_name . ': Closed'; } else { $formatted_days[] = $display_name . ': ' . $day_data['open'] . ' - ' . $day_data['close']; } } } return implode('; ', $formatted_days); } /** * Apply platform-specific optimizations * * @since 1.0.0 * * @param array $og_tags OG tags array * @param string $platform Target platform * @param array $data Content data * @return array Optimized OG tags */ private function apply_platform_specific_optimizations(array $og_tags, string $platform, array $data): array { $settings = $this->get_settings('site'); switch ($platform) { case 'linkedin': // LinkedIn prefers professional content if ($settings['enable_linkedin'] ?? false) { if (isset($og_tags['og:description'])) { $og_tags['og:description'] = $this->make_description_professional($og_tags['og:description']); } // Add LinkedIn-specific type if appropriate if ($og_tags['og:type'] === 'article') { $og_tags['article:author'] = $data['author'] ?? ''; } } break; case 'pinterest': // Pinterest prefers descriptive content if ($settings['enable_pinterest'] ?? false) { if (isset($og_tags['og:description'])) { $og_tags['og:description'] = $this->make_description_descriptive($og_tags['og:description']); } // Pinterest prefers article type for rich pins if (in_array($og_tags['og:type'], ['website', 'blog'], true)) { $og_tags['og:type'] = 'article'; } } break; case 'instagram': // Instagram optimizations if ($settings['enable_instagram'] ?? false) { // Instagram prefers square or vertical images if (isset($og_tags['og:description'])) { $og_tags['og:description'] = $this->make_description_engaging($og_tags['og:description']); } } break; case 'tiktok': // TikTok optimizations if ($settings['enable_tiktok'] ?? false) { // TikTok prefers short, catchy descriptions if (isset($og_tags['og:description'])) { $og_tags['og:description'] = $this->make_description_catchy($og_tags['og:description']); } } break; } return $og_tags; } /** * Make description more professional for LinkedIn * * @since 1.0.0 * * @param string $description Original description * @return string Professional description */ private function make_description_professional(string $description): string { // Remove casual language and emojis, add professional tone $description = preg_replace('/[^\w\s\.,!?-]/', '', $description); // Add professional keywords if not present $professional_words = ['insights', 'expertise', 'professional', 'industry', 'business']; if (!preg_match('/\b(' . implode('|', $professional_words) . ')\b/i', $description)) { $description = 'Professional insights: ' . $description; } return $description; } /** * Make description more descriptive for Pinterest * * @since 1.0.0 * * @param string $description Original description * @return string Descriptive description */ private function make_description_descriptive(string $description): string { // Pinterest users love detailed, searchable descriptions $descriptive_words = ['discover', 'explore', 'learn', 'find', 'ideas']; if (!preg_match('/\b(' . implode('|', $descriptive_words) . ')\b/i', $description)) { $description = 'Discover ' . lcfirst($description); } return $description; } /** * Make description engaging for Instagram * * @since 1.0.0 * * @param string $description Original description * @return string Engaging description */ private function make_description_engaging(string $description): string { // Instagram prefers engaging, visual language $engaging_words = ['amazing', 'stunning', 'beautiful', 'incredible', 'inspiring']; if (!preg_match('/\b(' . implode('|', $engaging_words) . ')\b/i', $description)) { $description = '✨ ' . $description; } return $description; } /** * Make description catchy for TikTok * * @since 1.0.0 * * @param string $description Original description * @return string Catchy description */ private function make_description_catchy(string $description): string { // TikTok prefers short, catchy descriptions $catchy_words = ['viral', 'trending', 'must-see', 'epic', 'mind-blowing']; // Limit to 100 characters for TikTok. strlen()/substr() count BYTES, // so this both fired three times too early on Thai/CJK text and cut // mid-character, emitting a broken UTF-8 sequence rather than a short // description (#687). $description = \ThinkRank\Core\Seo_Text::trim_to_length($description, 100); if (!preg_match('/\b(' . implode('|', $catchy_words) . ')\b/i', $description)) { $description = '🔥 ' . $description; } return $description; } /** * Add Twitter Card specific tags * * @since 1.0.0 * * @param array $twitter_tags Twitter tags array * @param string $card_type Card type * @param array $data Content data * @param string $context Context type * @return array Updated Twitter tags */ private function add_twitter_card_specific_tags(array $twitter_tags, string $card_type, array $data, string $context): array { // The content extractor only produces summary / summary_large_image // cards, so the player/app card variants were dead code that read keys // (video_url, app_name, …) the extractor never sets. Kept as a filterable // extension point for add-ons that do populate richer card data. return apply_filters('thinkrank_twitter_card_specific_tags', $twitter_tags, $card_type, $data, $context); } /** * Generate Open Graph preview * * @since 1.0.0 * * @param array $og_tags OG tags * @param string $platform Platform name * @param array $preview Preview array * @return array Updated preview */ private function generate_og_preview(array $og_tags, string $platform, array $preview): array { $preview['title'] = $og_tags['og:title'] ?? ''; $preview['description'] = $og_tags['og:description'] ?? ''; $preview['image'] = $og_tags['og:image'] ?? ''; $preview['preview_url'] = $og_tags['og:url'] ?? ''; // Validate required fields $required_fields = ['og:title', 'og:type', 'og:image', 'og:url']; $missing_fields = []; foreach ($required_fields as $field) { if (empty($og_tags[$field])) { $missing_fields[] = $field; } } if (empty($missing_fields)) { $preview['valid'] = true; } else { $preview['warnings'][] = 'Missing required fields: ' . implode(', ', $missing_fields); } // Platform-specific validation $platform_spec = $this->supported_platforms[$platform] ?? []; if (isset($platform_spec['title_max_length']) && strlen($preview['title']) > $platform_spec['title_max_length']) { $preview['warnings'][] = "Title exceeds {$platform} maximum length of {$platform_spec['title_max_length']} characters"; } return $preview; } /** * Generate Twitter preview * * @since 1.0.0 * * @param array $twitter_tags Twitter tags * @param array $preview Preview array * @return array Updated preview */ private function generate_twitter_preview(array $twitter_tags, array $preview): array { $preview['title'] = $twitter_tags['twitter:title'] ?? ''; $preview['description'] = $twitter_tags['twitter:description'] ?? ''; $preview['image'] = $twitter_tags['twitter:image'] ?? ''; // Validate required fields $required_fields = ['twitter:card', 'twitter:title']; $missing_fields = []; foreach ($required_fields as $field) { if (empty($twitter_tags[$field])) { $missing_fields[] = $field; } } if (empty($missing_fields)) { $preview['valid'] = true; } else { $preview['warnings'][] = 'Missing required fields: ' . implode(', ', $missing_fields); } // Twitter-specific validation $twitter_spec = $this->supported_platforms['twitter']; if (strlen($preview['title']) > $twitter_spec['title_max_length']) { $preview['warnings'][] = "Title exceeds Twitter maximum length of {$twitter_spec['title_max_length']} characters"; } return $preview; } /** * Generate Pinterest preview * * @since 1.0.0 * * @param array $og_tags OG tags * @param array $preview Preview array * @return array Updated preview */ private function generate_pinterest_preview(array $og_tags, array $preview): array { $preview['title'] = $og_tags['og:title'] ?? ''; $preview['description'] = $og_tags['og:description'] ?? ''; $preview['image'] = $og_tags['og:image'] ?? ''; $preview['preview_url'] = $og_tags['og:url'] ?? ''; // Validate required fields for Pinterest $required_fields = ['og:title', 'og:image', 'og:url']; $missing_fields = []; foreach ($required_fields as $field) { if (empty($og_tags[$field])) { $missing_fields[] = $field; } } if (empty($missing_fields)) { $preview['valid'] = true; } else { $preview['warnings'][] = 'Missing required fields: ' . implode(', ', $missing_fields); } // Pinterest-specific validation and suggestions $pinterest_spec = $this->supported_platforms['pinterest'] ?? []; // Title length validation if (isset($pinterest_spec['title_max_length']) && strlen($preview['title']) > $pinterest_spec['title_max_length']) { $preview['warnings'][] = "Title exceeds Pinterest maximum length of {$pinterest_spec['title_max_length']} characters"; } // Description length validation if (isset($pinterest_spec['description_max_length']) && strlen($preview['description']) > $pinterest_spec['description_max_length']) { $preview['warnings'][] = "Description exceeds Pinterest maximum length of {$pinterest_spec['description_max_length']} characters"; } // Image dimension suggestions for Pinterest if (!empty($preview['image'])) { $image_data = $this->optimize_image_for_platform($preview['image'], 'pinterest'); if (!empty($image_data['width']) && !empty($image_data['height'])) { $aspect_ratio = $image_data['width'] / $image_data['height']; // Pinterest prefers vertical images (2:3 ratio is optimal) if ($aspect_ratio > 1) { $preview['suggestions'][] = 'Pinterest performs better with vertical images (2:3 aspect ratio recommended)'; } elseif ($aspect_ratio < 0.6) { $preview['suggestions'][] = 'Image is very tall - consider a 2:3 aspect ratio for optimal Pinterest performance'; } // Minimum size recommendations if ($image_data['width'] < 600) { $preview['suggestions'][] = 'Pinterest recommends images at least 600px wide for better quality'; } } } return $preview; } /** * Generate platform-specific meta tags * * Platform IDs and verification codes are owned by the Social Platforms tab * (ThinkRank\API\Social_Platforms_Endpoint), which stores them in core * Settings (wp_options) with the sensitive codes encrypted at rest. The * social_meta settings table this manager normally reads no longer receives * these values from the UI, so we resolve each key from core Settings first * and fall back to any legacy value still present in the passed-in table * settings — otherwise the verification tags would never render. * * @since 1.0.0 * * @param array $settings Settings array (social_meta table) — legacy fallback. * @return array Platform-specific meta tags */ private function generate_platform_meta_tags(array $settings): array { $platform_tags = []; $core = \ThinkRank\Core\Settings::instance(); // One query for all seven instead of one query each. They are // autoload=off like every thinkrank_* option, so WordPress cannot // batch them out of `alloptions`, and this runs on every anonymous // front-end request — on most sites to discover that all seven are // empty and no tag is emitted at all (#393). $core->prime(array_keys(self::PLATFORM_META_KEYS)); // Core Settings (decrypted for sensitive keys) wins; the table value is a // backward-compat fallback for installs that saved these before the UI // moved to the Social Platforms tab. $resolve = static function (string $key) use ($core, $settings): string { $value = (string) $core->get($key, ''); if ('' === $value) { $value = (string) ($settings[$key] ?? ''); } return $value; }; foreach (self::PLATFORM_META_KEYS as $key => $meta_name) { $value = $resolve($key); if ('' !== $value) { $platform_tags[$meta_name] = $value; } } return $platform_tags; } /** * Platform verification settings, mapped to the meta name each is emitted * under. One list so the batch primed in generate_platform_meta_tags() and * the keys it then reads cannot drift apart. * * @since 2.1.0 * @var array<string,string> */ private const PLATFORM_META_KEYS = [ 'facebook_app_id' => 'fb:app_id', 'facebook_admins' => 'fb:admins', 'pinterest_site_verification' => 'pinterest-site-verification', 'instagram_verification' => 'instagram-site-verification', 'tiktok_verification' => 'tiktok-site-verification', 'youtube_channel_id' => 'youtube-channel-id', 'whatsapp_business_id' => 'whatsapp-business-id', ]; /** * Convert OG, Twitter, and Platform tags to HTML meta tags * * @since 1.0.0 * * @param array $og_tags Open Graph tags * @param array $twitter_tags Twitter tags * @param array $platform_tags Platform-specific tags * @return array HTML meta tags */ private function convert_to_meta_tags(array $og_tags, array $twitter_tags, array $platform_tags = []): array { $meta_tags = []; // Convert OG tags foreach ($og_tags as $property => $content) { if (!empty($content)) { $meta_tags[] = [ 'property' => esc_attr($property), 'content' => esc_attr($content) ]; } } // Convert Twitter tags foreach ($twitter_tags as $name => $content) { if (!empty($content)) { $meta_tags[] = [ 'name' => esc_attr($name), 'content' => esc_attr($content) ]; } } // Convert Platform tags foreach ($platform_tags as $name => $content) { if (!empty($content)) { // Determine if it should be property or name attribute if (strpos($name, 'fb:') === 0) { // Facebook tags use property attribute $meta_tags[] = [ 'property' => esc_attr($name), 'content' => esc_attr($content) ]; } else { // Other platform tags use name attribute $meta_tags[] = [ 'name' => esc_attr($name), 'content' => esc_attr($content) ]; } } } return $meta_tags; } /** * Validate social image * * @since 1.0.0 * * @param string $image_url Image URL to validate * @return array Validation results */ private function validate_social_image(string $image_url): array { $validation = [ 'valid' => false, 'warnings' => [], 'suggestions' => [] ]; if (empty($image_url)) { $validation['warnings'][] = 'No image provided'; return $validation; } // Check if URL is valid if (!filter_var($image_url, FILTER_VALIDATE_URL)) { $validation['warnings'][] = 'Invalid image URL'; return $validation; } // Get image metadata if it's a local attachment $attachment_id = attachment_url_to_postid($image_url); if ($attachment_id) { $image_meta = wp_get_attachment_metadata($attachment_id); $meta_width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0; $meta_height = isset($image_meta['height']) ? (int) $image_meta['height'] : 0; // SVGs and other vector uploads store 0x0 metadata — skip the // dimension/ratio checks instead of dividing by 0. if ($image_meta && $meta_width > 0 && $meta_height > 0) { // Check minimum dimensions for major platforms $min_width = 600; // Facebook minimum $min_height = 315; // Facebook minimum if ($meta_width >= $min_width && $meta_height >= $min_height) { $validation['valid'] = true; } else { $validation['warnings'][] = "Image dimensions ({$meta_width}x{$meta_height}) are below recommended minimum ({$min_width}x{$min_height})"; } // Check file size $file_path = get_attached_file($attachment_id); if ($file_path && file_exists($file_path)) { $file_size = filesize($file_path); $max_size = 5 * 1024 * 1024; // 5MB if ($file_size > $max_size) { $validation['warnings'][] = 'Image file size exceeds 5MB, may not display on some platforms'; } } // Check aspect ratio $aspect_ratio = $meta_width / $meta_height; if ($aspect_ratio < 1.5 || $aspect_ratio > 2.5) { $validation['suggestions'][] = 'Consider using an image with 1.91:1 aspect ratio for optimal display'; } } } else { $validation['suggestions'][] = 'External images may not be optimized for social media platforms'; } return $validation; } }