PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.0
Jetpack – WP Security, Backup, Speed, & Growth v16.0
12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 13.9.2 14.0.1 14.1.1 14.2.2 14.3.1 14.4.2 All 500 releases
jetpack / functions.opengraph.php
functions.opengraph.php
1,023 lines 31.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php // phpcs:ignore WordPress.Files.FileName.NotHyphenatedLowercase
2 /**
3 * Open Graph Tags
4 *
5 * Add Open Graph tags so that Facebook (and any other service that supports them)
6 * can crawl the site better and we provide a better sharing experience.
7 *
8 * @link https://ogp.me/
9 * @link https://developers.facebook.com/docs/opengraph/
10 *
11 * @package automattic/jetpack
12 */
13
14 use Automattic\Block_Scanner;
15 use Automattic\Jetpack\Connection\Manager as Connection_Manager;
16 use Automattic\Jetpack\Current_Plan;
17 use Automattic\Jetpack\Post_Media\Images;
18 use Automattic\Jetpack\Status;
19 use Automattic\Jetpack\Status\Host;
20
21 if ( ! defined( 'ABSPATH' ) ) {
22 exit( 0 );
23 }
24
25 add_action( 'wp_head', 'jetpack_og_tags' );
26 add_action( 'web_stories_story_head', 'jetpack_og_tags' );
27
28 // Add a Fediverse Open Graph Tag when an author has connected their Mastodon account.
29 add_filter( 'jetpack_open_graph_tags', 'jetpack_add_fediverse_creator_open_graph_tag', 10, 1 );
30 add_filter( 'jetpack_open_graph_output', 'jetpack_filter_fediverse_cards_output', 10, 1 );
31
32 /**
33 * Outputs Open Graph tags generated by Jetpack.
34 */
35 function jetpack_og_tags() {
36 global $post;
37 $data = $post; // so that we don't accidentally explode the global.
38
39 $is_amp_response = ( class_exists( 'Jetpack_AMP_Support' ) && Jetpack_AMP_Support::is_amp_request() );
40
41 // Disable the widont filter on WP.com to avoid stray &nbsps.
42 $disable_widont = remove_filter( 'the_title', 'widont' );
43
44 $og_output = "\n";
45 if ( ! $is_amp_response ) { // Because AMP optimizes the order or the nodes in the head.
46 $og_output .= "<!-- Jetpack Open Graph Tags -->\n";
47 }
48 $tags = array();
49
50 /**
51 * Filter the minimum width of the images used in Jetpack Open Graph Meta Tags.
52 *
53 * @module sharedaddy, publicize
54 *
55 * @since 2.0.0
56 *
57 * @param int 200 Minimum image width used in Jetpack Open Graph Meta Tags.
58 */
59 $image_width = absint( apply_filters( 'jetpack_open_graph_image_width', 200 ) );
60 /**
61 * Filter the minimum height of the images used in Jetpack Open Graph Meta Tags.
62 *
63 * @module sharedaddy, publicize
64 *
65 * @since 2.0.0
66 *
67 * @param int 200 Minimum image height used in Jetpack Open Graph Meta Tags.
68 */
69 $image_height = absint( apply_filters( 'jetpack_open_graph_image_height', 200 ) );
70 $description_length = 197;
71
72 if ( is_home() || is_front_page() ) {
73 $site_type = Jetpack_Options::get_option_and_ensure_autoload( 'open_graph_protocol_site_type', '' );
74 $tags['og:type'] = ! empty( $site_type ) ? $site_type : 'website';
75 $tags['og:title'] = get_bloginfo( 'name' );
76 $tags['og:description'] = get_bloginfo( 'description' );
77
78 $front_page_id = get_option( 'page_for_posts' );
79 if ( 'page' === get_option( 'show_on_front' ) && $front_page_id && is_home() ) {
80 $tags['og:url'] = get_permalink( $front_page_id );
81 } else {
82 $tags['og:url'] = home_url( '/' );
83 }
84
85 // Associate a blog's root path with one or more Facebook accounts.
86 $facebook_admins = Jetpack_Options::get_option_and_ensure_autoload( 'facebook_admins', array() );
87 if ( ! empty( $facebook_admins ) ) {
88 $tags['fb:admins'] = $facebook_admins;
89 }
90 } elseif ( is_author() ) {
91 $tags['og:type'] = 'profile';
92
93 $author = get_queried_object();
94
95 if ( is_a( $author, 'WP_User' ) ) {
96 $tags['og:title'] = $author->display_name;
97 if ( ! empty( $author->user_url ) ) {
98 $tags['og:url'] = $author->user_url;
99 } else {
100 $tags['og:url'] = get_author_posts_url( $author->ID );
101 }
102 $tags['og:description'] = $author->description;
103 $tags['profile:first_name'] = get_the_author_meta( 'first_name', $author->ID );
104 $tags['profile:last_name'] = get_the_author_meta( 'last_name', $author->ID );
105 }
106 } elseif ( is_archive() ) {
107 $tags['og:type'] = 'website';
108 $tags['og:title'] = wp_get_document_title();
109
110 $archive = get_queried_object();
111 if ( $archive instanceof WP_Term ) {
112 $tags['og:url'] = get_term_link( $archive->term_id, $archive->taxonomy );
113 $tags['og:description'] = $archive->description;
114 } elseif ( ! empty( $archive ) && is_post_type_archive() ) {
115 $tags['og:url'] = get_post_type_archive_link( $archive->name );
116 $tags['og:description'] = $archive->description;
117 }
118 } elseif ( is_singular() && is_a( $data, 'WP_Post' ) ) {
119 $tags['og:type'] = 'article';
120 if ( empty( $data->post_title ) ) {
121 $tags['og:title'] = ' ';
122 } else {
123 /** This filter is documented in core/src/wp-includes/post-template.php */
124 $tags['og:title'] = wp_kses( apply_filters( 'the_title', $data->post_title, $data->ID ), array() );
125 }
126
127 $tags['og:url'] = get_permalink( $data->ID );
128 if ( ! post_password_required() ) {
129 /*
130 * If the post author set an excerpt, use that.
131 * Otherwise, pick the post content that comes before the More tag if there is one.
132 * Do not use the post content if it contains premium content.
133 */
134 if ( ! empty( $data->post_excerpt ) ) {
135 $tags['og:description'] = jetpack_og_get_description( $data->post_excerpt );
136 } elseif ( ! has_block( 'premium-content/container', $data->post_content ) ) {
137 $excerpt = explode( '<!--more-->', $data->post_content )[0];
138 $tags['og:description'] = jetpack_og_get_description( $excerpt );
139 }
140 }
141
142 $tags['article:published_time'] = gmdate( 'c', strtotime( $data->post_date_gmt ) );
143 $tags['article:modified_time'] = gmdate( 'c', strtotime( $data->post_modified_gmt ) );
144 if ( post_type_supports( get_post_type( $data ), 'author' ) && isset( $data->post_author ) ) {
145 $publicize_facebook_user = get_post_meta( $data->ID, '_publicize_facebook_user', true );
146 if ( ! empty( $publicize_facebook_user ) ) {
147 $tags['article:author'] = esc_url( $publicize_facebook_user );
148 }
149 }
150 } elseif ( is_search() ) {
151 if ( '' !== get_query_var( 's', '' ) ) {
152 $tags['og:title'] = wp_get_document_title();
153 }
154 }
155 /**
156 * Allow plugins to inject additional template-specific Open Graph tags.
157 *
158 * @module sharedaddy, publicize
159 *
160 * @since 3.0.0
161 *
162 * @param array $tags Array of Open Graph Meta tags.
163 * @param array $args Array of image size parameters.
164 */
165 $tags = apply_filters( 'jetpack_open_graph_base_tags', $tags, compact( 'image_width', 'image_height' ) );
166
167 // Re-enable widont if we had disabled it.
168 if ( $disable_widont ) {
169 add_filter( 'the_title', 'widont' );
170 }
171
172 /**
173 * Do not return any Open Graph Meta tags if we don't have any info about a post.
174 *
175 * @module sharedaddy, publicize
176 *
177 * @since 3.0.0
178 *
179 * @param bool true Do not return any Open Graph Meta tags if we don't have any info about a post.
180 */
181 if ( empty( $tags ) && apply_filters( 'jetpack_open_graph_return_if_empty', true ) ) {
182 return;
183 }
184
185 $tags['og:site_name'] = get_bloginfo( 'name' );
186
187 // Get image info and build tags.
188 if ( ! post_password_required() ) {
189 $image_info = jetpack_og_get_image( $image_width, $image_height );
190 $tags['og:image'] = $image_info['src'];
191
192 if ( ! empty( $image_info['width'] ) ) {
193 $tags['og:image:width'] = (int) $image_info['width'];
194 }
195 if ( ! empty( $image_info['height'] ) ) {
196 $tags['og:image:height'] = (int) $image_info['height'];
197 }
198 // If we have an image, add the alt text even if it's empty.
199 if ( ! empty( $image_info['src'] ) && isset( $image_info['alt_text'] ) ) {
200 $tags['og:image:alt'] = esc_attr( $image_info['alt_text'] );
201 }
202 }
203
204 // Facebook whines if you give it an empty title.
205 if ( empty( $tags['og:title'] ) ) {
206 $tags['og:title'] = __( '(no title)', 'jetpack' );
207 }
208
209 // Shorten the description if it's too long.
210 if ( isset( $tags['og:description'] ) ) {
211 $tags['og:description'] = strlen( $tags['og:description'] ) > $description_length ? mb_substr( $tags['og:description'], 0, $description_length ) . '' : $tags['og:description'];
212 }
213
214 // Try to add OG locale tag if the WP->FB data mapping exists.
215 if ( defined( 'JETPACK__GLOTPRESS_LOCALES_PATH' ) && file_exists( JETPACK__GLOTPRESS_LOCALES_PATH ) ) {
216 require_once JETPACK__GLOTPRESS_LOCALES_PATH;
217 $_locale = get_locale();
218
219 // We have to account for w.org vs WP.com locale divergence.
220 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
221 $gp_locale = GP_Locales::by_field( 'slug', $_locale );
222 } else {
223 $gp_locale = GP_Locales::by_field( 'wp_locale', $_locale );
224 }
225 }
226
227 if ( isset( $gp_locale->facebook_locale ) && ! empty( $gp_locale->facebook_locale ) ) {
228 $tags['og:locale'] = $gp_locale->facebook_locale;
229 }
230
231 /**
232 * Allow the addition of additional Open Graph Meta tags, or modify the existing tags.
233 *
234 * @module sharedaddy, publicize
235 *
236 * @since 2.0.0
237 *
238 * @param array $tags Array of Open Graph Meta tags.
239 * @param array $args Array of image size parameters.
240 */
241 $tags = apply_filters( 'jetpack_open_graph_tags', $tags, compact( 'image_width', 'image_height' ) );
242
243 // secure_urls need to go right after each og:image to work properly so we will abstract them here.
244 $tags['og:image:secure_url'] = ( empty( $tags['og:image:secure_url'] ) ) ? '' : $tags['og:image:secure_url'];
245 $secure = $tags['og:image:secure_url'];
246 unset( $tags['og:image:secure_url'] );
247 $secure_image_num = 0;
248
249 $allowed_empty_tags = array(
250 'og:image:alt',
251 );
252
253 foreach ( (array) $tags as $tag_property => $tag_content ) {
254 // to accommodate multiple images.
255 $tag_content = (array) $tag_content;
256 $tag_content = array_unique( array_filter( $tag_content, 'is_scalar' ) );
257
258 foreach ( $tag_content as $tag_content_single ) {
259 if ( empty( $tag_content_single ) && ! in_array( $tag_property, $allowed_empty_tags, true ) ) {
260 continue; // Only allow certain empty tags.
261 }
262
263 switch ( $tag_property ) {
264 case 'og:url':
265 case 'og:image':
266 case 'og:image:url':
267 case 'og:image:secure_url':
268 case 'og:audio':
269 case 'og:audio:url':
270 case 'og:audio:secure_url':
271 case 'og:video':
272 case 'og:video:url':
273 case 'og:video:secure_url':
274 $og_tag = sprintf( '<meta property="%s" content="%s" />', esc_attr( $tag_property ), esc_url( $tag_content_single ) );
275 break;
276 default:
277 $og_tag = sprintf( '<meta property="%s" content="%s" />', esc_attr( $tag_property ), esc_attr( $tag_content_single ) );
278 }
279 /**
280 * Filter the HTML Output of each Open Graph Meta tag.
281 *
282 * @module sharedaddy, publicize
283 *
284 * @since 2.0.0
285 *
286 * @param string $og_tag HTML HTML Output of each Open Graph Meta tag.
287 */
288 $og_output .= apply_filters( 'jetpack_open_graph_output', $og_tag );
289 $og_output .= "\n";
290
291 if ( 'og:image' === $tag_property ) {
292 if ( is_array( $secure ) && ! empty( $secure[ $secure_image_num ] ) ) {
293 $og_tag = sprintf( '<meta property="og:image:secure_url" content="%s" />', esc_url( $secure[ $secure_image_num ] ) );
294 /** This filter is documented in functions.opengraph.php */
295 $og_output .= apply_filters( 'jetpack_open_graph_output', $og_tag );
296 $og_output .= "\n";
297 } elseif ( ! is_array( $secure ) && ! empty( $secure ) ) {
298 $og_tag = sprintf( '<meta property="og:image:secure_url" content="%s" />', esc_url( $secure ) );
299 /** This filter is documented in functions.opengraph.php */
300 $og_output .= apply_filters( 'jetpack_open_graph_output', $og_tag );
301 $og_output .= "\n";
302 }
303 ++$secure_image_num;
304 }
305 }
306 }
307
308 if ( ! $is_amp_response ) { // Because AMP optimizes the order or the nodes in the head.
309 $og_output .= "\n<!-- End Jetpack Open Graph Tags -->";
310 }
311 $og_output .= "\n";
312 // This is trusted output or added by a filter.
313 echo $og_output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
314 }
315
316 /**
317 * Returns an image used in social shares.
318 *
319 * @since 2.0.0
320 *
321 * @param int $width Minimum width for the image. Default is 200 based on Facebook's requirement.
322 * @param int $height Minimum height for the image. Default is 200 based on Facebook's requirement.
323 * @param null $deprecated Deprecated.
324 *
325 * @return array The source ('src'), 'width', and 'height' of the image.
326 */
327 function jetpack_og_get_image( $width = 200, $height = 200, $deprecated = null ) {
328 if ( ! empty( $deprecated ) ) {
329 _deprecated_argument( __FUNCTION__, 'jetpack-6.6.0' );
330 }
331 $image = array();
332
333 if ( is_singular() && ! is_home() ) {
334 // Grab obvious image if post is an attachment page for an image.
335 if ( is_attachment( get_the_ID() ) && str_starts_with( get_post_mime_type(), 'image' ) ) {
336 $image['src'] = wp_get_attachment_url( get_the_ID() );
337 $image['alt_text'] = Images::get_alt_text( get_the_ID() );
338 }
339
340 // Attempt to find something good for this post using our generalized PostImages code.
341 if ( empty( $image ) ) {
342 $post_image = Images::get_image(
343 get_the_ID(),
344 array(
345 'width' => $width,
346 'height' => $height,
347 )
348 );
349 if ( ! empty( $post_image ) && is_array( $post_image ) ) {
350 $image['src'] = $post_image['src'];
351 if ( isset( $post_image['src_width'] ) && isset( $post_image['src_height'] ) ) {
352 $image['width'] = $post_image['src_width'];
353 $image['height'] = $post_image['src_height'];
354 }
355 if ( ! empty( $post_image['alt_text'] ) ) {
356 $image['alt_text'] = $post_image['alt_text'];
357 }
358 }
359 }
360 } elseif ( is_author() ) {
361 $author = get_queried_object();
362 if ( is_a( $author, 'WP_User' ) ) {
363 $image['src'] = get_avatar_url(
364 $author->user_email,
365 array(
366 'size' => $width,
367 )
368 );
369 $image['alt_text'] = $author->display_name;
370 }
371 }
372
373 /*
374 * Generate a fallback social image,
375 * dynamically generated based on the site name,
376 * a representative image of the site,
377 * and a custom template used by our Social Image Generator.
378 */
379 if ( empty( $image ) ) {
380 $site_image = jetpack_og_get_fallback_social_image( $width, $height );
381 if ( ! empty( $site_image ) ) {
382 $image['src'] = $site_image['src'];
383 $image['width'] = $site_image['width'];
384 $image['height'] = $site_image['height'];
385 }
386 }
387
388 // If we didn't get an explicit alt tag from the image, set a default.
389 if ( empty( $image['alt_text'] ) ) {
390 /**
391 * Filter the default Open Graph image alt text, used when the Open Graph image from the post does not have an alt text.
392 *
393 * @since 10.4
394 *
395 * @param string $str Default Open Graph image alt text.
396 */
397 $image['alt_text'] = apply_filters( 'jetpack_open_graph_image_default_alt_text', '' );
398 }
399
400 return $image;
401 }
402
403 /**
404 * Get a fallback social image for the site.
405 *
406 * @since 14.9
407 *
408 * @param int $width The width of the image.
409 * @param int $height The height of the image.
410 *
411 * @return array The source ('src'), 'width', 'height', and source type of the image.
412 */
413 function jetpack_og_get_fallback_social_image( $width, $height ) {
414 // Default template.
415 $template = 'edge';
416
417 // Let's get the site's representative image.
418 $site_image = jetpack_og_get_site_image( $width, $height );
419
420 /**
421 * Define your own site's representative image,
422 * to override any fallback image found by looking through site's logo, site icon, and blavatar.
423 * This will allow you to overwrite the default fallback image generated dynamically.
424 *
425 * @since 15.0
426 *
427 * @param array $site_image Your own site's representative image.
428 * @param array $site_image The site's representative image picked by Jetpack. {
429 * @type string $src The source of the image.
430 * @type int $width The width of the image.
431 * @type int $height The height of the image.
432 * @type string $type The type of the image.
433 * }
434 */
435 $custom_site_image = apply_filters( 'jetpack_og_default_site_image', array(), $site_image );
436 if ( ! empty( $custom_site_image['src'] ) ) {
437 return $custom_site_image;
438 }
439
440 if ( empty( $site_image['src'] ) ) {
441 // When using the default blank image, use a different template in Social Image Generator.
442 $template = 'highway';
443 $site_image['src'] = jetpack_og_get_site_fallback_blank_image();
444 }
445
446 // Return the site image if we are in offline mode instead of attempting to generate a dynamic one.
447 if ( ( new Status() )->is_offline_mode() ) {
448 return $site_image;
449 }
450
451 /*
452 * Only attempt to generate a dynamic fallback image
453 * if we have a healthy connection to WPCOM.
454 * and if the site has the right plan.
455 */
456 if (
457 ( ( new Host() )->is_wpcom_simple()
458 || ( new Connection_Manager() )->is_connected()
459 )
460 && Current_Plan::supports( 'social-image-generator' )
461 ) {
462 /**
463 * Allow filtering the template to use with Social Image Generator.
464 * Available templates: highway, dois, fullscreen, edge.
465 *
466 * @since 14.9
467 *
468 * @param string $template The template to use.
469 */
470 $template = apply_filters( 'jetpack_og_fallback_social_image_template', $template );
471
472 // Let's generate the image.
473 $site_image = jetpack_og_generate_fallback_social_image( $site_image, $template );
474 }
475
476 return $site_image;
477 }
478
479 /**
480 * Get the site's representative image.
481 *
482 * @since 14.9
483 *
484 * @param int $width The width of the image.
485 * @param int $height The height of the image.
486 *
487 * @return array The source ('src'), 'width', 'height', and source type of the image.
488 */
489 function jetpack_og_get_site_image( $width, $height ) {
490 // First fall back, blavatar.
491 if ( function_exists( 'blavatar_domain' ) ) {
492 $blavatar_domain = blavatar_domain( site_url() );
493 if ( blavatar_exists( $blavatar_domain ) ) {
494 return array(
495 'src' => blavatar_url( $blavatar_domain, 'img', $width, false, true ),
496 'width' => $width,
497 'height' => $height,
498 'type' => 'blavatar',
499 );
500 }
501 }
502
503 // Second fall back, Site Logo.
504 if (
505 function_exists( 'jetpack_has_site_logo' )
506 && jetpack_has_site_logo()
507 ) {
508 $image_id = jetpack_get_site_logo( 'id' );
509 $logo = wp_get_attachment_image_src( $image_id, 'full' );
510 if (
511 isset( $logo[0] ) && isset( $logo[1] ) && isset( $logo[2] )
512 && ( _jetpack_og_get_image_validate_size( $logo[1], $logo[2], $width, $height ) )
513 ) {
514 return array(
515 'src' => $logo[0],
516 'width' => $logo[1],
517 'height' => $logo[2],
518 'type' => 'site_logo',
519 );
520 }
521 }
522
523 // Third fall back, Core's site logo.
524 if ( has_custom_logo() ) {
525 $custom_logo_id = get_theme_mod( 'custom_logo' );
526 $sl_details = wp_get_attachment_image_src(
527 $custom_logo_id,
528 'full'
529 );
530 if (
531 isset( $sl_details[0] ) && isset( $sl_details[1] ) && isset( $sl_details[2] )
532 && ( _jetpack_og_get_image_validate_size( $sl_details[1], $sl_details[2], $width, $height ) )
533 ) {
534 return array(
535 'src' => $sl_details[0],
536 'width' => $sl_details[1],
537 'height' => $sl_details[2],
538 'alt_text' => Images::get_alt_text( $custom_logo_id ),
539 );
540 }
541 }
542
543 // Fourth fall back, Core Site Icon, if valid in size.
544 if ( has_site_icon() ) {
545 $image_id = get_option( 'site_icon' );
546 $icon = wp_get_attachment_image_src( $image_id, 'full' );
547 if (
548 isset( $icon[0] ) && isset( $icon[1] ) && isset( $icon[2] )
549 && ( _jetpack_og_get_image_validate_size( $icon[1], $icon[2], $width, $height ) )
550 ) {
551 return array(
552 'src' => $icon[0],
553 'width' => $icon[1],
554 'height' => $icon[2],
555 'type' => 'site_icon',
556 );
557 }
558 }
559
560 return array(
561 'src' => '',
562 'width' => $width,
563 'height' => $height,
564 'type' => 'blank',
565 );
566 }
567
568 /**
569 * Get the site's fallback image.
570 *
571 * @since 14.9
572 *
573 * @return string
574 */
575 function jetpack_og_get_site_fallback_blank_image() {
576 /**
577 * Filter the default Open Graph Image tag, used when no Image can be found in a post.
578 *
579 * @since 3.0.0
580 *
581 * @param string $str Default Image URL.
582 */
583 return apply_filters( 'jetpack_open_graph_image_default', 'https://s0.wp.com/i/blank.jpg' );
584 }
585
586 /**
587 * Get available templates for Social Image Generator.
588 *
589 * @since 14.9
590 *
591 * @return array The available templates.
592 */
593 function jetpack_og_get_available_templates() {
594 if ( ! class_exists( '\Automattic\Jetpack\Publicize\Social_Image_Generator\Templates' ) ) {
595 return array();
596 }
597
598 return \Automattic\Jetpack\Publicize\Social_Image_Generator\Templates::TEMPLATES;
599 }
600
601 /**
602 * Get a social image token from Social Image Generator.
603 *
604 * @since 14.9
605 *
606 * @param string $site_title The site title.
607 * @param string $image_url The image URL.
608 * @param string $template The template to use.
609 *
610 * @return string|WP_Error The social image token, or a WP_Error if the token could not be generated.
611 */
612 function jetpack_og_get_social_image_token( $site_title, $image_url, $template ) {
613 // Let's check if we have a cached token.
614 $cache_key = wp_hash( $site_title . $image_url . $template );
615 $transient_name = 'jetpack_og_social_image_token_' . $cache_key;
616 $cached_token = get_transient( $transient_name );
617
618 if ( ! empty( $cached_token ) ) {
619 return $cached_token;
620 }
621
622 /**
623 * Filter the social image token for testing purposes.
624 *
625 * @since 14.9
626 *
627 * @param string|WP_Error|null $token The token to return, or null to use default behavior.
628 */
629 $token = apply_filters( 'jetpack_og_get_social_image_token', null );
630 if ( null !== $token ) {
631 return $token;
632 }
633
634 if ( ! function_exists( '\Automattic\Jetpack\Publicize\Social_Image_Generator\fetch_token' ) ) {
635 return new WP_Error( 'jetpack_og_get_social_image_token_error', __( 'Social Image Generator is not available.', 'jetpack' ) );
636 }
637
638 $token = \Automattic\Jetpack\Publicize\Social_Image_Generator\fetch_token( $site_title, $image_url, $template );
639
640 /*
641 * We want to cache 2 types of responses:
642 * - Successful responses with a token.
643 * - WP_Error responses that denote a WordPress.com connection issue.
644 */
645 if (
646 ! is_wp_error( $token )
647 || (
648 is_wp_error( $token )
649 && 'invalid_user_permission_publicize' === $token->get_error_code()
650 )
651 ) {
652 set_transient( $transient_name, $token, DAY_IN_SECONDS );
653 }
654
655 return $token;
656 }
657
658 /**
659 * Generate and create a fallback social image.
660 *
661 * @since 14.9
662 *
663 * @param array $representative_image The representative image of the site.
664 * @param string $template The template to use.
665 *
666 * @return array The source ('src'), 'width', and 'height' of the image.
667 */
668 function jetpack_og_generate_fallback_social_image( $representative_image, $template ) {
669 $site_title = get_bloginfo( 'name' );
670 $fallback_image = array(
671 'src' => $representative_image['src'],
672 'width' => $representative_image['width'],
673 'height' => $representative_image['height'],
674 );
675
676 // Ensure that we use a valid template.
677 if (
678 ! in_array(
679 $template,
680 jetpack_og_get_available_templates(),
681 true
682 )
683 ) {
684 $template = 'edge';
685 }
686
687 // Let's generate the token matching the image..
688 $token = jetpack_og_get_social_image_token( $site_title, $representative_image['src'], $template );
689
690 if ( is_wp_error( $token ) ) {
691 return $fallback_image;
692 }
693
694 // Build the image URL and return it.
695 return array(
696 'src' => sprintf(
697 'https://s0.wp.com/_si/?t=%s',
698 $token
699 ),
700 'width' => 1200,
701 'height' => 630,
702 );
703 }
704
705 /**
706 * Validate the width and height against required width and height
707 *
708 * @param int $width Width of the image.
709 * @param int $height Height of the image.
710 * @param int $req_width Required width to pass validation.
711 * @param int $req_height Required height to pass validation.
712 *
713 * @return bool - True if the image passed the required size validation
714 */
715 function _jetpack_og_get_image_validate_size( $width, $height, $req_width, $req_height ) {
716 if ( ! $width || ! $height ) {
717 return false;
718 }
719
720 $valid_width = ( $width >= $req_width );
721 $valid_height = ( $height >= $req_height );
722 $is_image_acceptable = $valid_width && $valid_height;
723
724 return $is_image_acceptable;
725 }
726
727 /**
728 * Gets a gravatar URL of the specified size.
729 *
730 * @param string $email E-mail address to get gravatar for.
731 * @param int $width Size of returned gravatar.
732 * @return array|bool|mixed|string
733 */
734 function jetpack_og_get_image_gravatar( $email, $width ) {
735 return get_avatar_url(
736 $email,
737 array(
738 'size' => $width,
739 )
740 );
741 }
742
743 /**
744 * Clean up text meant to be used as Description Open Graph tag.
745 *
746 * There should be:
747 * - no links
748 * - no shortcodes
749 * - no html tags or their contents
750 * - no content within wp:query blocks
751 * - not too many words.
752 *
753 * @param string $description Text coming from WordPress (autogenerated or manually generated by author).
754 * @param WP_Post|null $data Information about our post.
755 *
756 * @return string $description Cleaned up description string.
757 */
758 function jetpack_og_get_description( $description = '', $data = null ) {
759 // Remove content within wp:query blocks.
760 $description = jetpack_og_remove_query_blocks( $description );
761
762 // Remove tags such as <style or <script.
763 $description = wp_strip_all_tags( $description );
764
765 /*
766 * Clean up any plain text entities left into formatted entities.
767 * Intentionally not using a filter to prevent pollution.
768 * @see https://github.com/Automattic/jetpack/pull/2899#issuecomment-151957382
769 */
770 $description = wp_kses(
771 trim(
772 convert_chars(
773 wptexturize( $description )
774 )
775 ),
776 array()
777 );
778
779 // Remove shortcodes.
780 $description = strip_shortcodes( $description );
781
782 // Remove links.
783 $description = preg_replace(
784 '@https?://[\S]+@',
785 '',
786 $description
787 );
788
789 /*
790 * Limit things to a small text blurb.
791 * There isn't a hard limit set by Facebook, so let's rely on WP's own limit.
792 * (55 words or the localized equivalent).
793 * This limit can be customized with the wp_trim_words filter.
794 */
795 $description = wp_trim_words( $description );
796
797 // Let's set a default if we have no text by now.
798 if ( empty( $description ) ) {
799 /**
800 * Filter the fallback `og:description` used when no excerpt information is provided.
801 *
802 * @module sharedaddy, publicize
803 *
804 * @since 3.9.0
805 *
806 * @param string $var Fallback og:description. Default is translated `Visit the post for more'.
807 * @param object $data Post object for the current post.
808 */
809 $description = apply_filters(
810 'jetpack_open_graph_fallback_description',
811 __( 'Visit the post for more.', 'jetpack' ),
812 $data
813 );
814 }
815
816 return $description;
817 }
818
819 /**
820 * Remove content within wp:query blocks from the description.
821 *
822 * @since 14.9
823 *
824 * @param string $description The description text that may contain block markup.
825 * @return string The description with wp:query blocks removed.
826 */
827 function jetpack_og_remove_query_blocks( $description ) {
828 // Handle non-string input
829 if ( ! is_string( $description ) ) {
830 return '';
831 }
832
833 $output = '';
834 $offset = 0;
835 $depth = 0;
836 $in_query_block = false;
837
838 $scanner = Block_Scanner::create( $description );
839 if ( ! $scanner ) {
840 return $description;
841 }
842
843 while ( $scanner->next_delimiter() ) {
844 $span = $scanner->get_span();
845 $match_at = $span->start;
846 $length = $span->length;
847
848 // Check if this is a query block.
849 if ( $scanner->is_block_type( 'query' ) ) {
850 switch ( $scanner->get_delimiter_type() ) {
851 case Block_Scanner::OPENER:
852 if ( ! $in_query_block ) {
853 // Copy content before the query block.
854 $output .= substr( $description, $offset, $match_at - $offset );
855 $in_query_block = true;
856 }
857 ++$depth;
858 break;
859
860 case Block_Scanner::CLOSER:
861 --$depth;
862 if ( $in_query_block && $depth === 0 ) {
863 // We've exited the query block, continue from after it.
864 $in_query_block = false;
865 $offset = $match_at + $length;
866
867 // Remove extra newline if present
868 if (
869 str_starts_with( substr( $description, $offset ), "\n" )
870 && str_ends_with( $output, "\n" )
871 ) {
872 ++$offset;
873 }
874 }
875 break;
876
877 case Block_Scanner::VOID:
878 // Void query blocks should be removed entirely.
879 if ( ! $in_query_block ) {
880 $output .= substr( $description, $offset, $match_at - $offset );
881 $offset = $match_at + $length;
882 // Remove extra newline if present
883 if (
884 str_starts_with( substr( $description, $offset ), "\n" )
885 && str_ends_with( $output, "\n" )
886 ) {
887 ++$offset;
888 }
889 }
890 break;
891 }
892 } elseif ( ! $in_query_block ) {
893 // Not a query block, copy content including the delimiter if we're not inside a query block.
894 $output .= substr( $description, $offset, $match_at - $offset + $length );
895 $offset = $match_at + $length;
896 }
897 }
898
899 // Add any remaining content after the last delimiter.
900 if ( ! $in_query_block ) {
901 $output .= substr( $description, $offset );
902 }
903
904 return $output;
905 }
906
907 /**
908 * Display a Fediverse actor Open Graph tag when the post author has a Mastodon connection.
909 *
910 * @see https://blog.joinmastodon.org/2024/07/highlighting-journalism-on-mastodon/
911 *
912 * @since 13.8
913 *
914 * @param array $tags Current tags.
915 *
916 * @return array
917 */
918 function jetpack_add_fediverse_creator_open_graph_tag( $tags ) {
919 /*
920 * Let's not do this on WordPress.com Simple for now,
921 * because of its performance impact.
922 * See p1723574138779019/1723572983.803009-slack-C01U2KGS2PQ
923 */
924 if ( ( new Host() )->is_wpcom_simple() ) {
925 return $tags;
926 }
927
928 // Let's not add any tags when the ActivityPub plugin already adds its own.
929 $is_activitypub_opengraph_integration_active = get_option( 'activitypub_use_opengraph' );
930 if ( $is_activitypub_opengraph_integration_active ) {
931 return $tags;
932 }
933
934 // We pull the Mastodon connection data from Publicize.
935 if ( ! function_exists( 'publicize_init' ) ) {
936 return $tags;
937 }
938 $publicize = publicize_init();
939
940 global $post;
941 if (
942 ! is_singular()
943 || ! $post instanceof WP_Post
944 || ! isset( $post->ID )
945 || empty( $post->post_author )
946 ) {
947 return $tags;
948 }
949
950 $post_mastodon_connections = array();
951
952 // Loop through active connections.
953 foreach ( (array) $publicize->get_services( 'connected' ) as $service_name => $connections ) {
954 if ( 'mastodon' !== $service_name ) {
955 continue;
956 }
957
958 // services can have multiple connections. Store them all in our array.
959 foreach ( $connections as $connection ) {
960 $connection_id = $publicize->get_connection_id( $connection );
961 $connection_meta = $publicize->get_connection_meta( $connection );
962
963 $connection_data = $connection_meta['connection_data'] ?? array();
964 $mastodon_handle = $connection_meta['external_display'] ?? '';
965 $connection_user_id = $connection_data['user_id'] ?? 0;
966
967 if ( empty( $mastodon_handle ) ) {
968 continue;
969 }
970
971 // Did we skip this connection for this post?
972 if ( get_post_meta( $post->ID, $publicize->POST_SKIP_PUBLICIZE . $connection_id, true ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
973 continue;
974 }
975
976 $post_mastodon_connections[] = array(
977 'user_id' => (int) $connection_user_id,
978 'connection_id' => (int) $connection_id,
979 'handle' => $mastodon_handle,
980 'global' => 0 === (int) $connection_user_id,
981 );
982 }
983 }
984
985 // If we have no Mastodon connections, skip.
986 if ( empty( $post_mastodon_connections ) ) {
987 return $tags;
988 }
989
990 /*
991 * Select a single Mastodon connection to use.
992 * It should be either the first connection belonging to the post author,
993 * or the first global connection.
994 */
995 foreach ( $post_mastodon_connections as $mastodon_connection ) {
996 if ( (int) $post->post_author === $mastodon_connection['user_id'] ) {
997 $tags['fediverse:creator'] = esc_attr( $mastodon_connection['handle'] );
998 break;
999 }
1000
1001 if ( $mastodon_connection['global'] ) {
1002 $tags['fediverse:creator'] = esc_attr( $mastodon_connection['handle'] );
1003 break;
1004 }
1005 }
1006
1007 return $tags;
1008 }
1009
1010 /**
1011 * Update the markup for the Open Graph tag to match the expected output for Mastodon
1012 * (name instead of property).
1013 *
1014 * @since 13.8
1015 *
1016 * @param string $og_tag A single OG tag.
1017 *
1018 * @return string Result of the OG tag.
1019 */
1020 function jetpack_filter_fediverse_cards_output( $og_tag ) {
1021 return ( str_contains( $og_tag, 'fediverse:' ) ) ? preg_replace( '/property="([^"]+)"/', 'name="\1"', $og_tag ) : $og_tag;
1022 }
1023