Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
62.18% covered (warning)
62.18%
74 / 119
41.67% covered (danger)
41.67%
5 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Twitter_Cards
62.18% covered (warning)
62.18%
74 / 119
41.67% covered (danger)
41.67%
5 / 12
388.34
0.00% covered (danger)
0.00%
0 / 1
 twitter_cards_tags
66.18% covered (warning)
66.18%
45 / 68
0.00% covered (danger)
0.00%
0 / 1
118.91
 sanitize_twitter_user
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_default_site_tag
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prioritize_creator_over_default_site
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 twitter_cards_define_type_based_on_image_count
73.33% covered (warning)
73.33%
11 / 15
0.00% covered (danger)
0.00%
0 / 1
14.73
 twitter_cards_output
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 settings_init
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 sharing_global_options
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 site_tag
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
 settings_field
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 settings_validate
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
20
 init
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Twitter Cards handling.
4 *
5 * @package automattic/jetpack-post-media
6 */
7
8namespace Automattic\Jetpack\Post_Media;
9
10use WP_Post;
11
12/**
13 * Twitter Cards
14 *
15 * Hooks onto the Open Graph protocol and extends it by adding only the tags
16 * we need for twitter cards.
17 *
18 * @see https://dev.twitter.com/cards/overview
19 */
20class Twitter_Cards {
21
22    /**
23     * Adds Twitter Card tags.
24     *
25     * @param array $og_tags Existing OG tags.
26     *
27     * @return array OG tags inclusive of Twitter Card output.
28     */
29    public static function twitter_cards_tags( $og_tags ) {
30        global $post;
31        $post_id = ( $post instanceof WP_Post ) ? $post->ID : null;
32
33        /**
34         * Maximum alt text length.
35         *
36         * @see https://developer.twitter.com/en/docs/tweets/optimize-with-cards/overview/summary-card-with-large-image.html
37         */
38        $alt_length = 420;
39
40        if ( post_password_required() ) {
41            return $og_tags;
42        }
43
44        /** This action is documented in class.jetpack.php */
45        if ( apply_filters( 'jetpack_disable_twitter_cards', false ) ) {
46            return $og_tags;
47        }
48
49        /*
50         * These tags apply to any page (home, archives, etc).
51         */
52
53        // If we have information on the author/creator, then include that as well.
54        if ( ! empty( $post ) && ! empty( $post->post_author ) ) {
55            /** This action is documented in modules/sharedaddy/sharing-sources.php */
56            $handle = apply_filters( 'jetpack_sharing_twitter_via', '', $post_id );
57            if ( ! empty( $handle ) && ! self::is_default_site_tag( $handle ) ) {
58                $og_tags['twitter:creator'] = self::sanitize_twitter_user( $handle );
59            }
60        }
61
62        $site_tag = self::site_tag();
63        /** This action is documented in modules/sharedaddy/sharing-sources.php */
64        $site_tag = apply_filters( 'jetpack_sharing_twitter_via', $site_tag, ( is_singular() ? $post_id : null ) );
65        /** This action is documented in modules/sharedaddy/sharing-sources.php */
66        $site_tag = apply_filters( 'jetpack_twitter_cards_site_tag', $site_tag, $og_tags );
67        if ( ! empty( $site_tag ) ) {
68            $og_tags['twitter:site'] = self::sanitize_twitter_user( $site_tag );
69        }
70
71        if ( ! is_singular() || ! empty( $og_tags['twitter:card'] ) ) {
72            /**
73             * Filter the default Twitter card image, used when no image can be found in a post.
74             *
75             * @module sharedaddy
76             *
77             * @since jetpack-5.9.0
78             *
79             * @param string $str Default image URL.
80             */
81            $image = apply_filters( 'jetpack_twitter_cards_image_default', '' );
82            if ( ! empty( $image ) ) {
83                $og_tags['twitter:image'] = $image;
84            }
85
86            return $og_tags;
87        }
88
89        $the_title = get_the_title();
90        if ( ! $the_title ) {
91            $the_title = get_bloginfo( 'name' );
92        }
93        $og_tags['twitter:text:title'] = $the_title;
94
95        /*
96         * The following tags only apply to single pages.
97         */
98
99        $card_type = 'summary';
100
101        // Try to give priority to featured images.
102        if ( ! empty( $post_id ) ) {
103            $post_image = Images::get_image(
104                $post_id,
105                array(
106                    'width'  => 144,
107                    'height' => 144,
108                )
109            );
110            if ( ! empty( $post_image ) && is_array( $post_image ) ) {
111                // 4096 is the maximum size for an image per https://developer.twitter.com/en/docs/tweets/optimize-with-cards/overview/summary .
112                if (
113                    isset( $post_image['src_width'] ) && isset( $post_image['src_height'] )
114                    && (int) $post_image['src_width'] <= 4096
115                    && (int) $post_image['src_height'] <= 4096
116                ) {
117                    // 300x157 is the minimum size for a summary_large_image per https://developer.twitter.com/en/docs/tweets/optimize-with-cards/overview/summary-card-with-large-image .
118                    if ( (int) $post_image['src_width'] >= 300 && (int) $post_image['src_height'] >= 157 ) {
119                        $card_type                = 'summary_large_image';
120                        $og_tags['twitter:image'] = esc_url( add_query_arg( 'w', 640, $post_image['src'] ) );
121                    } else {
122                        $og_tags['twitter:image'] = esc_url( add_query_arg( 'w', 144, $post_image['src'] ) );
123                    }
124
125                    // Add the alt tag if we have one.
126                    if ( ! empty( $post_image['alt_text'] ) ) {
127                        // Shorten it if it is too long.
128                        if ( strlen( $post_image['alt_text'] ) > $alt_length ) {
129                            $og_tags['twitter:image:alt'] = esc_attr( mb_substr( $post_image['alt_text'], 0, $alt_length ) . '…' );
130                        } else {
131                            $og_tags['twitter:image:alt'] = esc_attr( $post_image['alt_text'] );
132                        }
133                    }
134                }
135            }
136        }
137
138        $extract = array();
139
140        // Only proceed with media analysis if a featured image has not superseded it already.
141        if ( empty( $og_tags['twitter:image'] ) && empty( $og_tags['twitter:image:src'] ) ) {
142            // @todo Jetpack_Media_Summary is defined in the Jetpack plugin. It should be moved to this package.
143            if ( ! class_exists( 'Jetpack_Media_Summary' ) && defined( 'JETPACK__PLUGIN_DIR' ) ) {
144                require_once JETPACK__PLUGIN_DIR . '_inc/lib/class.media-summary.php';
145            }
146
147            // Test again, class should already be auto-loaded in Jetpack.
148            // If not, skip extra media analysis and stick with a summary card.
149            if ( class_exists( 'Jetpack_Media_Summary' ) && ! empty( $post_id ) ) {
150                $extract = \Jetpack_Media_Summary::get( $post_id ); // @phan-suppress-current-line PhanUndeclaredClassMethod -- Guarded by class_exists().
151
152                if ( 'gallery' === $extract['type'] ) {
153                    list( $og_tags, $card_type ) = self::twitter_cards_define_type_based_on_image_count( $og_tags, $extract );
154                } elseif ( 'video' === $extract['type'] ) {
155                    // Leave as summary, but with large pict of poster frame (we know those comply to Twitter's size requirements).
156                    $card_type                = 'summary_large_image';
157                    $og_tags['twitter:image'] = esc_url( add_query_arg( 'w', 640, $extract['image'] ) );
158                } else {
159                    list( $og_tags, $card_type ) = self::twitter_cards_define_type_based_on_image_count( $og_tags, $extract );
160                }
161            }
162        }
163
164        $og_tags['twitter:card'] = $card_type;
165
166        // Make sure we have a description for Twitter, their validator isn't happy without some content (single space not valid).
167        if ( ! isset( $og_tags['og:description'] ) || '' === trim( $og_tags['og:description'] ) || __( 'Visit the post for more.', 'jetpack-post-media' ) === $og_tags['og:description'] ) { // empty( trim( $og_tags['og:description'] ) ) isn't valid php.
168            $has_creator = ! empty( $og_tags['twitter:creator'] ) && '@wordpressdotcom' !== $og_tags['twitter:creator'];
169            if ( ! empty( $extract ) && 'video' === $extract['type'] ) { // use $extract['type'] since $card_type is 'summary' for video posts.
170                /* translators: %s is the post author */
171                $og_tags['twitter:description'] = ( $has_creator ) ? sprintf( __( 'Video post by %s.', 'jetpack-post-media' ), $og_tags['twitter:creator'] ?? '' ) : __( 'Video post.', 'jetpack-post-media' );
172            } else {
173                /* translators: %s is the post author */
174                $og_tags['twitter:description'] = ( $has_creator ) ? sprintf( __( 'Post by %s.', 'jetpack-post-media' ), $og_tags['twitter:creator'] ?? '' ) : __( 'Visit the post for more.', 'jetpack-post-media' );
175            }
176        }
177
178        if ( empty( $og_tags['twitter:image'] ) && empty( $og_tags['twitter:image:src'] ) ) {
179            /** This action is documented in class-twitter-cards.php */
180            $image = apply_filters( 'jetpack_twitter_cards_image_default', '' );
181            if ( ! empty( $image ) ) {
182                $og_tags['twitter:image'] = $image;
183            }
184        }
185
186        return $og_tags;
187    }
188
189    /**
190     * Sanitize the Twitter user by normalizing the @.
191     *
192     * @param string $str Twitter user value.
193     *
194     * @return string Twitter user value.
195     */
196    public static function sanitize_twitter_user( $str ) {
197        return '@' . preg_replace( '/^@/', '', $str );
198    }
199
200    /**
201     * Determines if a site tag is one of the default WP.com/Jetpack ones.
202     *
203     * @param string $site_tag Site tag.
204     *
205     * @return bool True if the default site tag is being used.
206     */
207    public static function is_default_site_tag( $site_tag ) {
208        return in_array( $site_tag, array( '@wordpressdotcom', '@jetpack', 'wordpressdotcom', 'jetpack' ), true );
209    }
210
211    /**
212     * Give priority to the creator tag if using the default site tag.
213     *
214     * @param string $site_tag Site tag.
215     * @param array  $og_tags OG tags.
216     *
217     * @return string Site tag.
218     */
219    public static function prioritize_creator_over_default_site( $site_tag, $og_tags = array() ) {
220        if ( ! empty( $og_tags['twitter:creator'] ) && self::is_default_site_tag( $site_tag ) ) {
221            return $og_tags['twitter:creator'];
222        }
223        return $site_tag;
224    }
225
226    /**
227     * Define the Twitter Card type based on image count.
228     *
229     * @param array $og_tags Existing OG tags.
230     * @param array $extract Result of the Image Extractor class.
231     *
232     * @return array
233     */
234    public static function twitter_cards_define_type_based_on_image_count( $og_tags, $extract ) {
235        $card_type = 'summary';
236        $img_count = $extract['count']['image'];
237
238        if ( empty( $img_count ) ) {
239
240            // No images, use Blavatar as a thumbnail for the summary type.
241            // @todo blavatar_domain, blavatar_exists, and blavatar_url are WordPress.com functions. They should be abstracted.
242            if ( function_exists( 'blavatar_domain' ) ) {
243                $blavatar_domain = blavatar_domain( site_url() ); // @phan-suppress-current-line PhanUndeclaredFunction -- Guarded by function_exists().
244                if ( blavatar_exists( $blavatar_domain ) ) { // @phan-suppress-current-line PhanUndeclaredFunction -- Guarded by function_exists().
245                    $og_tags['twitter:image'] = blavatar_url( $blavatar_domain, 'img', 240 ); // @phan-suppress-current-line PhanUndeclaredFunction -- Guarded by function_exists().
246                }
247            }
248
249            // Second fall back, Site Logo.
250            // @todo jetpack_has_site_logo and jetpack_get_site_logo are defined in the Jetpack plugin. They should be abstracted.
251            if ( empty( $og_tags['twitter:image'] ) && ( function_exists( 'jetpack_has_site_logo' ) && jetpack_has_site_logo() ) ) { // @phan-suppress-current-line PhanUndeclaredFunction -- Guarded by function_exists().
252                $og_tags['twitter:image'] = jetpack_get_site_logo( 'url' ); // @phan-suppress-current-line PhanUndeclaredFunction -- Guarded by function_exists().
253            }
254
255            // Third fall back, Site Icon.
256            if ( empty( $og_tags['twitter:image'] ) && has_site_icon() ) {
257                $og_tags['twitter:image'] = get_site_icon_url( 240 );
258            }
259
260            // Not falling back on Gravatar, because there's no way to know if we end up with an auto-generated one.
261
262        } elseif ( 'image' === $extract['type'] || 'gallery' === $extract['type'] ) {
263            // Test for $extract['type'] to limit to image and gallery, so we don't send a potential fallback image like a Gravatar as a photo post.
264            $card_type                = 'summary_large_image';
265            $og_tags['twitter:image'] = esc_url( add_query_arg( 'w', 1400, ( empty( $extract['images'] ) ) ? $extract['image'] : $extract['images'][0]['url'] ) );
266        }
267
268        return array( $og_tags, $card_type );
269    }
270
271    /**
272     * Updates the Twitter Card output.
273     *
274     * @param string $og_tag A single OG tag.
275     *
276     * @return string Result of the OG tag.
277     */
278    public static function twitter_cards_output( $og_tag ) {
279        return ( str_contains( $og_tag, 'twitter:' ) ) ? preg_replace( '/property="([^"]+)"/', 'name="\1"', $og_tag ) : $og_tag;
280    }
281
282    /**
283     * Adds settings section and field.
284     */
285    public static function settings_init() {
286        add_settings_section( 'jetpack-twitter-cards-settings', 'Twitter Cards', '__return_false', 'sharing' );
287        add_settings_field(
288            'jetpack-twitter-cards-site-tag',
289            __( 'Twitter Site Tag', 'jetpack-post-media' ),
290            array( __CLASS__, 'settings_field' ),
291            'sharing',
292            'jetpack-twitter-cards-settings',
293            array(
294                'label_for' => 'jetpack-twitter-cards-site-tag',
295            )
296        );
297    }
298
299    /**
300     * Add global sharing options.
301     */
302    public static function sharing_global_options() {
303        do_settings_fields( 'sharing', 'jetpack-twitter-cards-settings' );
304    }
305
306    /**
307     * Get the Twitter Via tag.
308     *
309     * @return string Twitter via tag.
310     */
311    public static function site_tag() {
312        $site_tag = ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ?
313            trim( get_option( 'twitter_via' ) ) :
314            ( class_exists( 'Jetpack_Options' ) ? \Jetpack_Options::get_option_and_ensure_autoload( 'jetpack-twitter-cards-site-tag', '' ) : get_option( 'jetpack-twitter-cards-site-tag', '' ) );
315        if ( empty( $site_tag ) ) {
316            /** This action is documented in modules/sharedaddy/sharing-sources.php */
317            return apply_filters( 'jetpack_sharing_twitter_via', '', null );
318        }
319        return $site_tag;
320    }
321
322    /**
323     * Output the settings field.
324     */
325    public static function settings_field() {
326        wp_nonce_field( 'jetpack-twitter-cards-settings', 'jetpack_twitter_cards_nonce', false );
327        ?>
328        <input type="text" id="jetpack-twitter-cards-site-tag" class="regular-text" name="jetpack-twitter-cards-site-tag" value="<?php echo esc_attr( get_option( 'jetpack-twitter-cards-site-tag' ) ); ?>" />
329        <p class="description" style="width: auto;"><?php esc_html_e( 'The Twitter username of the owner of this site\'s domain.', 'jetpack-post-media' ); ?></p>
330        <?php
331    }
332
333    /**
334     * Validate the settings submission.
335     */
336    public static function settings_validate() {
337        if ( isset( $_POST['jetpack_twitter_cards_nonce'] ) && wp_verify_nonce( $_POST['jetpack_twitter_cards_nonce'], 'jetpack-twitter-cards-settings' ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
338            update_option( 'jetpack-twitter-cards-site-tag', isset( $_POST['jetpack-twitter-cards-site-tag'] ) ? trim( ltrim( wp_strip_all_tags( filter_var( wp_unslash( $_POST['jetpack-twitter-cards-site-tag'] ) ) ), '@' ) ) : '' );
339        }
340    }
341
342    /**
343     * Initiates the class by registering all WordPress hooks.
344     */
345    public static function init() {
346        add_filter( 'jetpack_open_graph_tags', array( __CLASS__, 'twitter_cards_tags' ), 11 ); // $priority=11: this should hook into jetpack_open_graph_tags after 'class.jetpack-seo.php' has done so.
347        add_filter( 'jetpack_open_graph_output', array( __CLASS__, 'twitter_cards_output' ) );
348        add_filter( 'jetpack_twitter_cards_site_tag', array( __CLASS__, 'site_tag' ), -99 );
349        add_filter( 'jetpack_twitter_cards_site_tag', array( __CLASS__, 'prioritize_creator_over_default_site' ), 99, 2 );
350        add_action( 'admin_init', array( __CLASS__, 'settings_init' ) );
351        add_action( 'sharing_global_options', array( __CLASS__, 'sharing_global_options' ) );
352        add_action( 'sharing_admin_update', array( __CLASS__, 'settings_validate' ) );
353    }
354}