Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
8.40% covered (danger)
8.40%
10 / 119
0.00% covered (danger)
0.00%
0 / 5
CRAP
n/a
0 / 0
Automattic\Jetpack\Extensions\Pinterest\pin_type
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
7.23
Automattic\Jetpack\Extensions\Pinterest\register_block
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
Automattic\Jetpack\Extensions\Pinterest\fetch_pin_info
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
110
Automattic\Jetpack\Extensions\Pinterest\render_amp_pin
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 1
56
Automattic\Jetpack\Extensions\Pinterest\load_assets
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * Pinterest Block.
4 *
5 * Note: this block is no longer available to be added to new posts.
6 * It is only kept available for existing posts with Pinterest blocks.
7 * You can still embed Pinterest content using the embed method provided by WordPress itself.
8 *
9 * @since 8.0.0
10 *
11 * @package automattic/jetpack
12 */
13
14namespace Automattic\Jetpack\Extensions\Pinterest;
15
16use Automattic\Jetpack\Blocks;
17use Automattic\Jetpack\Status\Request;
18use WP_Error;
19
20if ( ! defined( 'ABSPATH' ) ) {
21    exit( 0 );
22}
23
24const URL_PATTERN = '#^https?://(?:www\.)?(?:[a-z]{2}\.)?pinterest\.[a-z.]+/pin/(?P<pin_id>[^/]+)/?#i'; // Taken from AMP plugin, originally from Jetpack.
25// This is the validate Pinterest URLs, converted from URL_REGEX in extensions/blocks/pinterest/index.js.
26const PINTEREST_URL_REGEX = '/^https?:\/\/(?:www\.)?(?:[a-z]{2}\.)?(?:pinterest\.[a-z.]+|pin\.it)\/([^\/]+)(\/[^\/]+)?/i';
27// This looks for matches in /foo/ of https://www.pinterest.ca/foo/.
28const REMAINING_URL_PATH_REGEX = '/^\/([^\/]+)\/?$/';
29// This looks for matches with /foo/bar/ of https://www.pinterest.ca/foo/bar/.
30const REMAINING_URL_PATH_WITH_SUBPATH_REGEX = '/^\/([^\/]+)\/([^\/]+)\/?$/';
31
32/**
33 * Determines the Pinterest embed type from the URL.
34 *
35 * @param string $url the URL to check.
36 * @return string The pin type. Empty string if it isn't a valid Pinterest URL.
37 */
38function pin_type( $url ) {
39    if ( null === $url || ! preg_match( PINTEREST_URL_REGEX, $url ) ) {
40        return '';
41    }
42
43    $path = wp_parse_url( $url, PHP_URL_PATH );
44
45    if ( ! $path ) {
46        return '';
47    }
48
49    if ( str_starts_with( $path, '/pin/' ) ) {
50        return 'embedPin';
51    }
52
53    if ( preg_match( REMAINING_URL_PATH_REGEX, $path ) ) {
54        return 'embedUser';
55    }
56
57    if ( preg_match( REMAINING_URL_PATH_WITH_SUBPATH_REGEX, $path ) ) {
58        return 'embedBoard';
59    }
60
61    return '';
62}
63
64/**
65 * Registers the block for use in Gutenberg
66 * This is done via an action so that we can disable
67 * registration if we need to.
68 */
69function register_block() {
70    Blocks::jetpack_register_block(
71        __DIR__,
72        array( 'render_callback' => __NAMESPACE__ . '\load_assets' )
73    );
74}
75add_action( 'init', __NAMESPACE__ . '\register_block' );
76
77/**
78 * Fetch info for a Pin.
79 *
80 * This is using the same pin info API as AMP is using client-side in the amp-pinterest component.
81 * Successful API responses are cached in a transient for 1 month. Unsuccessful responses are cached for 1 hour.
82 *
83 * @link https://github.com/ampproject/amphtml/blob/b5dea36e0b8bd012585d50839766a084f99a3685/extensions/amp-pinterest/0.1/pin-widget.js#L83-L97
84 * @param string $pin_id Pin ID.
85 * @return array|WP_Error Pin info or error on failure.
86 */
87function fetch_pin_info( $pin_id ) {
88    $transient_id = substr( "jetpack_pin_info_{$pin_id}", 0, 172 );
89
90    $info = get_transient( $transient_id );
91    if ( is_array( $info ) || is_wp_error( $info ) ) {
92        return $info;
93    }
94
95    $pin_info_api_url = add_query_arg(
96        array(
97            'pin_ids'     => rawurlencode( $pin_id ),
98            'sub'         => 'wwww',
99            'base_scheme' => 'https',
100        ),
101        'https://widgets.pinterest.com/v3/pidgets/pins/info/'
102    );
103
104    $response = wp_remote_get( esc_url_raw( $pin_info_api_url ) );
105    if ( is_wp_error( $response ) ) {
106        set_transient( $transient_id, $response, HOUR_IN_SECONDS );
107        return $response;
108    }
109
110    $error = null;
111    $body  = json_decode( wp_remote_retrieve_body( $response ), true );
112    if ( ! is_array( $body ) || ! isset( $body['status'] ) ) {
113        $error = new WP_Error( 'bad_json_response', '', compact( 'pin_id' ) );
114    } elseif ( 'success' !== $body['status'] || ! isset( $body['data'][0] ) ) {
115        $error = new WP_Error( 'unsuccessful_request', '', compact( 'pin_id' ) );
116    } elseif ( ! isset( $body['data'][0]['images']['237x'] ) ) {
117        // See <https://github.com/ampproject/amphtml/blob/b5dea36e0b8bd012585d50839766a084f99a3685/extensions/amp-pinterest/0.1/pin-widget.js#L106>.
118        $error = new WP_Error( 'missing_required_image', '', compact( 'pin_id' ) );
119    }
120
121    if ( $error ) {
122        set_transient( $transient_id, $error, HOUR_IN_SECONDS );
123        return $error;
124    } else {
125        $data = $body['data'][0];
126        set_transient( $transient_id, $data, MONTH_IN_SECONDS );
127        return $data;
128    }
129}
130
131/**
132 * Render a Pin using the amp-pinterest component.
133 *
134 * This does not render boards or user profiles.
135 *
136 * Since AMP components need to be statically sized to be valid (so as to avoid layout shifting), there are quite a few
137 * hard-coded numbers as taken from the CSS for the AMP component.
138 *
139 * @param array $attr Block attributes.
140 * @return string Markup for <amp-pinterest>.
141 */
142function render_amp_pin( $attr ) {
143    $info = null;
144    if ( preg_match( URL_PATTERN, $attr['url'], $matches ) ) {
145        $info = fetch_pin_info( $matches['pin_id'] );
146    }
147
148    if ( is_array( $info ) ) {
149        $image       = $info['images']['237x'];
150        $title       = $info['rich_metadata']['title'] ?? null;
151        $description = $info['rich_metadata']['description'] ?? null;
152
153        // This placeholder will appear while waiting for the amp-pinterest component to initialize (or if it fails to initialize due to JS being disabled).
154        $placeholder = sprintf(
155            // The AMP_Img_Sanitizer will convert his to <amp-img> while also supplying `noscript > img` as fallback when JS is disabled.
156            '<a href="%s" placeholder><img src="%s" alt="%s" layout="fill" object-fit="contain" object-position="top left"></a>',
157            esc_url( $attr['url'] ),
158            esc_url( $image['url'] ),
159            esc_attr( $title )
160        );
161
162        $amp_padding     = 5;   // See <https://github.com/ampproject/amphtml/blob/b5dea36e0b8bd012585d50839766a084f99a3685/extensions/amp-pinterest/0.1/amp-pinterest.css#L269>.
163        $amp_fixed_width = 237; // See <https://github.com/ampproject/amphtml/blob/b5dea36e0b8bd012585d50839766a084f99a3685/extensions/amp-pinterest/0.1/amp-pinterest.css#L270>.
164        $pin_info_height = 60;  // Minimum Obtained by measuring the height of the .-amp-pinterest-embed-pin-text element.
165
166        // Add height based on how much description there is. There are roughly 30 characters on a line of description text.
167        $has_description = false;
168        if ( ! empty( $info['description'] ) ) {
169            $desc_padding_top = 5;  // See <https://github.com/ampproject/amphtml/blob/b5dea36e0b8bd012585d50839766a084f99a3685/extensions/amp-pinterest/0.1/amp-pinterest.css#L342>.
170            $pin_info_height += $desc_padding_top;
171
172            // Trim whitespace on description if there is any left, use to calculate the likely rows of text.
173            $description = trim( $info['description'] );
174            if ( strlen( $description ) > 0 ) {
175                $has_description  = true;
176                $desc_line_height = 17; // See <https://github.com/ampproject/amphtml/blob/b5dea36e0b8bd012585d50839766a084f99a3685/extensions/amp-pinterest/0.1/amp-pinterest.css#L341>.
177                $pin_info_height += ceil( strlen( $description ) / 30 ) * $desc_line_height;
178            }
179        }
180
181        if ( ! empty( $info['repin_count'] ) ) {
182            $pin_stats_height = 16;  // See <https://github.com/ampproject/amphtml/blob/b5dea36e0b8bd012585d50839766a084f99a3685/extensions/amp-pinterest/0.1/amp-pinterest.css#L322>.
183            $pin_info_height += $pin_stats_height;
184        }
185
186        // When Pin description is empty, make sure title and description from rich metadata are supplied for accessibility and discoverability.
187        $title = $has_description ? '' : implode( "\n", array_filter( array( $title, $description ) ) );
188
189        $amp_pinterest = sprintf(
190            '<amp-pinterest style="%1$s" data-do="embedPin" data-url="%2$s" width="%3$d" height="%4$d" title="%5$s">%6$s</amp-pinterest>',
191            esc_attr( 'line-height:1.5; font-size:21px' ), // Override styles from theme due to precise height calculations above.
192            esc_url( $attr['url'] ),
193            $amp_fixed_width + ( $amp_padding * 2 ),
194            $image['height'] + $pin_info_height + ( $amp_padding * 2 ),
195            esc_attr( $title ),
196            $placeholder
197        );
198    } else {
199        // Fallback embed when info is not available.
200        $amp_pinterest = sprintf(
201            '<amp-pinterest data-do="embedPin" data-url="%1$s" width="%2$d" height="%3$d">%4$s</amp-pinterest>',
202            esc_url( $attr['url'] ),
203            450, // Fallback width.
204            750, // Fallback height.
205            sprintf(
206                '<a placeholder href="%s">%s</a>',
207                esc_url( $attr['url'] ),
208                esc_html( $attr['url'] )
209            )
210        );
211    }
212
213    return sprintf(
214        '<div class="wp-block-jetpack-pinterest">%s</div>',
215        $amp_pinterest
216    );
217}
218
219/**
220 * Pinterest block registration/dependency declaration.
221 *
222 * @param array  $attr    Array containing the Pinterest block attributes.
223 * @param string $content String containing the Pinterest block content.
224 *
225 * @return string
226 */
227function load_assets( $attr, $content ) {
228    if ( ! Request::is_frontend() ) {
229        return $content;
230    }
231    $attr['url'] = $attr['url'] ?? '';
232    if ( Blocks::is_amp_request() ) {
233        return render_amp_pin( $attr );
234    } else {
235        $url  = $attr['url'];
236        $type = pin_type( $url );
237
238        if ( ! $type ) {
239            return '';
240        }
241
242        wp_enqueue_script( 'pinterest-pinit', 'https://assets.pinterest.com/js/pinit.js', array(), JETPACK__VERSION, true );
243        return sprintf(
244            '
245            <div class="%1$s">
246                <a data-pin-do="%2$s" href="%3$s"></a>
247            </div>
248        ',
249            esc_attr( Blocks::classes( Blocks::get_block_feature( __DIR__ ), $attr ) ),
250            esc_attr( $type ),
251            esc_url( $url )
252        );
253    }
254}