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

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