PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.3
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.3
2.15.0 2.14.3 2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.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 All 59 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.14.3, at includes/seo/class-social-meta-manager.php

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