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