PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.13.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.13.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 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 All 54 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.13.0, at includes/seo/class-social-meta-manager.php

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