PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.1.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.1.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / seo / class-social-meta-manager.php

class-social-meta-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.1.0, at includes/seo/class-social-meta-manager.php

2,989 lines 119.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Social Meta Manager Class
4 *
5 * Universal social media meta tag generation and optimization for all platforms.
6 * Implements 2025 social media SEO best practices with real Open Graph and Twitter Card
7 * specifications, social image optimization, and platform-specific handling.
8 *
9 * @package ThinkRank
10 * @subpackage SEO
11 * @since 1.0.0
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\SEO;
17
18 // Prevent direct access
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * Social Meta Manager Class
25 *
26 * Generates and validates social media meta tags for all supported platforms.
27 * Provides context-aware social meta generation with image optimization and
28 * platform-specific meta tag handling.
29 *
30 * @since 1.0.0
31 */
32 class Social_Meta_Manager extends Abstract_SEO_Manager {
33
34 /**
35 * Supported social media platforms with their specifications
36 *
37 * @since 1.0.0
38 * @var array
39 */
40 private array $supported_platforms = [
41 'facebook' => [
42 'og_required' => ['og:title', 'og:type', 'og:image', 'og:url'],
43 'og_recommended' => ['og:description', 'og:site_name', 'og:locale'],
44 'og_optional' => ['og:updated_time', 'og:see_also', 'og:video', 'og:audio'],
45 'image_min_width' => 600,
46 'image_min_height' => 315,
47 'image_recommended_ratio' => 1.91,
48 'title_max_length' => 60,
49 'description_max_length' => 160
50 ],
51 'twitter' => [
52 'card_types' => ['summary', 'summary_large_image', 'app', 'player'],
53 'required' => ['twitter:card', 'twitter:title'],
54 'recommended' => ['twitter:description', 'twitter:image', 'twitter:site'],
55 'optional' => ['twitter:creator', 'twitter:player', 'twitter:app:name'],
56 'image_min_width' => 300,
57 'image_min_height' => 157,
58 'image_max_size' => 5242880, // 5MB
59 'title_max_length' => 70,
60 'description_max_length' => 200
61 ],
62 'linkedin' => [
63 'og_required' => ['og:title', 'og:type', 'og:image', 'og:url'],
64 'og_recommended' => ['og:description'],
65 'image_min_width' => 1200,
66 'image_min_height' => 627,
67 'image_recommended_ratio' => 1.91,
68 'title_max_length' => 70,
69 'description_max_length' => 160
70 ],
71 'pinterest' => [
72 'required' => ['og:title', 'og:type', 'og:image', 'og:url'],
73 'recommended' => ['og:description', 'og:site_name'],
74 'image_min_width' => 600,
75 'image_min_height' => 900,
76 'image_recommended_ratio' => 0.67, // 2:3 ratio
77 'title_max_length' => 100,
78 'description_max_length' => 500
79 ],
80 'whatsapp' => [
81 'og_required' => ['og:title', 'og:type', 'og:image', 'og:url'],
82 'og_recommended' => ['og:description'],
83 'image_min_width' => 300,
84 'image_min_height' => 200,
85 'title_max_length' => 65,
86 'description_max_length' => 160
87 ]
88 ];
89
90 /**
91 * Open Graph types with their specifications
92 *
93 * @since 1.0.0
94 * @var array
95 */
96 private array $og_types = [
97 'website' => ['og:title', 'og:type', 'og:image', 'og:url'],
98 'article' => ['og:title', 'og:type', 'og:image', 'og:url', 'article:author', 'article:published_time'],
99 'product' => ['og:title', 'og:type', 'og:image', 'og:url', 'product:price:amount', 'product:price:currency'],
100 'video' => ['og:title', 'og:type', 'og:image', 'og:url', 'og:video', 'video:duration'],
101 'music' => ['og:title', 'og:type', 'og:image', 'og:url', 'music:duration', 'music:album'],
102 'book' => ['og:title', 'og:type', 'og:image', 'og:url', 'book:author', 'book:isbn'],
103 'profile' => ['og:title', 'og:type', 'og:image', 'og:url', 'profile:first_name', 'profile:last_name']
104 ];
105
106 /**
107 * Constructor
108 *
109 * @since 1.0.0
110 */
111 public function __construct() {
112 parent::__construct('social_meta');
113 }
114
115 /**
116 * Generate Open Graph tags for content
117 *
118 * @since 1.0.0
119 *
120 * @param array $data Content data for OG generation
121 * @param string $context Context type ('site', 'post', 'page', etc.)
122 * @param string $platform Target platform ('facebook', 'linkedin', etc.)
123 * @return array Generated Open Graph tags
124 */
125 public function generate_og_tags(array $data, string $context = 'site', string $platform = 'facebook'): array {
126 $platform = strtolower($platform);
127 if (!isset($this->supported_platforms[$platform])) {
128 $platform = 'facebook'; // Default fallback
129 }
130
131 $platform_spec = $this->supported_platforms[$platform];
132 $og_tags = [];
133
134 // OG type: the type the user explicitly chose, otherwise derived from
135 // the context. This used to call determine_og_type() unconditionally,
136 // overwriting the value extract_social_content_data() had already read
137 // from the setting — so the Content Type dropdown, the abilities API
138 // and every imported og:type were silently discarded (#398).
139 $og_type = ($data['type'] ?? '') !== ''
140 ? $data['type']
141 : $this->determine_og_type($context, $data);
142 $og_tags['og:type'] = $og_type;
143
144 // Required OG tags
145 // og:title must render the full resolved Open Graph title. The 60-char
146 // cap in optimize_title_for_platform() is an SEO-title recommendation for
147 // search results and does not apply to the og:title social tag, so use the
148 // resolved title verbatim (falling back to the site name when empty).
149 $og_tags['og:title'] = ($data['title'] ?? '') !== '' ? $data['title'] : get_bloginfo('name');
150 // `?:` rather than `??`: the key is always present, seeded as '', so the
151 // null-coalesce could never reach the fallback. og:url was emitted empty
152 // and then dropped by the !empty() guard in output_social_og_tags(),
153 // which is why archives carried no og:url at all (#388).
154 $og_tags['og:url'] = ($data['url'] ?? '') !== '' ? $data['url'] : $this->get_current_url();
155
156 // Image handling with optimization
157 if (!empty($data['image'])) {
158 $optimized_image = $this->optimize_image_for_platform($data['image'], $platform);
159 $og_tags['og:image'] = $optimized_image['url'];
160 // Emit og:image:secure_url for https images (parity with the basic
161 // emitter), so crawlers that prefer the secure URL get it.
162 if (!empty($optimized_image['url']) && strpos((string) $optimized_image['url'], 'https://') === 0) {
163 $og_tags['og:image:secure_url'] = $optimized_image['url'];
164 }
165 if (!empty($optimized_image['width'])) {
166 $og_tags['og:image:width'] = $optimized_image['width'];
167 }
168 if (!empty($optimized_image['height'])) {
169 $og_tags['og:image:height'] = $optimized_image['height'];
170 }
171 if (!empty($optimized_image['type'])) {
172 $og_tags['og:image:type'] = $optimized_image['type'];
173 }
174 if (!empty($optimized_image['alt'])) {
175 $og_tags['og:image:alt'] = $optimized_image['alt'];
176 }
177 } else {
178 // Fallback to site default image
179 $default_image = $this->get_default_social_image();
180 if ($default_image) {
181 $og_tags['og:image'] = $default_image;
182 }
183 }
184
185 // Recommended OG tags
186 if (!empty($data['description'])) {
187 $og_tags['og:description'] = $this->optimize_description_for_platform($data['description'], $platform);
188 }
189
190 $og_tags['og:site_name'] = $data['site_name'] ?? get_bloginfo('name');
191 $og_tags['og:locale'] = $data['locale'] ?? $this->get_og_locale();
192
193 // Context-specific OG tags
194 $og_tags = $this->add_context_specific_og_tags($og_tags, $context, $data, $og_type);
195
196 // Platform-specific optimizations
197 $og_tags = $this->apply_platform_specific_optimizations($og_tags, $platform, $data);
198
199 return $og_tags;
200 }
201
202 /**
203 * Generate Twitter Card tags for content
204 *
205 * @since 1.0.0
206 *
207 * @param array $data Content data for Twitter Card generation
208 * @param string $context Context type ('site', 'post', 'page', etc.)
209 * @return array Generated Twitter Card tags
210 */
211 public function generate_twitter_tags(array $data, string $context = 'site'): array {
212 $twitter_tags = [];
213
214 // Determine card type based on content
215 $card_type = $this->determine_twitter_card_type($data, $context);
216 $twitter_tags['twitter:card'] = $card_type;
217
218 // Required tags. Prefer a per-post Twitter-specific title, falling back
219 // to the resolved og:title/SEO title. Render it in full — the length cap
220 // in optimize_title_for_platform() is an SEO-title recommendation for
221 // search results, not a rule for the twitter:title social tag.
222 $twitter_title = ($data['twitter_title'] ?? '') !== '' ? $data['twitter_title'] : ($data['title'] ?? '');
223 $twitter_tags['twitter:title'] = $twitter_title !== '' ? $twitter_title : get_bloginfo('name');
224
225 // Recommended tags. Prefer a per-object Twitter-specific description,
226 // falling back to the resolved OG/meta description — mirroring the
227 // twitter:title cascade above. This lookup did not exist, so a Twitter
228 // description saved on the Social tab persisted, read back, and was
229 // then dropped in favour of the OG description (#406).
230 $twitter_description = ($data['twitter_description'] ?? '') !== ''
231 ? $data['twitter_description']
232 : (string) ($data['description'] ?? '');
233 if ($twitter_description !== '') {
234 $twitter_tags['twitter:description'] = $this->optimize_description_for_platform($twitter_description, 'twitter');
235 }
236
237 // Image handling - prioritize Twitter-specific image
238 $twitter_image = !empty($data['twitter_image']) ? $data['twitter_image'] : ($data['image'] ?? '');
239 if (!empty($twitter_image)) {
240 $optimized_image = $this->optimize_image_for_platform($twitter_image, 'twitter');
241 $twitter_tags['twitter:image'] = $optimized_image['url'];
242 if (!empty($optimized_image['alt'])) {
243 $twitter_tags['twitter:image:alt'] = $optimized_image['alt'];
244 }
245 }
246
247 // Site and creator information
248 $twitter_site = $this->get_twitter_site_handle();
249 if ($twitter_site) {
250 $twitter_tags['twitter:site'] = $twitter_site;
251 }
252
253 $twitter_creator = $this->get_twitter_creator_handle();
254 if ($twitter_creator) {
255 $twitter_tags['twitter:creator'] = $twitter_creator;
256 } elseif (!empty($data['author']['twitter'])) {
257 // Fallback to author data if available
258 $twitter_tags['twitter:creator'] = $data['author']['twitter'];
259 }
260
261 // Card-specific tags
262 $twitter_tags = $this->add_twitter_card_specific_tags($twitter_tags, $card_type, $data, $context);
263
264 return $twitter_tags;
265 }
266
267 /**
268 * Optimize social image for platform requirements
269 *
270 * @since 1.0.0
271 *
272 * @param string $image_url Image URL to optimize
273 * @param string $platform Target platform
274 * @return array Optimized image data
275 */
276 public function optimize_social_image(string $image_url, string $platform): array {
277 return $this->optimize_image_for_platform($image_url, $platform);
278 }
279
280 /**
281 * Generate social media preview data
282 *
283 * @since 1.0.0
284 *
285 * @param array $data Content data
286 * @param string $platform Target platform
287 * @return array Preview data for the platform
288 */
289 public function preview_social_post(array $data, string $platform): array {
290 $preview = [
291 'platform' => $platform,
292 'valid' => false,
293 'preview_url' => '',
294 'title' => '',
295 'description' => '',
296 'image' => '',
297 'warnings' => [],
298 'suggestions' => []
299 ];
300
301 switch ($platform) {
302 case 'facebook':
303 case 'linkedin':
304 $og_tags = $this->generate_og_tags($data, 'post', $platform);
305 $preview = $this->generate_og_preview($og_tags, $platform, $preview);
306 break;
307 case 'twitter':
308 $twitter_tags = $this->generate_twitter_tags($data, 'post');
309 $preview = $this->generate_twitter_preview($twitter_tags, $preview);
310 break;
311 case 'pinterest':
312 $og_tags = $this->generate_og_tags($data, 'post', $platform);
313 $preview = $this->generate_pinterest_preview($og_tags, $preview);
314 break;
315 default:
316 $preview['warnings'][] = 'Unsupported platform for preview generation';
317 }
318
319 return $preview;
320 }
321
322 /**
323 * Validate SEO settings (implements interface)
324 *
325 * @since 1.0.0
326 *
327 * @param array $settings Settings array to validate
328 * @param string $context Optional context for focused validation
329 * @return array Validation results
330 */
331 public function validate_settings(array $settings, string $context = 'all'): array {
332 $validation = [
333 'valid' => true,
334 'errors' => [],
335 'warnings' => [],
336 'suggestions' => [],
337 'score' => 100
338 ];
339
340 // Add detailed field validation breakdown
341 $field_details = $this->get_detailed_field_validation($settings, $context);
342 $validation['field_details'] = $field_details;
343
344 // Convert field details to validation format
345 foreach ($field_details as $field) {
346 switch ($field['status']) {
347 case 'error':
348 $validation['errors'][] = $field['label'];
349 $validation['valid'] = false;
350 $validation['score'] -= 15;
351 break;
352 case 'warning':
353 $validation['warnings'][] = $field['label'];
354 $validation['score'] -= 8;
355 break;
356 case 'suggestion':
357 $validation['suggestions'][] = $field['label'];
358 $validation['score'] -= 3;
359 break;
360 }
361 }
362
363 // Ensure score doesn't go below 0
364 $validation['score'] = max(0, $validation['score']);
365
366
367 // Validate general enabled setting (Social Media tab format)
368 if (isset($settings['enabled'])) {
369 // Convert string booleans to actual booleans
370 if (is_string($settings['enabled'])) {
371 $settings['enabled'] = filter_var($settings['enabled'], FILTER_VALIDATE_BOOLEAN);
372 }
373 if (!is_bool($settings['enabled'])) {
374 $validation['errors'][] = 'enabled must be a boolean value';
375 $validation['valid'] = false;
376 }
377 }
378
379 // Validate Open Graph settings (Social Media tab format)
380 if (isset($settings['enable_open_graph'])) {
381 // Convert string booleans to actual booleans
382 if (is_string($settings['enable_open_graph'])) {
383 $settings['enable_open_graph'] = filter_var($settings['enable_open_graph'], FILTER_VALIDATE_BOOLEAN);
384 }
385 if (!is_bool($settings['enable_open_graph'])) {
386 $validation['errors'][] = 'enable_open_graph must be a boolean value';
387 $validation['valid'] = false;
388 }
389 }
390
391 // Validate Twitter Cards settings (Social Media tab format)
392 if (isset($settings['enable_twitter_cards'])) {
393 // Convert string booleans to actual booleans
394 if (is_string($settings['enable_twitter_cards'])) {
395 $settings['enable_twitter_cards'] = filter_var($settings['enable_twitter_cards'], FILTER_VALIDATE_BOOLEAN);
396 }
397 if (!is_bool($settings['enable_twitter_cards'])) {
398 $validation['errors'][] = 'enable_twitter_cards must be a boolean value';
399 $validation['valid'] = false;
400 }
401 }
402
403 // Validate Twitter settings (only when relevant)
404 if ($context === 'all' || $context === 'twitter' || $context === 'twitter-cards') {
405 // Validate Twitter username format
406 if (isset($settings['twitter_username']) && !empty($settings['twitter_username'])) {
407 $username = trim($settings['twitter_username']);
408
409 // Remove @ if present (we store without @, add @ in output)
410 $clean_username = ltrim($username, '@');
411
412 if (empty($clean_username)) {
413 $validation['errors'][] = 'twitter_username cannot be empty';
414 $validation['valid'] = false;
415 } elseif (strlen($clean_username) > 15) {
416 $validation['errors'][] = 'twitter_username must be 15 characters or less';
417 $validation['valid'] = false;
418 } elseif (!preg_match('/^[A-Za-z0-9_]+$/', $clean_username)) {
419 $validation['errors'][] = 'twitter_username can only contain letters, numbers, and underscores';
420 $validation['valid'] = false;
421 }
422
423 // Update the setting to store without @ for consistency
424 $settings['twitter_username'] = $clean_username;
425 }
426
427 // Validate Twitter creator format
428 if (isset($settings['twitter_creator']) && !empty($settings['twitter_creator'])) {
429 $creator = trim($settings['twitter_creator']);
430
431 // Remove @ if present (we store without @, add @ in output)
432 $clean_creator = ltrim($creator, '@');
433
434 if (empty($clean_creator)) {
435 $validation['errors'][] = 'twitter_creator cannot be empty';
436 $validation['valid'] = false;
437 } elseif (strlen($clean_creator) > 15) {
438 $validation['errors'][] = 'twitter_creator must be 15 characters or less';
439 $validation['valid'] = false;
440 } elseif (!preg_match('/^[A-Za-z0-9_]+$/', $clean_creator)) {
441 $validation['errors'][] = 'twitter_creator can only contain letters, numbers, and underscores';
442 $validation['valid'] = false;
443 }
444
445 // Update the setting to store without @ for consistency
446 $settings['twitter_creator'] = $clean_creator;
447 }
448
449 // Validate Twitter card type
450 if (isset($settings['twitter_card_type'])) {
451 $valid_types = ['summary', 'summary_large_image', 'app', 'player'];
452 if (!in_array($settings['twitter_card_type'], $valid_types, true)) {
453 $validation['errors'][] = 'twitter_card_type must be one of: ' . implode(', ', $valid_types);
454 $validation['valid'] = false;
455 }
456 }
457 }
458
459 // Validate OG type
460 if (isset($settings['og_type'])) {
461 $valid_types = ['website', 'article', 'book', 'profile', 'music.song', 'music.album', 'video.movie', 'video.episode'];
462 if (!in_array($settings['og_type'], $valid_types, true)) {
463 $validation['warnings'][] = 'og_type should be one of the standard Open Graph types for best compatibility';
464 }
465 }
466
467 // Validate image dimensions
468 if (isset($settings['og_image_width']) && (!is_numeric($settings['og_image_width']) || $settings['og_image_width'] < 200)) {
469 $validation['warnings'][] = 'og_image_width should be at least 200 pixels for optimal social sharing';
470 }
471
472 if (isset($settings['og_image_height']) && (!is_numeric($settings['og_image_height']) || $settings['og_image_height'] < 200)) {
473 $validation['warnings'][] = 'og_image_height should be at least 200 pixels for optimal social sharing';
474 }
475
476 // Validate description length
477 if (isset($settings['max_description_length']) && (!is_numeric($settings['max_description_length']) || $settings['max_description_length'] < 50 || $settings['max_description_length'] > 300)) {
478 $validation['warnings'][] = 'max_description_length should be between 50 and 300 characters';
479 }
480
481 // Legacy validation for backward compatibility (only when legacy fields are actually present)
482 if (isset($settings['og_enabled'])) {
483 // Convert string booleans to actual booleans
484 if (is_string($settings['og_enabled'])) {
485 $settings['og_enabled'] = filter_var($settings['og_enabled'], FILTER_VALIDATE_BOOLEAN);
486 }
487 if (!is_bool($settings['og_enabled'])) {
488 $validation['errors'][] = 'og_enabled must be a boolean value';
489 $validation['valid'] = false;
490 }
491 }
492
493 if (isset($settings['twitter_enabled'])) {
494 // Convert string booleans to actual booleans
495 if (is_string($settings['twitter_enabled'])) {
496 $settings['twitter_enabled'] = filter_var($settings['twitter_enabled'], FILTER_VALIDATE_BOOLEAN);
497 }
498 if (!is_bool($settings['twitter_enabled'])) {
499 $validation['errors'][] = 'twitter_enabled must be a boolean value';
500 $validation['valid'] = false;
501 }
502 }
503
504 // Validate Twitter card type
505 if (isset($settings['twitter_card_type'])) {
506 $valid_card_types = $this->supported_platforms['twitter']['card_types'];
507 if (!in_array($settings['twitter_card_type'], $valid_card_types, true)) {
508 $validation['errors'][] = 'Invalid Twitter card type. Must be one of: ' . implode(', ', $valid_card_types);
509 $validation['valid'] = false;
510 }
511 }
512
513 // Validate default image
514 if (isset($settings['default_image']) && !empty($settings['default_image'])) {
515 if (!filter_var($settings['default_image'], FILTER_VALIDATE_URL)) {
516 $validation['errors'][] = 'default_image must be a valid URL';
517 $validation['valid'] = false;
518 } else {
519 // Check image dimensions and format
520 $image_validation = $this->validate_social_image($settings['default_image']);
521 if (!$image_validation['valid']) {
522 $validation['warnings'] = array_merge($validation['warnings'], $image_validation['warnings']);
523 }
524 }
525 }
526
527 // Validate Twitter site handle
528 if (isset($settings['twitter_site']) && !empty($settings['twitter_site'])) {
529 if (!preg_match('/^@[a-zA-Z0-9_]{1,15}$/', $settings['twitter_site'])) {
530 $validation['errors'][] = 'twitter_site must be a valid Twitter handle (e.g., @username)';
531 $validation['valid'] = false;
532 }
533 }
534
535 // Validate custom OG tags
536 if (isset($settings['custom_og_tags']) && !empty($settings['custom_og_tags'])) {
537 if (!is_array($settings['custom_og_tags'])) {
538 $validation['errors'][] = 'custom_og_tags must be an array';
539 $validation['valid'] = false;
540 } else {
541 foreach ($settings['custom_og_tags'] as $property => $content) {
542 if (!is_string($property) || !is_string($content)) {
543 $validation['errors'][] = 'Custom OG tags must have string property names and content';
544 $validation['valid'] = false;
545 break;
546 }
547 }
548 }
549 }
550
551 // Validate platform verification codes / IDs (Instagram, TikTok, YouTube,
552 // WhatsApp, Facebook App ID, Pinterest) against the shared format rules.
553 // Single source of truth — also enforced on the Social Platforms REST save
554 // path via self::validate_platform_field(). See get_platform_field_validation_rules().
555 foreach (self::get_platform_field_validation_rules() as $field_key => $rule) {
556 if (isset($settings[$field_key]) && $settings[$field_key] !== '') {
557 $error = self::validate_platform_field($field_key, $settings[$field_key]);
558 if ($error !== null) {
559 $validation['errors'][] = $error;
560 $validation['valid'] = false;
561 }
562 }
563 }
564
565 return $validation;
566 }
567
568 /**
569 * Format-validation rules for social platform verification codes / IDs.
570 *
571 * Single source of truth for the per-field format constraints, shared by
572 * validate_settings() and the Social Platforms REST endpoint
573 * (ThinkRank\API\Social_Platforms_Endpoint) so a value that is accepted on
574 * one path is accepted on the other. These mirror the `pattern` entries in
575 * get_settings_schema(); sanitization strips unsafe characters but does not
576 * enforce shape, so these rules back it with real validation.
577 *
578 * @since 1.14.0
579 *
580 * @return array<string, array{pattern: string, message: string}> Map of field key => rule.
581 */
582 public static function get_platform_field_validation_rules(): array {
583 return [
584 'facebook_app_id' => [
585 'pattern' => '/^[0-9]+$/',
586 'message' => 'Facebook App ID must contain digits only.',
587 ],
588 'pinterest_site_verification' => [
589 'pattern' => '/^[a-f0-9]{32}$/',
590 'message' => 'Pinterest site verification must be a 32-character hexadecimal string.',
591 ],
592 'instagram_verification' => [
593 'pattern' => '/^[a-zA-Z0-9_-]{20,}$/',
594 'message' => 'Instagram verification must be at least 20 characters (letters, numbers, underscore, dash).',
595 ],
596 'tiktok_verification' => [
597 'pattern' => '/^[a-zA-Z0-9_-]{20,}$/',
598 'message' => 'TikTok verification must be at least 20 characters (letters, numbers, underscore, dash).',
599 ],
600 'youtube_channel_id' => [
601 'pattern' => '/^UC[a-zA-Z0-9_-]{22}$/',
602 'message' => 'YouTube channel ID must start with "UC" followed by 22 characters (letters, numbers, underscore, dash).',
603 ],
604 'whatsapp_business_id' => [
605 'pattern' => '/^[0-9]{10,15}$/',
606 'message' => 'WhatsApp Business ID must be 10-15 digits.',
607 ],
608 ];
609 }
610
611 /**
612 * Validate a single social platform field value against its shared format rule.
613 *
614 * Returns null for fields with no format rule (e.g. facebook_admins) and for
615 * empty values, so callers can validate an arbitrary settings map and only
616 * act on genuine format violations.
617 *
618 * @since 1.14.0
619 *
620 * @param string $key Field key.
621 * @param mixed $value Field value.
622 * @return string|null Error message when the value violates the format, null otherwise.
623 */
624 public static function validate_platform_field(string $key, $value): ?string {
625 $rules = self::get_platform_field_validation_rules();
626
627 if (!isset($rules[$key]) || $value === '' || $value === null) {
628 return null;
629 }
630
631 if (!preg_match($rules[$key]['pattern'], (string) $value)) {
632 return $rules[$key]['message'];
633 }
634
635 return null;
636 }
637
638 /**
639 * Get detailed field validation breakdown
640 *
641 * @since 1.0.0
642 *
643 * @param array $settings Settings array to validate
644 * @param string $context Context for specific validation
645 * @return array Detailed field validation results
646 */
647 private function get_detailed_field_validation(array $settings, string $context = 'all'): array {
648 $field_details = [];
649
650 // Return tab-specific validation based on context
651 switch ($context) {
652 case 'open-graph':
653 return $this->validate_open_graph_fields($settings);
654 case 'twitter-cards':
655 return $this->validate_twitter_fields($settings);
656 default:
657 // For 'all' or unknown context, return only Social Media fields (Open Graph + Twitter)
658 $field_details = array_merge($field_details, $this->validate_open_graph_fields($settings));
659 $field_details = array_merge($field_details, $this->validate_twitter_fields($settings));
660 return $field_details;
661 }
662 }
663
664 /**
665 * Validate Open Graph fields
666 *
667 * @since 1.0.0
668 *
669 * @param array $settings Settings array to validate
670 * @return array Open Graph field validation results
671 */
672 private function validate_open_graph_fields(array $settings): array {
673 $field_details = [];
674
675 // Open Graph Enabled
676 if (!empty($settings['enable_open_graph'])) {
677 $field_details[] = [
678 'field' => 'enable_open_graph',
679 'label' => 'Open Graph is enabled for social media sharing.',
680 'status' => 'valid',
681 'icon' => ''
682 ];
683
684 // Site Name validation
685 if (!empty($settings['og_site_name'])) {
686 if (strlen($settings['og_site_name']) <= 60) {
687 $field_details[] = [
688 'field' => 'og_site_name',
689 'label' => 'Site name is properly configured for Open Graph.',
690 'status' => 'valid',
691 'icon' => ''
692 ];
693 } else {
694 $field_details[] = [
695 'field' => 'og_site_name',
696 'label' => 'Site name is longer than 60 characters, may be truncated.',
697 'status' => 'warning',
698 'icon' => ''
699 ];
700 }
701 } else {
702 $field_details[] = [
703 'field' => 'og_site_name',
704 'label' => 'Site name is required for Open Graph.',
705 'status' => 'error',
706 'icon' => ''
707 ];
708 }
709
710 // Description validation
711 if (!empty($settings['og_description'])) {
712 $length = strlen($settings['og_description']);
713 if ($length >= 120 && $length <= 160) {
714 $field_details[] = [
715 'field' => 'og_description',
716 'label' => 'Description is properly configured for social sharing.',
717 'status' => 'valid',
718 'icon' => ''
719 ];
720 } else {
721 $field_details[] = [
722 'field' => 'og_description',
723 'label' => 'Description length could be optimized (120-160 characters recommended).',
724 'status' => 'warning',
725 'icon' => ''
726 ];
727 }
728 } else {
729 $field_details[] = [
730 'field' => 'og_description',
731 'label' => 'Description is recommended for better social sharing.',
732 'status' => 'warning',
733 'icon' => ''
734 ];
735 }
736
737 // Content Type validation
738 if (!empty($settings['og_type'])) {
739 $field_details[] = [
740 'field' => 'og_type',
741 'label' => 'Content type is configured for Open Graph.',
742 'status' => 'valid',
743 'icon' => ''
744 ];
745 } else {
746 $field_details[] = [
747 'field' => 'og_type',
748 'label' => 'Content type should be specified.',
749 'status' => 'suggestion',
750 'icon' => ''
751 ];
752 }
753
754 // Default Image validation
755 if (!empty($settings['default_og_image'])) {
756 if (filter_var($settings['default_og_image'], FILTER_VALIDATE_URL)) {
757 $field_details[] = [
758 'field' => 'default_og_image',
759 'label' => 'Default Open Graph image is configured.',
760 'status' => 'valid',
761 'icon' => ''
762 ];
763 } else {
764 $field_details[] = [
765 'field' => 'default_og_image',
766 'label' => 'Default Open Graph image URL appears invalid.',
767 'status' => 'warning',
768 'icon' => ''
769 ];
770 }
771 } else {
772 $field_details[] = [
773 'field' => 'default_og_image',
774 'label' => 'Default image is recommended for social sharing.',
775 'status' => 'suggestion',
776 'icon' => ''
777 ];
778 }
779
780 } else {
781 $field_details[] = [
782 'field' => 'enable_open_graph',
783 'label' => 'Open Graph is disabled. Enable for better social media sharing.',
784 'status' => 'suggestion',
785 'icon' => ''
786 ];
787 }
788
789 return $field_details;
790 }
791
792 /**
793 * Validate Twitter fields
794 *
795 * @since 1.0.0
796 *
797 * @param array $settings Settings array to validate
798 * @return array Twitter field validation results
799 */
800 private function validate_twitter_fields(array $settings): array {
801 $field_details = [];
802
803 // Twitter Cards Enabled
804 if (!empty($settings['enable_twitter_cards'])) {
805 $field_details[] = [
806 'field' => 'enable_twitter_cards',
807 'label' => 'Twitter Cards are enabled for Twitter sharing.',
808 'status' => 'valid',
809 'icon' => ''
810 ];
811
812 // Twitter Username validation
813 if (!empty($settings['twitter_username'])) {
814 $username = trim($settings['twitter_username']);
815 $clean_username = ltrim($username, '@');
816
817 if (strlen($clean_username) <= 15 && preg_match('/^[A-Za-z0-9_]+$/', $clean_username)) {
818 $field_details[] = [
819 'field' => 'twitter_username',
820 'label' => 'Twitter username is properly formatted.',
821 'status' => 'valid',
822 'icon' => ''
823 ];
824 } else {
825 $field_details[] = [
826 'field' => 'twitter_username',
827 'label' => 'Twitter username format is invalid (max 15 chars, letters/numbers/underscore only).',
828 'status' => 'error',
829 'icon' => ''
830 ];
831 }
832 } else {
833 $field_details[] = [
834 'field' => 'twitter_username',
835 'label' => 'Twitter username recommended for better attribution.',
836 'status' => 'suggestion',
837 'icon' => ''
838 ];
839 }
840
841 // Twitter Creator validation
842 if (!empty($settings['twitter_creator'])) {
843 $creator = trim($settings['twitter_creator']);
844 $clean_creator = ltrim($creator, '@');
845
846 if (strlen($clean_creator) <= 15 && preg_match('/^[A-Za-z0-9_]+$/', $clean_creator)) {
847 $field_details[] = [
848 'field' => 'twitter_creator',
849 'label' => 'Twitter creator is properly formatted.',
850 'status' => 'valid',
851 'icon' => ''
852 ];
853 } else {
854 $field_details[] = [
855 'field' => 'twitter_creator',
856 'label' => 'Twitter creator format is invalid (max 15 chars, letters/numbers/underscore only).',
857 'status' => 'error',
858 'icon' => ''
859 ];
860 }
861 } else {
862 $field_details[] = [
863 'field' => 'twitter_creator',
864 'label' => 'Twitter creator recommended for content attribution.',
865 'status' => 'suggestion',
866 'icon' => ''
867 ];
868 }
869
870 // Card Type validation
871 $valid_card_types = ['summary', 'summary_large_image', 'app', 'player'];
872 if (!empty($settings['twitter_card_type']) && in_array($settings['twitter_card_type'], $valid_card_types, true)) {
873 $field_details[] = [
874 'field' => 'twitter_card_type',
875 'label' => 'Twitter Card type is properly configured.',
876 'status' => 'valid',
877 'icon' => ''
878 ];
879 } else {
880 $field_details[] = [
881 'field' => 'twitter_card_type',
882 'label' => 'Valid Twitter Card type is required.',
883 'status' => 'error',
884 'icon' => ''
885 ];
886 }
887
888 // Default Twitter Image validation
889 if (!empty($settings['default_twitter_image'])) {
890 if (filter_var($settings['default_twitter_image'], FILTER_VALIDATE_URL)) {
891 $field_details[] = [
892 'field' => 'default_twitter_image',
893 'label' => 'Default Twitter Card image is configured.',
894 'status' => 'valid',
895 'icon' => ''
896 ];
897 } else {
898 $field_details[] = [
899 'field' => 'default_twitter_image',
900 'label' => 'Default Twitter Card image URL appears invalid.',
901 'status' => 'warning',
902 'icon' => ''
903 ];
904 }
905 } else {
906 $field_details[] = [
907 'field' => 'default_twitter_image',
908 'label' => 'Default image recommended for Twitter sharing.',
909 'status' => 'suggestion',
910 'icon' => ''
911 ];
912 }
913
914 } else {
915 $field_details[] = [
916 'field' => 'enable_twitter_cards',
917 'label' => 'Twitter Cards are disabled. Enable for better Twitter sharing.',
918 'status' => 'suggestion',
919 'icon' => ''
920 ];
921 }
922
923 return $field_details;
924 }
925
926
927 /**
928 * Get output data for frontend rendering (implements interface)
929 *
930 * @since 1.0.0
931 *
932 * @param string $context_type The context type
933 * @param int|null $context_id Optional. Context ID
934 * @param string|null $fallback_title Optional. Effective SEO title to
935 * use when no per-post Open Graph
936 * title override is set, so
937 * rendered output mirrors the
938 * document title and the Social
939 * metabox preview.
940 * @param string|null $fallback_description Optional. Effective meta
941 * description, used as the
942 * Open Graph description fallback
943 * for the same parity reason.
944 * @return array Output data ready for frontend rendering
945 */
946 public function get_output_data(string $context_type, ?int $context_id, ?string $fallback_title = null, ?string $fallback_description = null): array {
947 // Memoize per request: the OG, Twitter and platform wp_head callbacks
948 // each call this with the same arguments, so the extraction work +
949 // Site_Identity_Manager instantiation would otherwise run three times.
950 $cache_key = $context_type . ':' . ($context_id ?? 0) . ':' . md5((string) $fallback_title . '|' . (string) $fallback_description);
951 if (isset($this->output_data_cache[$cache_key])) {
952 return $this->output_data_cache[$cache_key];
953 }
954
955 $settings = $this->get_settings($context_type, $context_id);
956 $output = [
957 'og_tags' => [],
958 'twitter_tags' => [],
959 'meta_tags' => [],
960 'platform_tags' => [],
961 // Per-feature flags so each emitter can honor its own toggle. The
962 // aggregate `enabled` (true if either is on) is kept for callers
963 // that only care whether social output ran at all.
964 'og_enabled' => false,
965 'twitter_enabled' => false,
966 'enabled' => false,
967 ];
968
969 // Check if individual social features are enabled
970 $og_enabled = !empty($settings['enable_open_graph'] ?? $settings['og_enabled'] ?? false);
971 $twitter_enabled = !empty($settings['enable_twitter_cards'] ?? $settings['twitter_enabled'] ?? false);
972 $output['og_enabled'] = $og_enabled;
973 $output['twitter_enabled'] = $twitter_enabled;
974
975 if (!$og_enabled && !$twitter_enabled) {
976 $this->output_data_cache[$cache_key] = $output;
977 return $output;
978 }
979
980 $output['enabled'] = true;
981
982 // Extract content data
983 $content_data = $this->extract_social_content_data($context_type, $context_id, $settings, $fallback_title, $fallback_description);
984
985 // Generate Open Graph tags if enabled
986 if ($og_enabled) {
987 $output['og_tags'] = $this->generate_og_tags($content_data, $context_type);
988
989 // Add custom OG tags
990 if (!empty($settings['custom_og_tags'])) {
991 $output['og_tags'] = array_merge($output['og_tags'], $settings['custom_og_tags']);
992 }
993 }
994
995 // Generate Twitter Card tags if enabled
996 if ($twitter_enabled) {
997 $output['twitter_tags'] = $this->generate_twitter_tags($content_data, $context_type);
998 }
999
1000 // Generate platform-specific meta tags
1001 $output['platform_tags'] = $this->generate_platform_meta_tags($settings);
1002
1003 // Convert to meta tag format for HTML output
1004 $output['meta_tags'] = $this->convert_to_meta_tags($output['og_tags'], $output['twitter_tags'], $output['platform_tags']);
1005
1006 $this->output_data_cache[$cache_key] = $output;
1007 return $output;
1008 }
1009
1010 /**
1011 * Request-scoped memoization of get_output_data() keyed by context + title/desc.
1012 *
1013 * @var array<string, array>
1014 */
1015 private array $output_data_cache = [];
1016
1017 /**
1018 * Site-wide default image keys inherited by every other context.
1019 *
1020 * @since 1.24.1
1021 * @var string[]
1022 */
1023 private const INHERITED_SITE_KEYS = [
1024 'default_og_image',
1025 'default_twitter_image',
1026 'default_image',
1027 ];
1028
1029 /**
1030 * Get settings for a context, inheriting the site-wide default images
1031 *
1032 * Two corrections over the generic lookup:
1033 *
1034 * 1. Site-wide settings are always stored with context_id 0, but the
1035 * frontend maps a homepage request to the `site` context while still
1036 * passing the queried object ID (non-zero on a static front page). That
1037 * looked up `site`/<page ID>, matched no row and silently fell back to
1038 * the schema defaults, so a configured default OG image never rendered
1039 * on a static front page. Normalize the ID away for `site`.
1040 * 2. `default_og_image` / `default_twitter_image` only exist in the site
1041 * context, so post/page and archive contexts had nothing to fall back to
1042 * when a post had no featured image — og:image dropped to the site logo
1043 * or vanished entirely. Inherit those keys when the context has no value
1044 * of its own.
1045 *
1046 * @since 1.24.1
1047 *
1048 * @param string $context_type The context type
1049 * @param int|null $context_id Optional. Context ID
1050 * @return array Settings with site-wide image defaults applied
1051 */
1052 public function get_settings(string $context_type, ?int $context_id = null): array {
1053 if ($context_type === 'site') {
1054 $context_id = null;
1055 }
1056
1057 $settings = parent::get_settings($context_type, $context_id);
1058
1059 if ($context_type === 'site') {
1060 return $settings;
1061 }
1062
1063 $site_settings = parent::get_settings('site', null);
1064 foreach (self::INHERITED_SITE_KEYS as $key) {
1065 if (empty($settings[$key]) && !empty($site_settings[$key])) {
1066 $settings[$key] = $site_settings[$key];
1067 }
1068 }
1069
1070 return $settings;
1071 }
1072
1073 /**
1074 * Get default settings for a context type (implements interface)
1075 *
1076 * @since 1.0.0
1077 *
1078 * @param string $context_type The context type to get defaults for
1079 * @return array Default settings array
1080 */
1081 public function get_default_settings(string $context_type): array {
1082 // Get WordPress site defaults
1083 $site_name = get_bloginfo('name');
1084 $site_description = get_bloginfo('description');
1085
1086 $defaults = [
1087 // Open Graph settings
1088 'enable_open_graph' => true,
1089 'og_site_name' => $site_name,
1090 'og_description' => $site_description,
1091 'og_type' => 'website',
1092 // Empty by design: og:locale is resolved from the site locale via
1093 // get_og_locale(), which is where the thinkrank_og_locale filter (and
1094 // therefore the WPML/Polylang/TranslatePress integration) applies. A
1095 // hardcoded default was merged into every settings read, so the
1096 // resolver was unreachable and every install advertised en_US.
1097 'og_locale' => '',
1098 'default_og_image' => '',
1099 'og_image_width' => 1200,
1100 'og_image_height' => 630,
1101
1102 // Twitter Cards settings
1103 'enable_twitter_cards' => true,
1104 'twitter_username' => '',
1105 'twitter_creator' => '',
1106 'twitter_card_type' => 'summary_large_image',
1107 'default_twitter_image' => '',
1108
1109 // Facebook settings
1110 'facebook_app_id' => '',
1111 'facebook_admins' => '',
1112
1113 // LinkedIn settings
1114 'enable_linkedin' => false,
1115
1116 // Pinterest settings
1117 'enable_pinterest' => false,
1118 'pinterest_site_verification' => '',
1119
1120 // Instagram settings
1121 'enable_instagram' => false,
1122 'instagram_verification' => '',
1123
1124 // TikTok settings
1125 'enable_tiktok' => false,
1126 'tiktok_verification' => '',
1127
1128 // YouTube settings
1129 'enable_youtube' => false,
1130 'youtube_channel_id' => '',
1131
1132 // WhatsApp Business settings
1133 'enable_whatsapp' => false,
1134 'whatsapp_business_id' => '',
1135
1136 // Advanced settings
1137 'auto_generate_descriptions' => true,
1138 'fallback_to_excerpt' => true,
1139 'strip_html_tags' => true,
1140 'max_description_length' => 160,
1141
1142 // Legacy format for backward compatibility (only when needed)
1143 'custom_og_tags' => [],
1144 'image_optimization' => true,
1145 'auto_generate' => true
1146 ];
1147
1148 // Context-specific defaults
1149 switch ($context_type) {
1150 case 'site':
1151 $defaults['og_type'] = 'website';
1152 break;
1153 case 'post':
1154 $defaults['og_type'] = 'article';
1155 break;
1156 case 'page':
1157 $defaults['og_type'] = 'website';
1158 break;
1159 case 'product':
1160 $defaults['og_type'] = 'product';
1161 break;
1162 }
1163
1164 return $defaults;
1165 }
1166
1167 /**
1168 * Get settings schema definition (implements interface)
1169 *
1170 * @since 1.0.0
1171 *
1172 * @param string $context_type The context type to get schema for
1173 * @return array Settings schema definition
1174 */
1175 public function get_settings_schema(string $context_type): array {
1176 return [
1177 'enable_open_graph' => [
1178 'type' => 'boolean',
1179 'title' => 'Enable Open Graph',
1180 'description' => 'Generate Open Graph meta tags for social media sharing',
1181 'default' => true
1182 ],
1183 'og_site_name' => [
1184 'type' => 'string',
1185 'title' => 'Site Name',
1186 'description' => 'The name of your website for Open Graph',
1187 'maxLength' => 60,
1188 'default' => get_bloginfo('name')
1189 ],
1190 'og_description' => [
1191 'type' => 'string',
1192 'title' => 'Site Description',
1193 'description' => 'Default description for Open Graph tags',
1194 'maxLength' => 160,
1195 'default' => get_bloginfo('description')
1196 ],
1197 'og_locale' => [
1198 'type' => 'string',
1199 'title' => 'Locale',
1200 'description' => 'Optional override for og:locale. Leave empty to follow the site language.',
1201 'default' => ''
1202 ],
1203 'default_og_image' => [
1204 'type' => 'string',
1205 'title' => 'Default Open Graph Image',
1206 'description' => 'Default image URL for Open Graph tags',
1207 'format' => 'uri',
1208 'default' => ''
1209 ],
1210 'og_image_width' => [
1211 'type' => 'integer',
1212 'title' => 'Image Width',
1213 'description' => 'Default width for Open Graph images',
1214 'minimum' => 200,
1215 'default' => 1200
1216 ],
1217 'og_image_height' => [
1218 'type' => 'integer',
1219 'title' => 'Image Height',
1220 'description' => 'Default height for Open Graph images',
1221 'minimum' => 200,
1222 'default' => 630
1223 ],
1224 'enable_twitter_cards' => [
1225 'type' => 'boolean',
1226 'title' => 'Enable Twitter Cards',
1227 'description' => 'Generate Twitter Card meta tags for Twitter sharing',
1228 'default' => true
1229 ],
1230 'twitter_username' => [
1231 'type' => 'string',
1232 'title' => 'Twitter Username',
1233 'description' => 'Twitter username for the site (without @, e.g., username)',
1234 'pattern' => '^[a-zA-Z0-9_]{1,15}$',
1235 'default' => ''
1236 ],
1237 'twitter_creator' => [
1238 'type' => 'string',
1239 'title' => 'Twitter Creator',
1240 'description' => 'Content creator Twitter username (without @, e.g., creator_username)',
1241 'pattern' => '^[a-zA-Z0-9_]{1,15}$',
1242 'default' => ''
1243 ],
1244 'twitter_card_type' => [
1245 'type' => 'string',
1246 'title' => 'Twitter Card Type',
1247 'description' => 'The type of Twitter Card to generate',
1248 'enum' => $this->supported_platforms['twitter']['card_types'],
1249 'default' => 'summary_large_image'
1250 ],
1251 'og_type' => [
1252 'type' => 'string',
1253 'title' => 'Open Graph Type',
1254 'description' => 'The Open Graph type for this content',
1255 'enum' => array_keys($this->og_types),
1256 'default' => $this->get_default_settings($context_type)['og_type']
1257 ],
1258 'default_image' => [
1259 'type' => 'string',
1260 'title' => 'Default Social Image',
1261 'description' => 'Default image URL for social media sharing',
1262 'format' => 'uri',
1263 'default' => ''
1264 ],
1265 'twitter_site' => [
1266 'type' => 'string',
1267 'title' => 'Twitter Site Handle',
1268 'description' => 'Twitter handle for the site (e.g., @username)',
1269 'pattern' => '^@[a-zA-Z0-9_]{1,15}$',
1270 'default' => ''
1271 ],
1272 'default_twitter_image' => [
1273 'type' => 'string',
1274 'title' => 'Default Twitter Image',
1275 'description' => 'Default image URL for Twitter Cards',
1276 'format' => 'uri',
1277 'default' => ''
1278 ],
1279 'facebook_app_id' => [
1280 'type' => 'string',
1281 'title' => 'Facebook App ID',
1282 'description' => 'Facebook App ID for analytics and insights',
1283 'pattern' => '^[0-9]+$',
1284 'default' => ''
1285 ],
1286 'facebook_admins' => [
1287 'type' => 'string',
1288 'title' => 'Facebook Admins',
1289 'description' => 'Comma-separated list of Facebook admin user IDs',
1290 'default' => ''
1291 ],
1292 'enable_linkedin' => [
1293 'type' => 'boolean',
1294 'title' => 'Enable LinkedIn',
1295 'description' => 'Enable LinkedIn-specific optimizations',
1296 'default' => false
1297 ],
1298 'enable_pinterest' => [
1299 'type' => 'boolean',
1300 'title' => 'Enable Pinterest',
1301 'description' => 'Enable Pinterest-specific optimizations',
1302 'default' => false
1303 ],
1304 'pinterest_site_verification' => [
1305 'type' => 'string',
1306 'title' => 'Pinterest Site Verification',
1307 'description' => 'Pinterest site verification meta tag content',
1308 'pattern' => '^[a-f0-9]{32}$',
1309 'default' => ''
1310 ],
1311 'enable_instagram' => [
1312 'type' => 'boolean',
1313 'title' => 'Enable Instagram',
1314 'description' => 'Enable Instagram-specific optimizations',
1315 'default' => false
1316 ],
1317 'instagram_verification' => [
1318 'type' => 'string',
1319 'title' => 'Instagram Site Verification',
1320 'description' => 'Instagram Business account verification code',
1321 'pattern' => '^[a-zA-Z0-9_-]{20,}$',
1322 'default' => ''
1323 ],
1324 'enable_tiktok' => [
1325 'type' => 'boolean',
1326 'title' => 'Enable TikTok',
1327 'description' => 'Enable TikTok-specific optimizations',
1328 'default' => false
1329 ],
1330 'tiktok_verification' => [
1331 'type' => 'string',
1332 'title' => 'TikTok Site Verification',
1333 'description' => 'TikTok for Business verification code',
1334 'pattern' => '^[a-zA-Z0-9_-]{20,}$',
1335 'default' => ''
1336 ],
1337 'enable_youtube' => [
1338 'type' => 'boolean',
1339 'title' => 'Enable YouTube',
1340 'description' => 'Enable YouTube-specific optimizations',
1341 'default' => false
1342 ],
1343 'youtube_channel_id' => [
1344 'type' => 'string',
1345 'title' => 'YouTube Channel ID',
1346 'description' => 'YouTube channel ID for content attribution',
1347 'pattern' => '^UC[a-zA-Z0-9_-]{22}$',
1348 'default' => ''
1349 ],
1350 'enable_whatsapp' => [
1351 'type' => 'boolean',
1352 'title' => 'Enable WhatsApp Business',
1353 'description' => 'Enable WhatsApp Business optimizations',
1354 'default' => false
1355 ],
1356 'whatsapp_business_id' => [
1357 'type' => 'string',
1358 'title' => 'WhatsApp Business ID',
1359 'description' => 'WhatsApp Business account ID',
1360 'pattern' => '^[0-9]{10,15}$',
1361 'default' => ''
1362 ],
1363 'auto_generate_descriptions' => [
1364 'type' => 'boolean',
1365 'title' => 'Auto-generate Descriptions',
1366 'description' => 'Automatically generate descriptions from content',
1367 'default' => true
1368 ],
1369 'fallback_to_excerpt' => [
1370 'type' => 'boolean',
1371 'title' => 'Fallback to Excerpt',
1372 'description' => 'Use post excerpt as fallback for descriptions',
1373 'default' => true
1374 ],
1375 'strip_html_tags' => [
1376 'type' => 'boolean',
1377 'title' => 'Strip HTML Tags',
1378 'description' => 'Remove HTML tags from generated descriptions',
1379 'default' => true
1380 ],
1381 'max_description_length' => [
1382 'type' => 'integer',
1383 'title' => 'Max Description Length',
1384 'description' => 'Maximum length for generated descriptions',
1385 'minimum' => 50,
1386 'maximum' => 300,
1387 'default' => 160
1388 ],
1389 'custom_og_tags' => [
1390 'type' => 'object',
1391 'title' => 'Custom Open Graph Tags',
1392 'description' => 'Additional custom Open Graph meta tags',
1393 'default' => []
1394 ],
1395 'image_optimization' => [
1396 'type' => 'boolean',
1397 'title' => 'Enable Image Optimization',
1398 'description' => 'Optimize images for social media platforms',
1399 'default' => true
1400 ],
1401 'auto_generate' => [
1402 'type' => 'boolean',
1403 'title' => 'Auto-generate Tags',
1404 'description' => 'Automatically generate social meta tags from content',
1405 'default' => true
1406 ]
1407 ];
1408 }
1409
1410 /**
1411 * Extract content data for social meta generation
1412 *
1413 * @since 1.0.0
1414 *
1415 * @param string $context_type The context type
1416 * @param int|null $context_id Optional. Context ID
1417 * @param array $settings Social meta settings
1418 * @return array Extracted content data
1419 */
1420 private function extract_social_content_data(string $context_type, ?int $context_id, array $settings, ?string $fallback_title = null, ?string $fallback_description = null): array {
1421 $data = [
1422 'title' => '',
1423 'description' => '',
1424 'url' => '',
1425 'image' => '',
1426 'twitter_title' => '', // Separate field for a Twitter-specific title
1427 'twitter_description' => '', // Separate field for a Twitter-specific description
1428 'twitter_image' => '', // Separate field for Twitter-specific images
1429 // '' rather than 'website' means "derive it from the context".
1430 // Seeding a concrete type here made an explicit choice
1431 // indistinguishable from the shipped default (#398).
1432 'type' => '',
1433 'twitter_card_type' => '',
1434 'author' => [],
1435 'published_time' => '',
1436 'modified_time' => '',
1437 'site_name' => $settings['og_site_name'] ?? get_bloginfo('name')
1438 ];
1439
1440 // Explicit type choices, resolved once for whichever context this is.
1441 $data['type'] = $this->configured_og_type($settings);
1442 $data['twitter_card_type'] = $this->configured_twitter_card_type($settings);
1443
1444 if ($context_type === 'site') {
1445 // Site-wide data with Social Media tab settings priority.
1446 //
1447 // og:title priority: an explicitly-configured OG Site Name wins;
1448 // otherwise mirror the effective SEO <title> (what search shows),
1449 // then the blogname. og_site_name is always present because it is
1450 // merged from a default equal to the blogname, so a value matching
1451 // the blogname is treated as "not explicitly overridden" and we fall
1452 // through to the passed effective SEO title. (og:site_name itself is
1453 // set separately from og_site_name and is unaffected.)
1454 $blogname = get_bloginfo('name');
1455 $og_site_name = $settings['og_site_name'] ?? '';
1456 if ($og_site_name !== '' && $og_site_name !== $blogname) {
1457 $data['title'] = $og_site_name;
1458 } elseif ($fallback_title !== null && $fallback_title !== '') {
1459 $data['title'] = $fallback_title;
1460 } else {
1461 $data['title'] = $blogname;
1462 }
1463 $data['description'] = $settings['og_description'] ?? get_bloginfo('description');
1464 // Use home_url('/') so og:url matches the homepage canonical
1465 // (class-seo-manager.php) and the WebSite schema, which both include
1466 // the trailing slash. A bare home_url() would key a different URL in
1467 // social caches than the canonical.
1468 //
1469 // Except when this is not the site home. detect_current_context()
1470 // collapses is_home() && !is_front_page() into 'homepage', so a
1471 // static posts page — and page 2 of any blog listing — advertised
1472 // the site home as its og:url while its own canonical said
1473 // otherwise (#397).
1474 $data['url'] = self::current_home_url();
1475
1476 // Leaving this null lets generate_og_tags() fall through to
1477 // get_og_locale(), which is what every other context already does.
1478 //
1479 // A stored 'en_US' is deliberately treated as "not set". It was the
1480 // hardcoded default on every install and there has never been a UI
1481 // control for this field, so it cannot represent a deliberate choice
1482 // — it is the old default persisted by an unrelated save of the
1483 // Social Media tab. Honouring it would leave every already-saved
1484 // site broken after this fix. Any other stored value is a genuine
1485 // override and still wins; a site that really wants to force en_US
1486 // can do so through the thinkrank_og_locale filter.
1487 $stored_locale = trim((string) ($settings['og_locale'] ?? ''));
1488 $data['locale'] = ('' !== $stored_locale && 'en_US' !== $stored_locale)
1489 ? $stored_locale
1490 : null;
1491
1492 // Set Open Graph image with proper fallback
1493 $data['image'] = $this->get_og_image_for_context($settings, null);
1494
1495 // Set Twitter image with proper fallback
1496 $data['twitter_image'] = $this->get_twitter_image_for_context($settings, null);
1497 } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'], true)) {
1498 // Post-specific data
1499 $post = get_post($context_id);
1500 if ($post) {
1501 // Per-post Open Graph overrides from the metabox Social tab take
1502 // precedence over the derived title/description/image. These read
1503 // the same meta keys the metabox save handler and the import
1504 // migrator write to, so manual edits and migrated data flow
1505 // through one path. Title/description may hold variable tags
1506 // (e.g. %title%), resolved here to match output_basic_og_tags().
1507 $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value(
1508 (string) get_post_meta($post->ID, '_thinkrank_og_title', true),
1509 $post->ID
1510 );
1511 $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value(
1512 (string) get_post_meta($post->ID, '_thinkrank_og_description', true),
1513 $post->ID
1514 );
1515 $og_image_override = get_post_meta($post->ID, '_thinkrank_og_image', true);
1516
1517 // Per-post Twitter-specific title override (metabox Social tab).
1518 // Twitter Cards fall back to the og:title when this is empty, so
1519 // only capture it here; the fallback is applied in
1520 // generate_twitter_tags().
1521 $data['twitter_title'] = \ThinkRank\SEO\Pattern_Resolver::resolve_value(
1522 (string) get_post_meta($post->ID, '_thinkrank_twitter_title', true),
1523 $post->ID
1524 );
1525
1526 // Per-post Twitter-specific description. twitter:description
1527 // falls back to the OG/meta description when this is empty, so
1528 // only capture it here; generate_twitter_tags() applies the
1529 // fallback. There was no counterpart to the title lookup above,
1530 // so the stored value never entered $data at all (#406).
1531 $data['twitter_description'] = \ThinkRank\SEO\Pattern_Resolver::resolve_value(
1532 (string) get_post_meta($post->ID, '_thinkrank_twitter_description', true),
1533 $post->ID
1534 );
1535
1536 // Title priority: per-post OG override > effective SEO title
1537 // (document <title> / metabox preview) > post title.
1538 if ($og_title_override !== '') {
1539 $data['title'] = $og_title_override;
1540 } elseif ($fallback_title !== null && $fallback_title !== '') {
1541 $data['title'] = $fallback_title;
1542 } else {
1543 $data['title'] = get_the_title($post);
1544 }
1545 // Description priority: per-post OG override > effective meta
1546 // description (meta tag / metabox preview) > derived excerpt.
1547 if ($og_description_override !== '') {
1548 $data['description'] = $og_description_override;
1549 } elseif ($fallback_description !== null && $fallback_description !== '') {
1550 $data['description'] = $fallback_description;
1551 } else {
1552 $data['description'] = $this->get_social_description($post);
1553 }
1554 // The canonical carries the <!--nextpage--> sub-page and the
1555 // comment page; og:url said page 1 regardless, which is the
1556 // same contradiction #397 fixed for the blog listing, one page
1557 // type over (#397 review).
1558 $data['url'] = self::with_singular_page((string) get_permalink($post));
1559
1560 $data['published_time'] = get_the_date('c', $post);
1561 $data['modified_time'] = get_the_modified_date('c', $post);
1562 $data['post_id'] = $post->ID;
1563
1564 // Author data
1565 $author_id = $post->post_author;
1566 $data['author'] = [
1567 'name' => get_the_author_meta('display_name', $author_id),
1568 'url' => get_author_posts_url($author_id),
1569 'twitter' => get_the_author_meta('twitter', $author_id)
1570 ];
1571
1572 // Set Open Graph image: per-post override first, then the
1573 // featured-image/default fallback chain.
1574 $data['image'] = $og_image_override !== ''
1575 ? $og_image_override
1576 : $this->get_og_image_for_context($settings, $post);
1577
1578 // Set Twitter image with proper fallback
1579 $data['twitter_image'] = $this->get_twitter_image_for_context($settings, $post);
1580 }
1581 } else {
1582 // Archive-style contexts (category, tag, author, search, date).
1583 // They carry no object of their own, but the site-wide default
1584 // image still applies — without this they fell through to the raw
1585 // logo/site-icon fallback in generate_og_tags() and ignored a
1586 // configured default OG image.
1587 $data['image'] = $this->get_og_image_for_context($settings, null);
1588 $data['twitter_image'] = $this->get_twitter_image_for_context($settings, null);
1589
1590 // A term archive owns SEO values of its own, and the caller has
1591 // already resolved them into the fallbacks — the same strings
1592 // rendered as the document <title> and the description tag. Leaving
1593 // title and description empty here published the bare site name as
1594 // og:title on every archive and no og:description at all, so a term
1595 // SEO title never reached a social surface (#386).
1596 // Archives have a URL of their own. The seed leaves it '', and the
1597 // og:url emitter could not fall through to get_current_url() while
1598 // the key existed, so no archive carried an og:url at all (#388).
1599 // A search archive's URL is the search link, not the bare request
1600 // path — get_current_url() reads $wp->request, which is empty for a
1601 // search served from the front page, so og:url pointed at the site
1602 // home while the page was a search result.
1603 // The non-search archive URL is the page's own canonical, so og:url
1604 // and <link rel="canonical"> agree on the paginated page rather than
1605 // both claiming page 1 (#397).
1606 // `?:` keeps the #388 guarantee that an archive always carries an
1607 // og:url: get_non_singular_canonical_url() returns '' for a view it
1608 // has no canonical rule for, and the request URL is still better
1609 // than no tag at all.
1610 $data['url'] = is_search()
1611 ? get_search_link()
1612 : (self::current_archive_url() ?: $this->get_current_url());
1613
1614 $term = get_queried_object();
1615 $term_id = ($term instanceof \WP_Term) ? (int) $term->term_id : 0;
1616
1617 $og_title_override = '';
1618 $og_description_override = '';
1619
1620 if ($term_id > 0) {
1621 // Terms carry the same social override keys as posts — the
1622 // abilities API and the metabox both write them.
1623 $og_title_override = Pattern_Resolver::resolve_term_value(
1624 (string) get_term_meta($term_id, '_thinkrank_og_title', true),
1625 $term_id
1626 );
1627 $og_description_override = Pattern_Resolver::resolve_term_value(
1628 (string) get_term_meta($term_id, '_thinkrank_og_description', true),
1629 $term_id
1630 );
1631
1632 // Twitter Cards fall back to og:title when this is empty, so
1633 // only capture it; generate_twitter_tags() applies the fallback.
1634 $data['twitter_title'] = Pattern_Resolver::resolve_term_value(
1635 (string) get_term_meta($term_id, '_thinkrank_twitter_title', true),
1636 $term_id
1637 );
1638 $data['twitter_description'] = Pattern_Resolver::resolve_term_value(
1639 (string) get_term_meta($term_id, '_thinkrank_twitter_description', true),
1640 $term_id
1641 );
1642
1643 $term_og_image = (string) get_term_meta($term_id, '_thinkrank_og_image', true);
1644 if ('' !== $term_og_image) {
1645 $data['image'] = $term_og_image;
1646 }
1647
1648 $term_twitter_image = (string) get_term_meta($term_id, '_thinkrank_twitter_image', true);
1649 if ('' !== $term_twitter_image) {
1650 $data['twitter_image'] = $term_twitter_image;
1651 }
1652 }
1653
1654 // Author, date and search archives have no ThinkRank-managed title
1655 // to inherit — Author_Archives_Manager owns the author one, and the
1656 // rest have no template — so the caller's fallback arrives empty and
1657 // og:title used to collapse to the bare site name. Fall through to
1658 // what the page itself is called (#388).
1659 if ($og_title_override !== '') {
1660 $data['title'] = $og_title_override;
1661 } elseif ($fallback_title !== null && $fallback_title !== '') {
1662 $data['title'] = $fallback_title;
1663 } else {
1664 $data['title'] = $this->archive_fallback_title();
1665 }
1666
1667 if ($og_description_override !== '') {
1668 $data['description'] = $og_description_override;
1669 } elseif ($fallback_description !== null && $fallback_description !== '') {
1670 $data['description'] = $fallback_description;
1671 } else {
1672 $data['description'] = $this->archive_fallback_description($term_id);
1673 }
1674 }
1675
1676 return $data;
1677 }
1678
1679 /**
1680 * The canonical URL of the archive currently being rendered.
1681 *
1682 * Deliberately the same value class-seo-manager.php puts in
1683 * <link rel="canonical">: an og:url that disagrees with the canonical is
1684 * the bug this is fixing, so the two read from one source.
1685 *
1686 * @since 2.0.1
1687 *
1688 * @return string Archive URL, '' when there is none (search, 404).
1689 */
1690 private static function current_archive_url(): string {
1691 if (!class_exists('\ThinkRank\Frontend\SEO_Manager')) {
1692 return '';
1693 }
1694
1695 return \ThinkRank\Frontend\SEO_Manager::get_non_singular_canonical_url();
1696 }
1697
1698 /**
1699 * A permalink with the current sub-page or comment page appended.
1700 *
1701 * @since 2.0.1
1702 *
1703 * @param string $url Permalink.
1704 * @return string
1705 */
1706 private static function with_singular_page(string $url): string {
1707 if ('' === $url || !class_exists('\ThinkRank\Frontend\SEO_Manager')) {
1708 return $url;
1709 }
1710
1711 return \ThinkRank\Frontend\SEO_Manager::with_singular_page($url);
1712 }
1713
1714 /**
1715 * The URL of the page the 'site' context is actually being rendered for.
1716 *
1717 * home_url('/') for the front page, the posts page's own permalink when the
1718 * site uses a static front page, and the paginated variant on page 2+.
1719 *
1720 * @since 2.0.1
1721 *
1722 * @return string
1723 */
1724 private static function current_home_url(): string {
1725 $url = home_url('/');
1726
1727 if (function_exists('is_home') && is_home() && !is_front_page()) {
1728 $posts_page = (int) get_option('page_for_posts');
1729
1730 if ($posts_page > 0) {
1731 $permalink = get_permalink($posts_page);
1732
1733 if (is_string($permalink) && '' !== $permalink) {
1734 $url = $permalink;
1735 }
1736 }
1737 }
1738
1739 if (!class_exists('\ThinkRank\Frontend\SEO_Manager')) {
1740 return $url;
1741 }
1742
1743 // A static front page is a singular view, so its page number lives in
1744 // `page`, not `paged` — the canonical already reads it that way, and
1745 // og:url has to agree or the two describe different URLs.
1746 if (function_exists('is_singular') && is_singular()) {
1747 return \ThinkRank\Frontend\SEO_Manager::with_singular_page($url);
1748 }
1749
1750 return \ThinkRank\Frontend\SEO_Manager::with_pagination(
1751 $url,
1752 \ThinkRank\Frontend\SEO_Manager::current_page_number()
1753 );
1754 }
1755
1756 /**
1757 * Get Open Graph image for context with proper fallback
1758 *
1759 * @since 1.0.0
1760 *
1761 * @param array $settings Social meta settings
1762 * @param \WP_Post|null $post Optional. Post object for post-specific images
1763 * @return string Image URL or empty string
1764 */
1765 private function get_og_image_for_context(array $settings, ?\WP_Post $post = null): string {
1766 // For posts, check featured image first
1767 if ($post) {
1768 $image_id = get_post_thumbnail_id($post);
1769 if ($image_id) {
1770 $image_data = wp_get_attachment_image_src($image_id, 'large');
1771 if (!empty($image_data[0])) {
1772 return $image_data[0];
1773 }
1774 }
1775 }
1776
1777 // Fallback to configured Open Graph image
1778 if (!empty($settings['default_og_image'])) {
1779 return $settings['default_og_image'];
1780 }
1781
1782 // Final fallback to generic default image
1783 if (!empty($settings['default_image'])) {
1784 return $settings['default_image'];
1785 }
1786
1787 // Last-resort fallback to the site logo / site icon. Resolving it here
1788 // (rather than late inside generate_og_tags()) writes it back to the
1789 // shared $data['image'], so generate_twitter_tags() and
1790 // determine_twitter_card_type() see the same image: twitter:image is
1791 // emitted and the card is promoted to summary_large_image, and the
1792 // image is routed through optimize_image_for_platform() so
1793 // og:image:width/height/type/alt companions are produced.
1794 return $this->get_default_social_image();
1795 }
1796
1797 /**
1798 * Get Twitter image for context with proper fallback
1799 *
1800 * @since 1.0.0
1801 *
1802 * @param array $settings Social meta settings
1803 * @param \WP_Post|null $post Optional. Post object for post-specific images
1804 * @return string Image URL or empty string
1805 */
1806 private function get_twitter_image_for_context(array $settings, ?\WP_Post $post = null): string {
1807 // For posts, check for post-specific Twitter image meta first (if implemented)
1808 if ($post) {
1809 // Check for post-specific Twitter image meta (future enhancement)
1810 $post_twitter_image = get_post_meta($post->ID, '_thinkrank_twitter_image', true);
1811 if (!empty($post_twitter_image)) {
1812 return $post_twitter_image;
1813 }
1814
1815 // Check featured image as fallback for posts
1816 $image_id = get_post_thumbnail_id($post);
1817 if ($image_id) {
1818 $image_data = wp_get_attachment_image_src($image_id, 'large');
1819 if (!empty($image_data[0])) {
1820 return $image_data[0];
1821 }
1822 }
1823 }
1824
1825 // Prioritize Twitter-specific default image
1826 if (!empty($settings['default_twitter_image'])) {
1827 return $settings['default_twitter_image'];
1828 }
1829
1830 // Fallback to Open Graph default image
1831 if (!empty($settings['default_og_image'])) {
1832 return $settings['default_og_image'];
1833 }
1834
1835 // Final fallback to generic default image
1836 if (!empty($settings['default_image'])) {
1837 return $settings['default_image'];
1838 }
1839
1840 return '';
1841 }
1842
1843 /**
1844 * Get social media description for post
1845 *
1846 * @since 1.0.0
1847 *
1848 * @param \WP_Post $post Post object
1849 * @return string Social media description
1850 */
1851 private function get_social_description(\WP_Post $post): string {
1852 // A password-gated body must never become a social description. Core
1853 // answers get_the_excerpt() with its "There is no excerpt because this
1854 // is a protected post." placeholder rather than the body, so today the
1855 // derive-from-content fallback below is unreachable here — but it is one
1856 // core change away from leaking, and that placeholder sentence is not a
1857 // description worth publishing to every crawler and unfurler either.
1858 // An authored post_excerpt is written for public consumption, so it
1859 // still stands (#363).
1860 if (function_exists('post_password_required') && post_password_required($post)) {
1861 return '' !== $post->post_excerpt ? $post->post_excerpt : get_bloginfo('description');
1862 }
1863
1864 // Try excerpt first
1865 $description = get_the_excerpt($post);
1866
1867 // If no excerpt, generate from content. Shortcodes and block delimiters
1868 // are removed the way core's wp_trim_excerpt() does, so a shortcode-built
1869 // page does not publish its source as og:description (#387).
1870 if (empty($description)) {
1871 $description = Pattern_Resolver::derive_excerpt((string) $post->post_content, 30);
1872 }
1873
1874 // If still empty, use site description
1875 if (empty($description)) {
1876 $description = get_bloginfo('description');
1877 }
1878
1879 return $description;
1880 }
1881
1882 /**
1883 * Determine Open Graph type based on context
1884 *
1885 * @since 1.0.0
1886 *
1887 * @param string $context Context type
1888 * @param array $data Content data
1889 * @return string OG type
1890 */
1891 private function determine_og_type(string $context, array $data): string {
1892 switch ($context) {
1893 case 'post':
1894 return 'article';
1895 case 'product':
1896 return 'product';
1897 case 'page':
1898 case 'site':
1899 default:
1900 return 'website';
1901 }
1902 }
1903
1904 /**
1905 * Determine Twitter Card type based on content
1906 *
1907 * @since 1.0.0
1908 *
1909 * @param array $data Content data
1910 * @param string $context Context type
1911 * @return string Twitter Card type
1912 */
1913 private function determine_twitter_card_type(array $data, string $context): string {
1914 // An explicitly chosen card type wins. Without this the setting was
1915 // inert and the `app` and `player` options in the UI could never be
1916 // emitted at all (#398).
1917 $chosen = (string) ($data['twitter_card_type'] ?? '');
1918 if ('' !== $chosen) {
1919 return $chosen;
1920 }
1921
1922 // Use a large-image card when a Twitter image will actually be emitted.
1923 // twitter:image resolves to the twitter-specific image first, then the OG
1924 // image, so key the card type off the same precedence.
1925 $twitter_image = !empty($data['twitter_image']) ? $data['twitter_image'] : ($data['image'] ?? '');
1926 return !empty($twitter_image) ? 'summary_large_image' : 'summary';
1927 }
1928
1929 /**
1930 * What an archive calls itself, for the og:title of last resort.
1931 *
1932 * Deliberately not wp_get_document_title(): that re-enters the
1933 * pre_get_document_title filter this plugin short-circuits, so it would
1934 * recurse. get_the_archive_title() wraps its subject in a <span>, hence the
1935 * strip.
1936 *
1937 * @since 2.0.1
1938 *
1939 * @return string Archive title, or '' when there is nothing sensible to say.
1940 */
1941 private function archive_fallback_title(): string {
1942 if (is_search()) {
1943 /* translators: %s: search query. */
1944 return trim(sprintf(__('Search Results for "%s"', 'thinkrank'), get_search_query()));
1945 }
1946
1947 if (!function_exists('get_the_archive_title')) {
1948 return '';
1949 }
1950
1951 return trim(wp_strip_all_tags((string) get_the_archive_title()));
1952 }
1953
1954 /**
1955 * What an archive says about itself, for the og:description of last resort.
1956 *
1957 * @since 2.0.1
1958 *
1959 * @param int $term_id Queried term, or 0 when the archive is not a term.
1960 * @return string Archive description, or '' when there is none.
1961 */
1962 private function archive_fallback_description(int $term_id): string {
1963 if ($term_id > 0) {
1964 $description = trim(wp_strip_all_tags((string) term_description($term_id)));
1965
1966 if ('' !== $description) {
1967 return $description;
1968 }
1969 }
1970
1971 if (is_author()) {
1972 $bio = trim(wp_strip_all_tags((string) get_the_author_meta('description', (int) get_query_var('author'))));
1973
1974 if ('' !== $bio) {
1975 return $bio;
1976 }
1977 }
1978
1979 return '';
1980 }
1981
1982 /**
1983 * The Open Graph type the user explicitly chose, or '' when they did not.
1984 *
1985 * `og_type` ships a default of 'website' that is merged into every settings
1986 * read, so a stored 'website' cannot be told apart from "never touched" —
1987 * the same trap the `og_locale` note above documents. Treating it as unset
1988 * keeps determine_og_type() reachable, so a post is still `article`, while
1989 * any other stored value is a genuine override and wins.
1990 *
1991 * @since 2.0.1
1992 *
1993 * @param array $settings Social meta settings.
1994 * @return string The chosen type, or '' for "derive it".
1995 */
1996 private function configured_og_type(array $settings): string {
1997 $type = trim((string) ($settings['og_type'] ?? ''));
1998
1999 return ('' === $type || 'website' === $type) ? '' : $type;
2000 }
2001
2002 /**
2003 * The Twitter card type the user explicitly chose, or '' when they did not.
2004 *
2005 * Same reasoning as configured_og_type(): the shipped default is
2006 * 'summary_large_image', which is also what the automatic rule produces
2007 * whenever an image is available, so it is treated as "not chosen" and the
2008 * automatic rule stays in charge. `summary`, `app` and `player` are real
2009 * choices and are honoured.
2010 *
2011 * @since 2.0.1
2012 *
2013 * @param array $settings Social meta settings.
2014 * @return string The chosen card type, or '' for "derive it".
2015 */
2016 private function configured_twitter_card_type(array $settings): string {
2017 $type = trim((string) ($settings['twitter_card_type'] ?? ''));
2018
2019 if (!in_array($type, ['summary', 'app', 'player'], true)) {
2020 return '';
2021 }
2022
2023 return $type;
2024 }
2025
2026 /**
2027 * Optimize title for platform requirements
2028 *
2029 * @since 1.0.0
2030 *
2031 * @param string $title Original title
2032 * @param string $platform Target platform
2033 * @return string Optimized title
2034 */
2035 private function optimize_title_for_platform(string $title, string $platform): string {
2036 if (empty($title)) {
2037 return get_bloginfo('name');
2038 }
2039
2040 $max_length = $this->supported_platforms[$platform]['title_max_length'] ?? 60;
2041
2042 // Multibyte-safe: byte-based substr() would split characters in
2043 // CJK/Bengali/accented titles (WP ships an mbstring fallback).
2044 if (mb_strlen($title) <= $max_length) {
2045 return $title;
2046 }
2047
2048 // Truncate at word boundary
2049 $truncated = wp_trim_words($title, 10, '');
2050 if (mb_strlen($truncated) <= $max_length) {
2051 return $truncated;
2052 }
2053
2054 // Hard truncate if necessary
2055 return mb_substr($title, 0, $max_length - 3) . '...';
2056 }
2057
2058 /**
2059 * Optimize description for platform requirements
2060 *
2061 * @since 1.0.0
2062 *
2063 * @param string $description Original description
2064 * @param string $platform Target platform
2065 * @return string Optimized description
2066 */
2067 private function optimize_description_for_platform(string $description, string $platform): string {
2068 if (empty($description)) {
2069 return get_bloginfo('description');
2070 }
2071
2072 $max_length = $this->supported_platforms[$platform]['description_max_length'] ?? 160;
2073
2074 // Multibyte-safe: byte-based substr() would split characters in
2075 // CJK/Bengali/accented descriptions (WP ships an mbstring fallback).
2076 if (mb_strlen($description) <= $max_length) {
2077 return $description;
2078 }
2079
2080 // Truncate at word boundary
2081 $truncated = wp_trim_words($description, 25, '');
2082 if (mb_strlen($truncated) <= $max_length) {
2083 return $truncated;
2084 }
2085
2086 // Hard truncate if necessary
2087 return mb_substr($description, 0, $max_length - 3) . '...';
2088 }
2089
2090 /**
2091 * Optimize image for platform requirements
2092 *
2093 * @since 1.0.0
2094 *
2095 * @param string $image_url Image URL
2096 * @param string $platform Target platform
2097 * @return array Optimized image data
2098 */
2099 private function optimize_image_for_platform(string $image_url, string $platform): array {
2100 $image_data = [
2101 'url' => $image_url,
2102 'width' => '',
2103 'height' => '',
2104 'alt' => '',
2105 'type' => '',
2106 'valid' => false,
2107 'warnings' => []
2108 ];
2109
2110 if (empty($image_url)) {
2111 return $image_data;
2112 }
2113
2114 // Get image metadata
2115 $attachment_id = attachment_url_to_postid($image_url);
2116 if ($attachment_id) {
2117 $image_meta = wp_get_attachment_metadata($attachment_id);
2118 $image_alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
2119 $mime_type = get_post_mime_type($attachment_id);
2120
2121 if ($image_meta && isset($image_meta['width'], $image_meta['height'])) {
2122 $width = (int) $image_meta['width'];
2123 $height = (int) $image_meta['height'];
2124
2125 $image_data['alt'] = $image_alt ?: '';
2126 $image_data['type'] = $mime_type ?: '';
2127
2128 // SVGs and other vector uploads store 0x0 metadata. Dimension
2129 // checks are meaningless there and dividing by 0 is fatal.
2130 if ($width > 0 && $height > 0) {
2131 $image_data['width'] = $width;
2132 $image_data['height'] = $height;
2133
2134 // Validate against platform requirements
2135 $platform_spec = $this->supported_platforms[$platform] ?? [];
2136 $min_width = $platform_spec['image_min_width'] ?? 300;
2137 $min_height = $platform_spec['image_min_height'] ?? 200;
2138
2139 if ($width >= $min_width && $height >= $min_height) {
2140 $image_data['valid'] = true;
2141 } else {
2142 $image_data['warnings'][] = "Image dimensions ({$width}x{$height}) are below recommended minimum ({$min_width}x{$min_height}) for {$platform}";
2143 }
2144
2145 // Check aspect ratio if specified
2146 if (isset($platform_spec['image_recommended_ratio'])) {
2147 $actual_ratio = $width / $height;
2148 $recommended_ratio = $platform_spec['image_recommended_ratio'];
2149 $ratio_tolerance = 0.1;
2150
2151 if (abs($actual_ratio - $recommended_ratio) > $ratio_tolerance) {
2152 $image_data['warnings'][] = sprintf(
2153 "Image aspect ratio (%.2f) differs from recommended ratio (%.2f) for %s",
2154 $actual_ratio,
2155 $recommended_ratio,
2156 $platform
2157 );
2158 }
2159 }
2160 }
2161 }
2162 }
2163
2164 return $image_data;
2165 }
2166
2167 /**
2168 * Get default social image
2169 *
2170 * @since 1.0.0
2171 *
2172 * @return string Default social image URL
2173 */
2174 private function get_default_social_image(): string {
2175 // Try custom logo first
2176 $custom_logo_id = get_theme_mod('custom_logo');
2177 if ($custom_logo_id) {
2178 $logo_data = wp_get_attachment_image_src($custom_logo_id, 'large');
2179 if ($logo_data) {
2180 return $logo_data[0];
2181 }
2182 }
2183
2184 // Try site icon
2185 $site_icon_id = get_option('site_icon');
2186 if ($site_icon_id) {
2187 $icon_data = wp_get_attachment_image_src($site_icon_id, 'large');
2188 if ($icon_data) {
2189 return $icon_data[0];
2190 }
2191 }
2192
2193 return '';
2194 }
2195
2196 /**
2197 * Get Open Graph locale
2198 *
2199 * @since 1.0.0
2200 *
2201 * @return string OG locale
2202 */
2203 private function get_og_locale(): string {
2204 /**
2205 * Filter the locale used for og:locale.
2206 *
2207 * Defaults to get_locale(), which only tracks the active language once
2208 * that language's translation files are installed — on a multilingual
2209 * site without them every translated URL still reports the default
2210 * locale. The multilingual integration answers with the locale its
2211 * provider reports for the language actually being viewed.
2212 *
2213 * @since 1.23.0
2214 *
2215 * @param string $locale Locale for the current request.
2216 */
2217 $locale = (string) apply_filters('thinkrank_og_locale', get_locale());
2218
2219 // Convert WordPress locale to OG locale format for the few that differ
2220 // from the xx_YY form (e.g. bare 'ja').
2221 $og_locale_map = [
2222 'ja' => 'ja_JP',
2223 ];
2224
2225 if (isset($og_locale_map[$locale])) {
2226 return $og_locale_map[$locale];
2227 }
2228
2229 // Fall back to the actual site locale (normalized to xx_YY) rather than
2230 // mislabeling every unmapped language as en_US.
2231 if (preg_match('/^[a-z]{2,3}_[A-Z]{2}$/', $locale)) {
2232 return $locale;
2233 }
2234
2235 // Bare language code (e.g. 'nl') → best-effort xx_XX.
2236 if (preg_match('/^([a-z]{2,3})$/', $locale, $m)) {
2237 return $m[1] . '_' . strtoupper($m[1]);
2238 }
2239
2240 return 'en_US';
2241 }
2242
2243 /**
2244 * Get current URL
2245 *
2246 * @since 1.0.0
2247 *
2248 * @return string Current URL
2249 */
2250 private function get_current_url(): string {
2251 if (is_admin()) {
2252 return home_url();
2253 }
2254
2255 global $wp;
2256 return home_url(add_query_arg([], $wp->request));
2257 }
2258
2259 /**
2260 * Get Twitter site handle
2261 *
2262 * @since 1.0.0
2263 *
2264 * @return string Twitter site handle
2265 */
2266 private function get_twitter_site_handle(): string {
2267 $settings = $this->get_settings('site');
2268 $username = $settings['twitter_username'] ?? '';
2269
2270 if (empty($username)) {
2271 return '';
2272 }
2273
2274 // Ensure username starts with @ for meta tag output
2275 return '@' . ltrim($username, '@');
2276 }
2277
2278 /**
2279 * Get Twitter creator handle
2280 *
2281 * @since 1.0.0
2282 *
2283 * @return string Twitter creator handle
2284 */
2285 private function get_twitter_creator_handle(): string {
2286 $settings = $this->get_settings('site');
2287 $creator = $settings['twitter_creator'] ?? '';
2288
2289 if (empty($creator)) {
2290 return '';
2291 }
2292
2293 // Ensure creator starts with @ for meta tag output
2294 return '@' . ltrim($creator, '@');
2295 }
2296
2297 /**
2298 * Add context-specific Open Graph tags
2299 *
2300 * @since 1.0.0
2301 *
2302 * @param array $og_tags OG tags array
2303 * @param string $context Context type
2304 * @param array $data Content data
2305 * @param string $og_type OG type
2306 * @return array Updated OG tags
2307 */
2308 private function add_context_specific_og_tags(array $og_tags, string $context, array $data, string $og_type): array {
2309 switch ($og_type) {
2310 case 'article':
2311 if (!empty($data['author']['name'])) {
2312 $og_tags['article:author'] = $data['author']['name'];
2313 }
2314 if (!empty($data['published_time'])) {
2315 $og_tags['article:published_time'] = $data['published_time'];
2316 }
2317 if (!empty($data['modified_time'])) {
2318 $og_tags['article:modified_time'] = $data['modified_time'];
2319 }
2320 // article:section — the post's primary category (parity with the
2321 // basic emitter, which the active path previously omitted).
2322 if (!empty($data['post_id'])) {
2323 $categories = get_the_category((int) $data['post_id']);
2324 if (!empty($categories) && !is_wp_error($categories)) {
2325 $og_tags['article:section'] = $categories[0]->name;
2326 }
2327 }
2328 break;
2329 case 'product':
2330 // Product-specific tags would be added here
2331 // This could be extended with price, availability, etc.
2332 break;
2333 }
2334
2335 // Add local business Open Graph tags if business data is available
2336 $og_tags = $this->add_local_business_og_tags($og_tags, $data);
2337
2338 return $og_tags;
2339 }
2340
2341 /**
2342 * Add local business Open Graph tags
2343 *
2344 * @since 1.0.0
2345 *
2346 * @param array $og_tags OG tags array
2347 * @param array $data Content data
2348 * @return array Updated OG tags with local business information
2349 */
2350 private function add_local_business_og_tags(array $og_tags, array $data): array {
2351 // Get business data from Site Identity settings
2352 $business_data = $this->get_local_business_data();
2353
2354 if (empty($business_data) || empty($business_data['business_name'])) {
2355 return $og_tags;
2356 }
2357
2358 // Add business contact data Open Graph tags
2359 if (!empty($business_data['business_address'])) {
2360 $og_tags['business:contact_data:street_address'] = $business_data['business_address'];
2361 }
2362
2363 if (!empty($business_data['business_city'])) {
2364 $og_tags['business:contact_data:locality'] = $business_data['business_city'];
2365 }
2366
2367 if (!empty($business_data['business_state'])) {
2368 $og_tags['business:contact_data:region'] = $business_data['business_state'];
2369 }
2370
2371 if (!empty($business_data['business_postal_code'])) {
2372 $og_tags['business:contact_data:postal_code'] = $business_data['business_postal_code'];
2373 }
2374
2375 if (!empty($business_data['business_country'])) {
2376 $og_tags['business:contact_data:country_name'] = $business_data['business_country'];
2377 }
2378
2379 if (!empty($business_data['business_phone'])) {
2380 $og_tags['business:contact_data:phone_number'] = $business_data['business_phone'];
2381 }
2382
2383 if (!empty($business_data['business_email'])) {
2384 $og_tags['business:contact_data:email'] = $business_data['business_email'];
2385 }
2386
2387 // Add business hours if available
2388 if (!empty($business_data['business_hours']) && is_array($business_data['business_hours'])) {
2389 $formatted_hours = $this->format_business_hours_for_og($business_data['business_hours']);
2390 if (!empty($formatted_hours)) {
2391 $og_tags['business:hours'] = $formatted_hours;
2392 }
2393 }
2394
2395 // Add business website
2396 if (!empty($business_data['business_website'])) {
2397 $og_tags['business:contact_data:website'] = $business_data['business_website'];
2398 }
2399
2400 // Add coordinates if available
2401 if (!empty($business_data['business_latitude']) && !empty($business_data['business_longitude'])) {
2402 $og_tags['place:location:latitude'] = $business_data['business_latitude'];
2403 $og_tags['place:location:longitude'] = $business_data['business_longitude'];
2404 }
2405
2406 return $og_tags;
2407 }
2408
2409 /**
2410 * Get local business data from Site Identity settings
2411 *
2412 * @since 1.0.0
2413 *
2414 * @return array Business data array
2415 */
2416 private function get_local_business_data(): array {
2417 // Try to get Site Identity Manager
2418 if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) {
2419 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-site-identity-manager.php';
2420 }
2421
2422 $site_identity_manager = new \ThinkRank\SEO\Site_Identity_Manager();
2423 $settings = $site_identity_manager->get_settings('site');
2424
2425 // Only return data if local SEO is enabled
2426 if (empty($settings['local_seo_enabled'])) {
2427 return [];
2428 }
2429
2430 return [
2431 'business_name' => $settings['business_name'] ?? '',
2432 'business_address' => $settings['business_address'] ?? '',
2433 'business_city' => $settings['business_city'] ?? '',
2434 'business_state' => $settings['business_state'] ?? '',
2435 'business_postal_code' => $settings['business_postal_code'] ?? '',
2436 'business_country' => $settings['business_country'] ?? '',
2437 'business_phone' => $settings['business_phone'] ?? '',
2438 'business_email' => $settings['business_email'] ?? '',
2439 'business_hours' => $settings['business_hours'] ?? [],
2440 'business_website' => $settings['business_website'] ?? home_url(),
2441 'business_latitude' => $settings['business_latitude'] ?? '',
2442 'business_longitude' => $settings['business_longitude'] ?? ''
2443 ];
2444 }
2445
2446 /**
2447 * Format business hours for Open Graph tags
2448 *
2449 * @since 1.0.0
2450 *
2451 * @param array $business_hours Business hours array
2452 * @return string Formatted hours string
2453 */
2454 private function format_business_hours_for_og(array $business_hours): string {
2455 $formatted_days = [];
2456
2457 $day_names = [
2458 'monday' => 'Monday',
2459 'tuesday' => 'Tuesday',
2460 'wednesday' => 'Wednesday',
2461 'thursday' => 'Thursday',
2462 'friday' => 'Friday',
2463 'saturday' => 'Saturday',
2464 'sunday' => 'Sunday'
2465 ];
2466
2467 foreach ($day_names as $day => $display_name) {
2468 if (isset($business_hours[$day]) && !empty($business_hours[$day])) {
2469 $day_data = $business_hours[$day];
2470
2471 if (!empty($day_data['closed']) || empty($day_data['open']) || empty($day_data['close'])) {
2472 $formatted_days[] = $display_name . ': Closed';
2473 } else {
2474 $formatted_days[] = $display_name . ': ' . $day_data['open'] . ' - ' . $day_data['close'];
2475 }
2476 }
2477 }
2478
2479 return implode('; ', $formatted_days);
2480 }
2481
2482 /**
2483 * Apply platform-specific optimizations
2484 *
2485 * @since 1.0.0
2486 *
2487 * @param array $og_tags OG tags array
2488 * @param string $platform Target platform
2489 * @param array $data Content data
2490 * @return array Optimized OG tags
2491 */
2492 private function apply_platform_specific_optimizations(array $og_tags, string $platform, array $data): array {
2493 $settings = $this->get_settings('site');
2494
2495 switch ($platform) {
2496 case 'linkedin':
2497 // LinkedIn prefers professional content
2498 if ($settings['enable_linkedin'] ?? false) {
2499 if (isset($og_tags['og:description'])) {
2500 $og_tags['og:description'] = $this->make_description_professional($og_tags['og:description']);
2501 }
2502 // Add LinkedIn-specific type if appropriate
2503 if ($og_tags['og:type'] === 'article') {
2504 $og_tags['article:author'] = $data['author'] ?? '';
2505 }
2506 }
2507 break;
2508
2509 case 'pinterest':
2510 // Pinterest prefers descriptive content
2511 if ($settings['enable_pinterest'] ?? false) {
2512 if (isset($og_tags['og:description'])) {
2513 $og_tags['og:description'] = $this->make_description_descriptive($og_tags['og:description']);
2514 }
2515 // Pinterest prefers article type for rich pins
2516 if (in_array($og_tags['og:type'], ['website', 'blog'], true)) {
2517 $og_tags['og:type'] = 'article';
2518 }
2519 }
2520 break;
2521
2522 case 'instagram':
2523 // Instagram optimizations
2524 if ($settings['enable_instagram'] ?? false) {
2525 // Instagram prefers square or vertical images
2526 if (isset($og_tags['og:description'])) {
2527 $og_tags['og:description'] = $this->make_description_engaging($og_tags['og:description']);
2528 }
2529 }
2530 break;
2531
2532 case 'tiktok':
2533 // TikTok optimizations
2534 if ($settings['enable_tiktok'] ?? false) {
2535 // TikTok prefers short, catchy descriptions
2536 if (isset($og_tags['og:description'])) {
2537 $og_tags['og:description'] = $this->make_description_catchy($og_tags['og:description']);
2538 }
2539 }
2540 break;
2541 }
2542
2543 return $og_tags;
2544 }
2545
2546 /**
2547 * Make description more professional for LinkedIn
2548 *
2549 * @since 1.0.0
2550 *
2551 * @param string $description Original description
2552 * @return string Professional description
2553 */
2554 private function make_description_professional(string $description): string {
2555 // Remove casual language and emojis, add professional tone
2556 $description = preg_replace('/[^\w\s\.,!?-]/', '', $description);
2557
2558 // Add professional keywords if not present
2559 $professional_words = ['insights', 'expertise', 'professional', 'industry', 'business'];
2560 if (!preg_match('/\b(' . implode('|', $professional_words) . ')\b/i', $description)) {
2561 $description = 'Professional insights: ' . $description;
2562 }
2563
2564 return $description;
2565 }
2566
2567 /**
2568 * Make description more descriptive for Pinterest
2569 *
2570 * @since 1.0.0
2571 *
2572 * @param string $description Original description
2573 * @return string Descriptive description
2574 */
2575 private function make_description_descriptive(string $description): string {
2576 // Pinterest users love detailed, searchable descriptions
2577 $descriptive_words = ['discover', 'explore', 'learn', 'find', 'ideas'];
2578
2579 if (!preg_match('/\b(' . implode('|', $descriptive_words) . ')\b/i', $description)) {
2580 $description = 'Discover ' . lcfirst($description);
2581 }
2582
2583 return $description;
2584 }
2585
2586 /**
2587 * Make description engaging for Instagram
2588 *
2589 * @since 1.0.0
2590 *
2591 * @param string $description Original description
2592 * @return string Engaging description
2593 */
2594 private function make_description_engaging(string $description): string {
2595 // Instagram prefers engaging, visual language
2596 $engaging_words = ['amazing', 'stunning', 'beautiful', 'incredible', 'inspiring'];
2597
2598 if (!preg_match('/\b(' . implode('|', $engaging_words) . ')\b/i', $description)) {
2599 $description = '' . $description;
2600 }
2601
2602 return $description;
2603 }
2604
2605 /**
2606 * Make description catchy for TikTok
2607 *
2608 * @since 1.0.0
2609 *
2610 * @param string $description Original description
2611 * @return string Catchy description
2612 */
2613 private function make_description_catchy(string $description): string {
2614 // TikTok prefers short, catchy descriptions
2615 $catchy_words = ['viral', 'trending', 'must-see', 'epic', 'mind-blowing'];
2616
2617 // Limit to 100 characters for TikTok
2618 if (strlen($description) > 100) {
2619 $description = substr($description, 0, 97) . '...';
2620 }
2621
2622 if (!preg_match('/\b(' . implode('|', $catchy_words) . ')\b/i', $description)) {
2623 $description = '🔥 ' . $description;
2624 }
2625
2626 return $description;
2627 }
2628
2629 /**
2630 * Add Twitter Card specific tags
2631 *
2632 * @since 1.0.0
2633 *
2634 * @param array $twitter_tags Twitter tags array
2635 * @param string $card_type Card type
2636 * @param array $data Content data
2637 * @param string $context Context type
2638 * @return array Updated Twitter tags
2639 */
2640 private function add_twitter_card_specific_tags(array $twitter_tags, string $card_type, array $data, string $context): array {
2641 // The content extractor only produces summary / summary_large_image
2642 // cards, so the player/app card variants were dead code that read keys
2643 // (video_url, app_name, …) the extractor never sets. Kept as a filterable
2644 // extension point for add-ons that do populate richer card data.
2645 return apply_filters('thinkrank_twitter_card_specific_tags', $twitter_tags, $card_type, $data, $context);
2646 }
2647
2648 /**
2649 * Generate Open Graph preview
2650 *
2651 * @since 1.0.0
2652 *
2653 * @param array $og_tags OG tags
2654 * @param string $platform Platform name
2655 * @param array $preview Preview array
2656 * @return array Updated preview
2657 */
2658 private function generate_og_preview(array $og_tags, string $platform, array $preview): array {
2659 $preview['title'] = $og_tags['og:title'] ?? '';
2660 $preview['description'] = $og_tags['og:description'] ?? '';
2661 $preview['image'] = $og_tags['og:image'] ?? '';
2662 $preview['preview_url'] = $og_tags['og:url'] ?? '';
2663
2664 // Validate required fields
2665 $required_fields = ['og:title', 'og:type', 'og:image', 'og:url'];
2666 $missing_fields = [];
2667
2668 foreach ($required_fields as $field) {
2669 if (empty($og_tags[$field])) {
2670 $missing_fields[] = $field;
2671 }
2672 }
2673
2674 if (empty($missing_fields)) {
2675 $preview['valid'] = true;
2676 } else {
2677 $preview['warnings'][] = 'Missing required fields: ' . implode(', ', $missing_fields);
2678 }
2679
2680 // Platform-specific validation
2681 $platform_spec = $this->supported_platforms[$platform] ?? [];
2682 if (isset($platform_spec['title_max_length']) && strlen($preview['title']) > $platform_spec['title_max_length']) {
2683 $preview['warnings'][] = "Title exceeds {$platform} maximum length of {$platform_spec['title_max_length']} characters";
2684 }
2685
2686 return $preview;
2687 }
2688
2689 /**
2690 * Generate Twitter preview
2691 *
2692 * @since 1.0.0
2693 *
2694 * @param array $twitter_tags Twitter tags
2695 * @param array $preview Preview array
2696 * @return array Updated preview
2697 */
2698 private function generate_twitter_preview(array $twitter_tags, array $preview): array {
2699 $preview['title'] = $twitter_tags['twitter:title'] ?? '';
2700 $preview['description'] = $twitter_tags['twitter:description'] ?? '';
2701 $preview['image'] = $twitter_tags['twitter:image'] ?? '';
2702
2703 // Validate required fields
2704 $required_fields = ['twitter:card', 'twitter:title'];
2705 $missing_fields = [];
2706
2707 foreach ($required_fields as $field) {
2708 if (empty($twitter_tags[$field])) {
2709 $missing_fields[] = $field;
2710 }
2711 }
2712
2713 if (empty($missing_fields)) {
2714 $preview['valid'] = true;
2715 } else {
2716 $preview['warnings'][] = 'Missing required fields: ' . implode(', ', $missing_fields);
2717 }
2718
2719 // Twitter-specific validation
2720 $twitter_spec = $this->supported_platforms['twitter'];
2721 if (strlen($preview['title']) > $twitter_spec['title_max_length']) {
2722 $preview['warnings'][] = "Title exceeds Twitter maximum length of {$twitter_spec['title_max_length']} characters";
2723 }
2724
2725 return $preview;
2726 }
2727
2728 /**
2729 * Generate Pinterest preview
2730 *
2731 * @since 1.0.0
2732 *
2733 * @param array $og_tags OG tags
2734 * @param array $preview Preview array
2735 * @return array Updated preview
2736 */
2737 private function generate_pinterest_preview(array $og_tags, array $preview): array {
2738 $preview['title'] = $og_tags['og:title'] ?? '';
2739 $preview['description'] = $og_tags['og:description'] ?? '';
2740 $preview['image'] = $og_tags['og:image'] ?? '';
2741 $preview['preview_url'] = $og_tags['og:url'] ?? '';
2742
2743 // Validate required fields for Pinterest
2744 $required_fields = ['og:title', 'og:image', 'og:url'];
2745 $missing_fields = [];
2746
2747 foreach ($required_fields as $field) {
2748 if (empty($og_tags[$field])) {
2749 $missing_fields[] = $field;
2750 }
2751 }
2752
2753 if (empty($missing_fields)) {
2754 $preview['valid'] = true;
2755 } else {
2756 $preview['warnings'][] = 'Missing required fields: ' . implode(', ', $missing_fields);
2757 }
2758
2759 // Pinterest-specific validation and suggestions
2760 $pinterest_spec = $this->supported_platforms['pinterest'] ?? [];
2761
2762 // Title length validation
2763 if (isset($pinterest_spec['title_max_length']) && strlen($preview['title']) > $pinterest_spec['title_max_length']) {
2764 $preview['warnings'][] = "Title exceeds Pinterest maximum length of {$pinterest_spec['title_max_length']} characters";
2765 }
2766
2767 // Description length validation
2768 if (isset($pinterest_spec['description_max_length']) && strlen($preview['description']) > $pinterest_spec['description_max_length']) {
2769 $preview['warnings'][] = "Description exceeds Pinterest maximum length of {$pinterest_spec['description_max_length']} characters";
2770 }
2771
2772 // Image dimension suggestions for Pinterest
2773 if (!empty($preview['image'])) {
2774 $image_data = $this->optimize_image_for_platform($preview['image'], 'pinterest');
2775 if (!empty($image_data['width']) && !empty($image_data['height'])) {
2776 $aspect_ratio = $image_data['width'] / $image_data['height'];
2777
2778 // Pinterest prefers vertical images (2:3 ratio is optimal)
2779 if ($aspect_ratio > 1) {
2780 $preview['suggestions'][] = 'Pinterest performs better with vertical images (2:3 aspect ratio recommended)';
2781 } elseif ($aspect_ratio < 0.6) {
2782 $preview['suggestions'][] = 'Image is very tall - consider a 2:3 aspect ratio for optimal Pinterest performance';
2783 }
2784
2785 // Minimum size recommendations
2786 if ($image_data['width'] < 600) {
2787 $preview['suggestions'][] = 'Pinterest recommends images at least 600px wide for better quality';
2788 }
2789 }
2790 }
2791
2792 return $preview;
2793 }
2794
2795 /**
2796 * Generate platform-specific meta tags
2797 *
2798 * Platform IDs and verification codes are owned by the Social Platforms tab
2799 * (ThinkRank\API\Social_Platforms_Endpoint), which stores them in core
2800 * Settings (wp_options) with the sensitive codes encrypted at rest. The
2801 * social_meta settings table this manager normally reads no longer receives
2802 * these values from the UI, so we resolve each key from core Settings first
2803 * and fall back to any legacy value still present in the passed-in table
2804 * settings — otherwise the verification tags would never render.
2805 *
2806 * @since 1.0.0
2807 *
2808 * @param array $settings Settings array (social_meta table) — legacy fallback.
2809 * @return array Platform-specific meta tags
2810 */
2811 private function generate_platform_meta_tags(array $settings): array {
2812 $platform_tags = [];
2813
2814 $core = \ThinkRank\Core\Settings::instance();
2815
2816 // One query for all seven instead of one query each. They are
2817 // autoload=off like every thinkrank_* option, so WordPress cannot
2818 // batch them out of `alloptions`, and this runs on every anonymous
2819 // front-end request — on most sites to discover that all seven are
2820 // empty and no tag is emitted at all (#393).
2821 $core->prime(array_keys(self::PLATFORM_META_KEYS));
2822
2823 // Core Settings (decrypted for sensitive keys) wins; the table value is a
2824 // backward-compat fallback for installs that saved these before the UI
2825 // moved to the Social Platforms tab.
2826 $resolve = static function (string $key) use ($core, $settings): string {
2827 $value = (string) $core->get($key, '');
2828 if ('' === $value) {
2829 $value = (string) ($settings[$key] ?? '');
2830 }
2831 return $value;
2832 };
2833
2834 foreach (self::PLATFORM_META_KEYS as $key => $meta_name) {
2835 $value = $resolve($key);
2836
2837 if ('' !== $value) {
2838 $platform_tags[$meta_name] = $value;
2839 }
2840 }
2841
2842 return $platform_tags;
2843 }
2844
2845 /**
2846 * Platform verification settings, mapped to the meta name each is emitted
2847 * under. One list so the batch primed in generate_platform_meta_tags() and
2848 * the keys it then reads cannot drift apart.
2849 *
2850 * @since 2.1.0
2851 * @var array<string,string>
2852 */
2853 private const PLATFORM_META_KEYS = [
2854 'facebook_app_id' => 'fb:app_id',
2855 'facebook_admins' => 'fb:admins',
2856 'pinterest_site_verification' => 'pinterest-site-verification',
2857 'instagram_verification' => 'instagram-site-verification',
2858 'tiktok_verification' => 'tiktok-site-verification',
2859 'youtube_channel_id' => 'youtube-channel-id',
2860 'whatsapp_business_id' => 'whatsapp-business-id',
2861 ];
2862
2863 /**
2864 * Convert OG, Twitter, and Platform tags to HTML meta tags
2865 *
2866 * @since 1.0.0
2867 *
2868 * @param array $og_tags Open Graph tags
2869 * @param array $twitter_tags Twitter tags
2870 * @param array $platform_tags Platform-specific tags
2871 * @return array HTML meta tags
2872 */
2873 private function convert_to_meta_tags(array $og_tags, array $twitter_tags, array $platform_tags = []): array {
2874 $meta_tags = [];
2875
2876 // Convert OG tags
2877 foreach ($og_tags as $property => $content) {
2878 if (!empty($content)) {
2879 $meta_tags[] = [
2880 'property' => esc_attr($property),
2881 'content' => esc_attr($content)
2882 ];
2883 }
2884 }
2885
2886 // Convert Twitter tags
2887 foreach ($twitter_tags as $name => $content) {
2888 if (!empty($content)) {
2889 $meta_tags[] = [
2890 'name' => esc_attr($name),
2891 'content' => esc_attr($content)
2892 ];
2893 }
2894 }
2895
2896 // Convert Platform tags
2897 foreach ($platform_tags as $name => $content) {
2898 if (!empty($content)) {
2899 // Determine if it should be property or name attribute
2900 if (strpos($name, 'fb:') === 0) {
2901 // Facebook tags use property attribute
2902 $meta_tags[] = [
2903 'property' => esc_attr($name),
2904 'content' => esc_attr($content)
2905 ];
2906 } else {
2907 // Other platform tags use name attribute
2908 $meta_tags[] = [
2909 'name' => esc_attr($name),
2910 'content' => esc_attr($content)
2911 ];
2912 }
2913 }
2914 }
2915
2916 return $meta_tags;
2917 }
2918
2919 /**
2920 * Validate social image
2921 *
2922 * @since 1.0.0
2923 *
2924 * @param string $image_url Image URL to validate
2925 * @return array Validation results
2926 */
2927 private function validate_social_image(string $image_url): array {
2928 $validation = [
2929 'valid' => false,
2930 'warnings' => [],
2931 'suggestions' => []
2932 ];
2933
2934 if (empty($image_url)) {
2935 $validation['warnings'][] = 'No image provided';
2936 return $validation;
2937 }
2938
2939 // Check if URL is valid
2940 if (!filter_var($image_url, FILTER_VALIDATE_URL)) {
2941 $validation['warnings'][] = 'Invalid image URL';
2942 return $validation;
2943 }
2944
2945 // Get image metadata if it's a local attachment
2946 $attachment_id = attachment_url_to_postid($image_url);
2947 if ($attachment_id) {
2948 $image_meta = wp_get_attachment_metadata($attachment_id);
2949 $meta_width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0;
2950 $meta_height = isset($image_meta['height']) ? (int) $image_meta['height'] : 0;
2951
2952 // SVGs and other vector uploads store 0x0 metadata — skip the
2953 // dimension/ratio checks instead of dividing by 0.
2954 if ($image_meta && $meta_width > 0 && $meta_height > 0) {
2955 // Check minimum dimensions for major platforms
2956 $min_width = 600; // Facebook minimum
2957 $min_height = 315; // Facebook minimum
2958
2959 if ($meta_width >= $min_width && $meta_height >= $min_height) {
2960 $validation['valid'] = true;
2961 } else {
2962 $validation['warnings'][] = "Image dimensions ({$meta_width}x{$meta_height}) are below recommended minimum ({$min_width}x{$min_height})";
2963 }
2964
2965 // Check file size
2966 $file_path = get_attached_file($attachment_id);
2967 if ($file_path && file_exists($file_path)) {
2968 $file_size = filesize($file_path);
2969 $max_size = 5 * 1024 * 1024; // 5MB
2970
2971 if ($file_size > $max_size) {
2972 $validation['warnings'][] = 'Image file size exceeds 5MB, may not display on some platforms';
2973 }
2974 }
2975
2976 // Check aspect ratio
2977 $aspect_ratio = $meta_width / $meta_height;
2978 if ($aspect_ratio < 1.5 || $aspect_ratio > 2.5) {
2979 $validation['suggestions'][] = 'Consider using an image with 1.91:1 aspect ratio for optimal display';
2980 }
2981 }
2982 } else {
2983 $validation['suggestions'][] = 'External images may not be optimized for social media platforms';
2984 }
2985
2986 return $validation;
2987 }
2988 }
2989