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