Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.77% covered (warning)
75.77%
172 / 227
23.08% covered (danger)
23.08%
3 / 13
CRAP
n/a
0 / 0
Automattic\Jetpack\Extensions\Story\is_same_resource
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
Automattic\Jetpack\Extensions\Story\enrich_media_files
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
8.30
Automattic\Jetpack\Extensions\Story\enrich_image_meta
17.39% covered (danger)
17.39%
4 / 23
0.00% covered (danger)
0.00%
0 / 1
19.09
Automattic\Jetpack\Extensions\Story\enrich_video_meta
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
10.05
Automattic\Jetpack\Extensions\Story\render_image
59.38% covered (warning)
59.38%
19 / 32
0.00% covered (danger)
0.00%
0 / 1
14.43
Automattic\Jetpack\Extensions\Story\get_image_crop_class
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
6.01
Automattic\Jetpack\Extensions\Story\get_blavatar_or_site_icon_url
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
Automattic\Jetpack\Extensions\Story\render_video
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
5
Automattic\Jetpack\Extensions\Story\render_static_slide
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
7
Automattic\Jetpack\Extensions\Story\render_top_right_icon
58.33% covered (warning)
58.33%
7 / 12
0.00% covered (danger)
0.00%
0 / 1
3.65
Automattic\Jetpack\Extensions\Story\render_pagination_bullet
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
Automattic\Jetpack\Extensions\Story\render_pagination
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
4.01
Automattic\Jetpack\Extensions\Story\render
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * Story block render implementation.
4 *
5 * Loaded lazily from story.php only when the block is rendered, to keep the
6 * render body out of the eager front-end PHP/opcache footprint.
7 *
8 * @package automattic/jetpack
9 */
10
11namespace Automattic\Jetpack\Extensions\Story;
12
13use Automattic\Jetpack\Blocks;
14use Automattic\Jetpack\Connection\Connection_Assets;
15use Automattic\Jetpack\Post_Media\Images;
16use Jetpack;
17use Jetpack_Gutenberg;
18
19if ( ! defined( 'ABSPATH' ) ) {
20    exit( 0 );
21}
22
23const EMBED_SIZE        = array( 360, 640 ); // twice as many pixels for retina displays.
24const CROP_UP_TO        = 0.2;
25const MAX_BULLETS       = 7;
26const IMAGE_BREAKPOINTS = '(max-width: 460px) 576w, (max-width: 614px) 768w, 120vw'; // 120vw to match the 20% CROP_UP_TO ratio
27
28/**
29 * Compare 2 urls and return true if they likely correspond to the same resource.
30 * Ignore scheme, ports, query params and hashes and only compare hostname and pathname.
31 *
32 * @param string $url1  - First url used in comparison.
33 * @param string $url2  - Second url used in comparison.
34 *
35 * @return boolean
36 */
37function is_same_resource( $url1, $url2 ) {
38    $url1_parsed = wp_parse_url( $url1 );
39    $url2_parsed = wp_parse_url( $url2 );
40    return isset( $url1_parsed['host'] ) &&
41        isset( $url2_parsed['host'] ) &&
42        isset( $url1_parsed['path'] ) &&
43        isset( $url2_parsed['path'] ) &&
44        $url1_parsed['host'] === $url2_parsed['host'] &&
45        $url1_parsed['path'] === $url2_parsed['path'];
46}
47
48/**
49 * Enrich media files retrieved from the story block attributes
50 * with extra information we can retrieve from the media library.
51 *
52 * @param array $media_files  - List of media, each as an array containing the media attributes.
53 *
54 * @return array $media_files
55 */
56function enrich_media_files( $media_files ) {
57    return array_filter(
58        array_map(
59            function ( $media_file ) {
60                if ( ! is_array( $media_file ) || ! isset( $media_file['type'] ) ) {
61                    return null;
62                }
63                if ( 'image' === $media_file['type'] ) {
64                    return enrich_image_meta( $media_file );
65                }
66
67                /*
68                 * VideoPress videos can sometimes have type 'file', and mime 'video/videopress' or 'video/mp4'.
69                 * Let's fix `type` for those.
70                 */
71                if (
72                    'file' === $media_file['type']
73                    && isset( $media_file['mime'] )
74                    && str_starts_with( $media_file['mime'], 'video' )
75                ) {
76                    $media_file['type'] = 'video';
77                }
78                if ( 'video' !== $media_file['type'] ) { // we only support images and videos at this point.
79                    return null;
80                }
81                return enrich_video_meta( $media_file );
82            },
83            $media_files
84        )
85    );
86}
87
88/**
89 * Enrich image information with extra data we can retrieve from the media library.
90 * Add missing `width`, `height`, `srcset`, `sizes`, `title`, `alt` and `caption` properties to the image.
91 *
92 * @param array $media_file  - An array containing the media attributes for a specific image.
93 *
94 * @return array $media_file_enriched
95 */
96function enrich_image_meta( $media_file ) {
97    $attachment_id = $media_file['id'] ?? null;
98    $image         = wp_get_attachment_image_src( $attachment_id, 'full', false );
99    if ( ! $image ) {
100        return $media_file;
101    }
102    list( $src, $width, $height ) = $image;
103    // Bail if url stored in block attributes is different than the media library one for that id.
104    if ( isset( $media_file['url'] ) && ! is_same_resource( $media_file['url'], $src ) ) {
105        return $media_file;
106    }
107    $image_meta = wp_get_attachment_metadata( $attachment_id );
108    if ( ! is_array( $image_meta ) ) {
109        return $media_file;
110    }
111    $size_array = array( absint( $width ), absint( $height ) );
112    return array_merge(
113        $media_file,
114        array(
115            'width'   => absint( $width ),
116            'height'  => absint( $height ),
117            'srcset'  => wp_calculate_image_srcset( $size_array, $src, $image_meta, $attachment_id ),
118            'sizes'   => IMAGE_BREAKPOINTS,
119            'title'   => get_the_title( $attachment_id ),
120            'alt'     => get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ),
121            'caption' => wp_get_attachment_caption( $attachment_id ),
122        )
123    );
124}
125
126/**
127 * Enrich video information with extra data we can retrieve from the media library.
128 * Add missing `width`, `height`, `alt`, `url`, `title`, `caption` and `poster` properties to the image.
129 *
130 * @param array $media_file  - An array containing the media attributes for a specific video.
131 *
132 * @return array $media_file_enriched
133 */
134function enrich_video_meta( $media_file ) {
135    $attachment_id = $media_file['id'] ?? null;
136    $video_meta    = wp_get_attachment_metadata( $attachment_id );
137    if ( ! $video_meta ) {
138        return $media_file;
139    }
140
141    $video_url = ! empty( $video_meta['original']['url'] ) ? $video_meta['original']['url'] : wp_get_attachment_url( $attachment_id );
142
143    // Set the poster attribute for the video tag if a poster image is available.
144    $poster_url = null;
145    if ( ! empty( $video_meta['videopress']['poster'] ) ) {
146        $poster_url = $video_meta['videopress']['poster'];
147    } elseif ( ! empty( $video_meta['thumb'] ) ) {
148        $poster_url = str_replace( wp_basename( $video_url ), $video_meta['thumb'], $video_url );
149    }
150
151    if ( $poster_url ) {
152        /*
153         * Use the global content width for thumbnail resize so we match the `w=` query parameter
154         * that jetpack is going to add when "Enable site accelerator" is enabled for images.
155         */
156        $content_width = (int) Jetpack::get_content_width();
157        $new_width     = $content_width > 0 ? $content_width : EMBED_SIZE[0];
158        $poster_url    = add_query_arg( 'w', $new_width, $poster_url );
159    }
160
161    return array_merge(
162        $media_file,
163        array(
164            'width'   => absint( ! empty( $video_meta['width'] ) ? $video_meta['width'] : ( $media_file['width'] ?? 0 ) ),
165            'height'  => absint( ! empty( $video_meta['height'] ) ? $video_meta['height'] : ( $media_file['height'] ?? 0 ) ),
166            'alt'     => ! empty( $video_meta['videopress']['description'] ) ? $video_meta['videopress']['description'] : ( $media_file['alt'] ?? '' ),
167            'url'     => $video_url,
168            'title'   => get_the_title( $attachment_id ),
169            'caption' => wp_get_attachment_caption( $attachment_id ),
170            'poster'  => $poster_url,
171        )
172    );
173}
174
175/**
176 * Render an image inside a slide
177 *
178 * @param array $media  - Image information.
179 *
180 * @return string
181 */
182function render_image( $media ) {
183    $src    = '';
184    $width  = null;
185    $height = null;
186
187    if ( empty( $media['id'] ) || empty( $media['url'] ) ) {
188        return __( 'Error retrieving media', 'jetpack' );
189    }
190    $image = wp_get_attachment_image_src( $media['id'], 'full', false );
191    if ( $image ) {
192        list( $src, $width, $height ) = $image;
193    }
194
195    // if image does not match.
196    if ( ! $image || isset( $media['url'] ) && ! is_same_resource( $media['url'], $src ?? '' ) ) {
197        $width  = $media['width'] ?? null;
198        $height = $media['height'] ?? null;
199        $title  = $media['title'] ?? '';
200        $alt    = $media['alt'] ?? '';
201        return sprintf(
202            '<img
203                title="%1$s"
204                alt="%2$s"
205                class="wp-block-jetpack-story_image wp-story-image %3$s"
206                src="%4$s"
207            />',
208            esc_attr( $title ),
209            esc_attr( $alt ),
210            $width && $height ? get_image_crop_class( $width, $height ) : '',
211            esc_attr( $media['url'] )
212        );
213    }
214
215    $crop_class = get_image_crop_class( $width, $height );
216
217    /*
218     * need to specify the size of the embed so it picks an image that is large enough for the `src` attribute
219     * `sizes` is optimized for 1080x1920 (9:16) images
220     * Note that the Story block does not have thumbnail support, it will load the right
221     * image based on the viewport size only.
222     */
223    return wp_get_attachment_image(
224        $media['id'],
225        EMBED_SIZE,
226        false,
227        array(
228            'class' => sprintf( 'wp-story-image wp-image-%d %s', $media['id'], $crop_class ),
229            'sizes' => IMAGE_BREAKPOINTS,
230            'title' => get_the_title( $media['id'] ),
231        )
232    );
233}
234
235/**
236 * Return the css crop class if image width and height requires it
237 *
238 * @param int $width   - Image width.
239 * @param int $height  - Image height.
240 *
241 * @return string The CSS class which will display a cropped image
242 */
243function get_image_crop_class( $width, $height ) {
244    $crop_class = '';
245    $width      = (int) $width;
246    $height     = (int) $height;
247    if ( ! $width || ! $height ) {
248        return $crop_class;
249    }
250    $media_aspect_ratio  = $width / $height;
251    $target_aspect_ratio = EMBED_SIZE[0] / EMBED_SIZE[1];
252    if ( $media_aspect_ratio >= $target_aspect_ratio ) {
253        // image wider than canvas.
254        $media_too_wide_to_crop = $media_aspect_ratio > $target_aspect_ratio / ( 1 - CROP_UP_TO );
255        if ( ! $media_too_wide_to_crop ) {
256            $crop_class = 'wp-story-crop-wide';
257        }
258    } else {
259        // image narrower than canvas.
260        $media_too_narrow_to_crop = $media_aspect_ratio < $target_aspect_ratio * ( 1 - CROP_UP_TO );
261        if ( ! $media_too_narrow_to_crop ) {
262            $crop_class = 'wp-story-crop-narrow';
263        }
264    }
265    return $crop_class;
266}
267
268/**
269 * Returns a URL for the site icon.
270 *
271 * @param int    $size - Size for (square) sitei icon.
272 * @param string $fallback - Fallback URL to use if no site icon is found.
273 *
274 * @return string
275 */
276function get_blavatar_or_site_icon_url( $size, $fallback ) {
277    $image_array = Images::from_blavatar( get_the_ID(), $size );
278    if ( ! empty( $image_array ) ) {
279        return $image_array[0]['src'];
280    } else {
281        return $fallback;
282    }
283}
284
285/**
286 * Render a video inside a slide
287 *
288 * @param array $media  - Video information.
289 *
290 * @return string
291 */
292function render_video( $media ) {
293    if ( empty( $media['id'] ) || empty( $media['mime'] ) || empty( $media['url'] ) ) {
294        return __( 'Error retrieving media', 'jetpack' );
295    }
296
297    if ( ! empty( $media['poster'] ) ) {
298        return render_image(
299            array_merge(
300                $media,
301                array(
302                    'type' => 'image',
303                    'url'  => $media['poster'],
304                )
305            )
306        );
307    }
308
309    return sprintf(
310        '<video
311            title="%1$s"
312            type="%2$s"
313            class="wp-story-video intrinsic-ignore wp-video-%3$s"
314            data-id="%3$d"
315            src="%4$s">
316        </video>',
317        esc_attr( get_the_title( $media['id'] ) ),
318        esc_attr( $media['mime'] ),
319        absint( $media['id'] ),
320        esc_attr( $media['url'] )
321    );
322}
323
324/**
325 * Pick a thumbnail to render a static/embedded story
326 *
327 * @param array $media_files  - list of Media files.
328 *
329 * @return string
330 */
331function render_static_slide( $media_files ) {
332    $media_template = '';
333    if ( empty( $media_files ) ) {
334        return '';
335    }
336
337    // find an image to showcase.
338    foreach ( $media_files as $media ) {
339        switch ( $media['type'] ) {
340            case 'image':
341                $media_template = render_image( $media );
342                break 2;
343            case 'video':
344                // ignore videos without a poster image.
345                if ( empty( $media['poster'] ) ) {
346                    continue 2;
347                }
348                $media_template = render_video( $media );
349                break 2;
350        }
351    }
352
353    // if no "static" media was found for the thumbnail try to render a video tag without poster.
354    if ( empty( $media_template ) ) {
355        // enrich_media_files() may return an array with no zero-index, so use reset()
356        $media_template = render_video( reset( $media_files ) );
357    }
358
359    return sprintf(
360        '<div class="wp-story-slide" style="display: block;">
361            <figure>%s</figure>
362        </div>',
363        $media_template
364    );
365}
366
367/**
368 * Render the top right icon on top of the story embed
369 *
370 * @param array $settings  - The block settings.
371 *
372 * @return string
373 */
374function render_top_right_icon( $settings ) {
375    $show_slide_count = $settings['showSlideCount'] ?? false;
376    $slide_count      = isset( $settings['slides'] ) ? count( $settings['slides'] ) : 0;
377    if ( $show_slide_count ) {
378        // Render the story block icon along with the slide count.
379        return sprintf(
380            '<div class="wp-story-embed-icon">
381                <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" role="img" aria-hidden="true" focusable="false">
382                    <path d="M0 0h24v24H0z" fill="none"></path>
383                    <path fill-rule="evenodd" clip-rule="evenodd" d="M6 3H14V17H6L6 3ZM4 3C4 1.89543 4.89543 1 6 1H14C15.1046 1 16 1.89543 16 3V17C16 18.1046 15.1046 19 14 19H6C4.89543 19 4 18.1046 4 17V3ZM18 5C19.1046 5 20 5.89543 20 7V21C20 22.1046 19.1046 23 18 23H10C8.89543 23 8 22.1046 8 21H18V5Z"></path>
384                </svg>
385                <span>%d</span>
386            </div>',
387            $slide_count
388        );
389    } else {
390        // Render the Fullscreen Gridicon.
391        return (
392            '<div class="wp-story-embed-icon-expand">
393                <svg class="gridicon gridicons-fullscreen" role="img" height="24" width="24" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
394                    <g>
395                        <path d="M21 3v6h-2V6.41l-3.29 3.3-1.42-1.42L17.59 5H15V3zM3 3v6h2V6.41l3.29 3.3 1.42-1.42L6.41 5H9V3zm18 18v-6h-2v2.59l-3.29-3.29-1.41 1.41L17.59 19H15v2zM9 21v-2H6.41l3.29-3.29-1.41-1.42L5 17.59V15H3v6z"></path>
396                    </g>
397                </svg>
398            </div>'
399        );
400    }
401}
402
403/**
404 * Render a pagination bullet
405 *
406 * @param int    $slide_index  - The slide index it corresponds to.
407 * @param string $class_name   - Optional css class name(s) to customize the bullet element.
408 *
409 * @return string
410 */
411function render_pagination_bullet( $slide_index, $class_name = '' ) {
412    return sprintf(
413        '<div class="wp-story-pagination-bullet %s" aria-label="%s">
414            <div class="wp-story-pagination-bullet-bar"></div>
415        </div>',
416        esc_attr( $class_name ),
417        /* translators: %d is the slide number (1, 2, 3...) */
418        sprintf( __( 'Go to slide %d', 'jetpack' ), $slide_index )
419    );
420}
421
422/**
423 * Render pagination on top of the story embed
424 *
425 * @param array $settings  - The block settings.
426 *
427 * @return string
428 */
429function render_pagination( $settings ) {
430    $show_slide_count = $settings['showSlideCount'] ?? false;
431    if ( $show_slide_count ) {
432        return '';
433    }
434    $slide_count     = isset( $settings['slides'] ) ? count( $settings['slides'] ) : 0;
435    $bullet_count    = min( $slide_count, MAX_BULLETS );
436    $bullet_ellipsis = $slide_count > $bullet_count
437        ? render_pagination_bullet( $bullet_count + 1, 'wp-story-pagination-ellipsis' )
438        : '';
439    return sprintf(
440        '<div class="wp-story-pagination wp-story-pagination-bullets">
441            %s
442        </div>',
443        implode( "\n", array_map( __NAMESPACE__ . '\render_pagination_bullet', range( 1, $bullet_count ) ) ) . $bullet_ellipsis
444    );
445}
446
447/**
448 * Render story block
449 *
450 * @param array $attributes  - Block attributes.
451 *
452 * @return string
453 */
454function render( $attributes ) {
455    // Let's use a counter to have a different id for each story rendered in the same context.
456    static $story_block_counter = 0;
457
458    if ( 0 === $story_block_counter ) {
459        // @todo Fix the webpack tree shaking so the block's view.js no longer depends on jetpack-connection, then remove this.
460        Connection_Assets::register_assets();
461    }
462
463    Jetpack_Gutenberg::load_assets_as_required( __DIR__ );
464
465    $media_files              = isset( $attributes['mediaFiles'] ) ? enrich_media_files( $attributes['mediaFiles'] ) : array();
466    $settings_from_attributes = $attributes['settings'] ?? array();
467
468    $settings = array_merge(
469        $settings_from_attributes,
470        array(
471            'slides' => $media_files,
472        )
473    );
474
475    /* translators: Placehodlder if the Story block can't find a post title to use. */
476    $story_title = in_the_loop() ? get_the_title() : __( 'Story', 'jetpack' );
477
478    return sprintf(
479        '<div class="%1$s" data-id="%2$s" data-settings="%3$s">
480            <div class="wp-story-app">
481                <div class="wp-story-display-contents" style="display: contents;">
482                    <a class="wp-story-container" href="%4$s" title="%5$s">
483                        <div class="wp-story-meta">
484                            <div class="wp-story-icon">
485                                <img alt="%6$s" src="%7$s" width="40" height="40">
486                            </div>
487                            <div>
488                                <div class="wp-story-title">
489                                    %8$s
490                                </div>
491                            </div>
492                        </div>
493                        <div class="wp-story-wrapper">
494                            %9$s
495                        </div>
496                        <div class="wp-story-overlay">
497                            %10$s
498                        </div>
499                        %11$s
500                    </a>
501                </div>
502            </div>
503        </div>',
504        esc_attr( Blocks::classes( Blocks::get_block_feature( __DIR__ ), $attributes, array( 'wp-story', 'aligncenter' ) ) ),
505        esc_attr( 'wp-story-' . get_the_ID() . '-' . strval( ++$story_block_counter ) ),
506        filter_var( wp_json_encode( $settings, JSON_UNESCAPED_SLASHES ), FILTER_SANITIZE_SPECIAL_CHARS ),
507        get_permalink() . '?wp-story-load-in-fullscreen=true&amp;wp-story-play-on-load=true',
508        __( 'Play story in new tab', 'jetpack' ),
509        __( 'Site icon', 'jetpack' ),
510        esc_attr( get_blavatar_or_site_icon_url( 80, includes_url( 'images/w-logo-blue.png' ) ) ),
511        esc_html( $story_title ),
512        render_static_slide( $media_files ),
513        render_top_right_icon( $settings ),
514        render_pagination( $settings )
515    );
516}