PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.4.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.4.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.4.0, at includes/seo/class-social-meta-manager.php

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