Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.04% covered (success)
99.04%
309 / 312
90.91% covered (success)
90.91%
10 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
All_Playlists_Block
99.04% covered (success)
99.04%
309 / 312
90.91% covered (success)
90.91%
10 / 11
94
0.00% covered (danger)
0.00%
0 / 1
 register
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
6.17
 settings
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
7
 ordered_playlists
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
1 / 1
23
 block_style_vars
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
12
 sanitize_style_value
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
8
 requested_page
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 render
100.00% covered (success)
100.00%
57 / 57
100.00% covered (success)
100.00%
1 / 1
10
 render_heading
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 render_item
100.00% covered (success)
100.00%
68 / 68
100.00% covered (success)
100.00%
1 / 1
16
 render_numbered_pagination
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
6
 render_load_more
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * The All Playlists block: every Video Playlist on the site, as a gallery or a list.
4 *
5 * @package automattic/jetpack-videopress
6 */
7
8namespace Automattic\Jetpack\VideoPress;
9
10use WP_Post;
11
12/**
13 * Registers and renders the `videopress/all-playlists` block from the playlist
14 * index: a paginated gallery or list of playlist cards, each linking to the
15 * post its playlist lives in.
16 */
17class All_Playlists_Block {
18
19    /**
20     * Block name.
21     *
22     * @var string
23     */
24    const BLOCK_NAME = 'videopress/all-playlists';
25
26    /**
27     * Supported layouts, the first being the default.
28     *
29     * @var string[]
30     */
31    const LAYOUTS = array( 'gallery', 'list' );
32
33    /**
34     * Supported orders, the first being the default.
35     *
36     * @var string[]
37     */
38    const ORDERS = array( 'newest', 'oldest', 'title' );
39
40    /**
41     * Supported pagination styles, the first being the default.
42     *
43     * @var string[]
44     */
45    const PAGINATIONS = array( 'numbered', 'load-more' );
46
47    /**
48     * Bounds of the layout settings; the editor controls use the same range.
49     */
50    const MIN_COLUMNS      = 1;
51    const MAX_COLUMNS      = 6;
52    const DEFAULT_COLUMNS  = 3;
53    const MIN_PER_PAGE     = 1;
54    const MAX_PER_PAGE     = 48;
55    const DEFAULT_PER_PAGE = 6;
56
57    /**
58     * Query argument carrying the current page of a numbered pagination.
59     *
60     * @var string
61     */
62    const PAGE_QUERY_ARG = 'playlists-page';
63
64    /**
65     * Register the block.
66     *
67     * It lists Video Playlist blocks, so it is only registered where that block is.
68     *
69     * @param string|null $metadata_file Path to the block.json metadata file. Defaults to the
70     *                                   package build output; tests can point it at a fixture.
71     *
72     * @return void
73     */
74    public static function register( $metadata_file = null ) {
75        if ( ! \WP_Block_Type_Registry::get_instance()->is_registered( Playlist_Index::BLOCK_NAME ) ) {
76            return;
77        }
78
79        if ( null === $metadata_file ) {
80            $metadata_file = __DIR__ . '/../build/block-editor/blocks/all-playlists/block.json';
81        }
82
83        if ( ! file_exists( $metadata_file ) ) {
84            return;
85        }
86
87        $metadata = json_decode(
88            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
89            file_get_contents( $metadata_file )
90        );
91
92        if ( empty( $metadata->name )
93            || \WP_Block_Type_Registry::get_instance()->is_registered( $metadata->name )
94        ) {
95            return;
96        }
97
98        register_block_type(
99            $metadata_file,
100            array(
101                'render_callback' => array( __CLASS__, 'render' ),
102            )
103        );
104    }
105
106    /**
107     * Normalize the block attributes into the values the render uses.
108     *
109     * @param mixed $block_attributes Block attributes; anything but an array counts as none.
110     *
111     * @return array{layout: string, columns: int, per_page: int, order_by: string, show_description: bool, show_video_count: bool, pagination: string}
112     */
113    public static function settings( $block_attributes ) {
114        $block_attributes = is_array( $block_attributes ) ? $block_attributes : array();
115
116        $pick  = function ( $key, $allowed ) use ( $block_attributes ) {
117            return isset( $block_attributes[ $key ] ) && in_array( $block_attributes[ $key ], $allowed, true )
118                ? $block_attributes[ $key ]
119                : $allowed[0];
120        };
121        $clamp = function ( $key, $min, $max, $default_value ) use ( $block_attributes ) {
122            return isset( $block_attributes[ $key ] ) && is_numeric( $block_attributes[ $key ] )
123                ? max( $min, min( $max, (int) $block_attributes[ $key ] ) )
124                : $default_value;
125        };
126        $flag  = function ( $key, $default_value ) use ( $block_attributes ) {
127            return isset( $block_attributes[ $key ] ) ? (bool) $block_attributes[ $key ] : $default_value;
128        };
129
130        return array(
131            'layout'           => $pick( 'layout', self::LAYOUTS ),
132            'columns'          => $clamp( 'columns', self::MIN_COLUMNS, self::MAX_COLUMNS, self::DEFAULT_COLUMNS ),
133            'per_page'         => $clamp( 'perPage', self::MIN_PER_PAGE, self::MAX_PER_PAGE, self::DEFAULT_PER_PAGE ),
134            'order_by'         => $pick( 'orderBy', self::ORDERS ),
135            'show_description' => $flag( 'showDescription', true ),
136            'show_video_count' => $flag( 'showVideoCount', true ),
137            'pagination'       => $pick( 'pagination', self::PAGINATIONS ),
138        );
139    }
140
141    /**
142     * The indexed playlists in display order, each with its source post resolved.
143     *
144     * @param string $order_by One of ORDERS.
145     *
146     * @return array[] Records with `key`, `title`, `description`, `videos` and `post` (WP_Post|null).
147     */
148    public static function ordered_playlists( $order_by ) {
149        $playlists = array();
150        $post_ids  = array();
151        foreach ( Playlist_Index::get_playlists() as $key => $record ) {
152            if ( ! is_array( $record ) ) {
153                continue;
154            }
155            $post_id     = isset( $record['post_id'] ) ? absint( $record['post_id'] ) : 0;
156            $playlists[] = array(
157                'key'         => (string) $key,
158                'title'       => isset( $record['title'] ) && is_string( $record['title'] ) ? $record['title'] : '',
159                'description' => isset( $record['description'] ) && is_string( $record['description'] ) ? $record['description'] : '',
160                'videos'      => isset( $record['videos'] ) && is_array( $record['videos'] ) ? $record['videos'] : array(),
161                'post_id'     => $post_id,
162                'post'        => null,
163            );
164            if ( $post_id ) {
165                $post_ids[] = $post_id;
166            }
167        }
168
169        if ( $post_ids ) {
170            _prime_post_caches( array_unique( $post_ids ), false, false );
171            foreach ( $playlists as &$playlist ) {
172                $post = $playlist['post_id'] ? get_post( $playlist['post_id'] ) : null;
173                if ( $post instanceof WP_Post && 'publish' === $post->post_status ) {
174                    $playlist['post'] = $post;
175                }
176            }
177            unset( $playlist );
178        }
179
180        if ( 'title' === $order_by ) {
181            usort(
182                $playlists,
183                function ( $a, $b ) {
184                    $result = strnatcasecmp( $a['title'], $b['title'] );
185                    return 0 !== $result ? $result : strcmp( $a['key'], $b['key'] );
186                }
187            );
188        } else {
189            // Newest first by the source post's publish date; playlists without a post go last.
190            $date_of = function ( $playlist ) {
191                return $playlist['post'] ? $playlist['post']->post_date_gmt : '';
192            };
193            usort(
194                $playlists,
195                function ( $a, $b ) use ( $date_of, $order_by ) {
196                    $date_a = $date_of( $a );
197                    $date_b = $date_of( $b );
198                    if ( '' === $date_a || '' === $date_b ) {
199                        $result = strcmp( $date_b, $date_a );
200                    } else {
201                        $result = 'newest' === $order_by ? strcmp( $date_b, $date_a ) : strcmp( $date_a, $date_b );
202                    }
203                    return 0 !== $result ? $result : strcmp( $a['key'], $b['key'] );
204                }
205            );
206        }
207
208        return $playlists;
209    }
210
211    /**
212     * The typography and color settings the Styles tab stores on the block, as
213     * CSS custom properties plus a marker class per property.
214     *
215     * Block supports put these on the wrapper, but headings inside it keep the
216     * theme's heading styles instead of inheriting, so the stylesheet applies the
217     * variables to the titles and the header heading only when a value is set.
218     *
219     * @param mixed $block_attributes Block attributes.
220     *
221     * @return array{classes: string[], style: string}
222     */
223    public static function block_style_vars( $block_attributes ) {
224        $block_attributes = is_array( $block_attributes ) ? $block_attributes : array();
225        $style_attribute  = isset( $block_attributes['style'] ) && is_array( $block_attributes['style'] ) ? $block_attributes['style'] : array();
226        $typography       = isset( $style_attribute['typography'] ) && is_array( $style_attribute['typography'] ) ? $style_attribute['typography'] : array();
227        $color            = isset( $style_attribute['color'] ) && is_array( $style_attribute['color'] ) ? $style_attribute['color'] : array();
228
229        $preset = function ( $type, $slug ) {
230            return is_string( $slug ) && preg_match( '/^[a-z0-9-]+$/i', $slug )
231                ? 'var(--wp--preset--' . $type . '--' . strtolower( $slug ) . ')'
232                : null;
233        };
234
235        $values = array(
236            'font-family'     => $preset( 'font-family', $block_attributes['fontFamily'] ?? null ) ?? ( $typography['fontFamily'] ?? null ),
237            'font-size'       => $preset( 'font-size', $block_attributes['fontSize'] ?? null ) ?? ( $typography['fontSize'] ?? null ),
238            'font-style'      => $typography['fontStyle'] ?? null,
239            'font-weight'     => $typography['fontWeight'] ?? null,
240            'line-height'     => $typography['lineHeight'] ?? null,
241            'letter-spacing'  => $typography['letterSpacing'] ?? null,
242            'text-transform'  => $typography['textTransform'] ?? null,
243            'text-decoration' => $typography['textDecoration'] ?? null,
244            'color'           => $preset( 'color', $block_attributes['textColor'] ?? null ) ?? ( $color['text'] ?? null ),
245        );
246
247        $classes = array();
248        $style   = '';
249        foreach ( $values as $property => $value ) {
250            $value = self::sanitize_style_value( $value );
251            if ( null === $value ) {
252                continue;
253            }
254            $classes[] = 'has-vpap-' . $property;
255            $style    .= '--vpap-' . $property . ':' . $value . ';';
256        }
257
258        return array(
259            'classes' => $classes,
260            'style'   => $style,
261        );
262    }
263
264    /**
265     * Keep a style value to what a font or color setting can hold: presets
266     * (including the `var:preset|type|slug` notation) and plain CSS values.
267     *
268     * @param mixed $value Raw attribute value.
269     *
270     * @return string|null The value, or null when it is empty or unsafe.
271     */
272    private static function sanitize_style_value( $value ) {
273        if ( is_int( $value ) || is_float( $value ) ) {
274            $value = (string) $value;
275        }
276        if ( ! is_string( $value ) ) {
277            return null;
278        }
279
280        $value = trim( $value );
281        if ( preg_match( '/^var:preset\|([a-z0-9-]+)\|([a-z0-9-]+)$/i', $value, $matches ) ) {
282            $value = 'var(--wp--preset--' . strtolower( $matches[1] ) . '--' . strtolower( $matches[2] ) . ')';
283        }
284
285        if ( '' === $value
286            || ! preg_match( '/^[A-Za-z0-9 ,.%#\/+"\'()-]+$/', $value )
287            || preg_match( '/(url|expression|image)\s*\(/i', $value )
288        ) {
289            return null;
290        }
291
292        return $value;
293    }
294
295    /**
296     * The page a numbered pagination is showing, from the request.
297     *
298     * @return int 1-based page number.
299     */
300    private static function requested_page() {
301        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only pagination argument, like core's paged.
302        $page = isset( $_GET[ self::PAGE_QUERY_ARG ] ) ? absint( wp_unslash( $_GET[ self::PAGE_QUERY_ARG ] ) ) : 1;
303
304        return max( 1, $page );
305    }
306
307    /**
308     * Render callback.
309     *
310     * @param array  $block_attributes Block attributes.
311     * @param string $content          The rendered inner blocks: the heading the user edits in the editor.
312     *
313     * @return string Block markup, or an empty string when the site has no playlists.
314     */
315    public static function render( $block_attributes, $content = '' ) {
316        $settings  = self::settings( $block_attributes );
317        $playlists = self::ordered_playlists( $settings['order_by'] );
318        $total     = count( $playlists );
319        if ( ! $total ) {
320            return '';
321        }
322
323        $per_page     = $settings['per_page'];
324        $pages        = (int) ceil( $total / $per_page );
325        $is_load_more = 'load-more' === $settings['pagination'];
326        $current_page = $is_load_more ? 1 : min( $pages, self::requested_page() );
327
328        $items = '';
329        foreach ( $playlists as $index => $playlist ) {
330            $page = (int) floor( $index / $per_page ) + 1;
331            if ( ! $is_load_more && $page !== $current_page ) {
332                continue;
333            }
334            $items .= self::render_item( $playlist, $settings, $page, $is_load_more && $page > 1 );
335        }
336
337        if ( $is_load_more ) {
338            $shown   = min( $per_page, $total );
339            $summary = sprintf(
340                /* translators: 1: number of playlists shown. 2: number of playlists on the site. */
341                __( 'Showing %1$s of %2$s', 'jetpack-videopress-pkg' ),
342                number_format_i18n( $shown ),
343                number_format_i18n( $total )
344            );
345            $pagination = $total > $shown ? self::render_load_more( $total - $shown, $per_page ) : '';
346        } else {
347            $summary = sprintf(
348                /* translators: %s: number of playlists on the site. */
349                _n( '%s playlist', '%s playlists', $total, 'jetpack-videopress-pkg' ),
350                number_format_i18n( $total )
351            );
352            $pagination = $pages > 1 ? self::render_numbered_pagination( $current_page, $pages ) : '';
353        }
354
355        $style_vars = self::block_style_vars( $block_attributes );
356        $classes    = array_merge(
357            array(
358                'videopress-all-playlists',
359                'is-layout-' . $settings['layout'],
360                'is-pagination-' . $settings['pagination'],
361            ),
362            $style_vars['classes']
363        );
364
365        $wrapper_attributes = get_block_wrapper_attributes(
366            array(
367                'class'               => implode( ' ', $classes ),
368                'style'               => '--vpap-columns:' . $settings['columns'] . ';' . $style_vars['style'],
369                'data-playlist-total' => (string) $total,
370                'data-per-page'       => (string) $per_page,
371                /* translators: 1: number of playlists shown. 2: number of playlists on the site. */
372                'data-summary'        => __( 'Showing %1$s of %2$s', 'jetpack-videopress-pkg' ),
373            )
374        );
375
376        return sprintf(
377            '<div %1$s>' .
378                '<div class="videopress-all-playlists__header">%2$s<span class="videopress-all-playlists__summary">%3$s</span></div>' .
379                '<ul class="videopress-all-playlists__items">%4$s</ul>%5$s' .
380            '</div>',
381            $wrapper_attributes,
382            self::render_heading( $content ),
383            esc_html( $summary ),
384            $items,
385            $pagination
386        );
387    }
388
389    /**
390     * The header's heading: the core Heading block saved with the post, or a
391     * plain fallback for content saved before the heading became editable.
392     *
393     * @param string $content Rendered inner blocks.
394     *
395     * @return string Heading markup.
396     */
397    private static function render_heading( $content ) {
398        $content = is_string( $content ) ? trim( $content ) : '';
399        if ( '' !== $content ) {
400            return $content;
401        }
402
403        return sprintf(
404            '<h2 class="videopress-all-playlists__heading">%s</h2>',
405            esc_html__( 'Playlists', 'jetpack-videopress-pkg' )
406        );
407    }
408
409    /**
410     * Render one playlist card.
411     *
412     * @param array $playlist A record from ordered_playlists().
413     * @param array $settings Normalized settings.
414     * @param int   $page     The page the card belongs to.
415     * @param bool  $hidden   Whether the card starts hidden, waiting for "Load more".
416     *
417     * @return string Card markup.
418     */
419    private static function render_item( $playlist, $settings, $page, $hidden ) {
420        $title       = '' !== $playlist['title'] ? $playlist['title'] : __( 'Untitled playlist', 'jetpack-videopress-pkg' );
421        $permalink   = $playlist['post'] ? get_permalink( $playlist['post'] ) : '';
422        $video_count = count( $playlist['videos'] );
423        $first_guid  = $video_count && isset( $playlist['videos'][0]['guid'] ) && is_string( $playlist['videos'][0]['guid'] )
424            ? $playlist['videos'][0]['guid']
425            : '';
426
427        $item_classes   = array( 'videopress-all-playlists__item' );
428        $item_classes[] = $first_guid ? 'is-poster-loading' : 'is-poster-missing';
429
430        $badge = '';
431        if ( $settings['show_video_count'] ) {
432            $badge = sprintf(
433                '<span class="videopress-all-playlists__badge"><span class="videopress-all-playlists__badge-icon" aria-hidden="true">≡</span>%s</span>',
434                esc_html(
435                    sprintf(
436                        /* translators: %s: number of videos in the playlist. */
437                        _n( '%s video', '%s videos', $video_count, 'jetpack-videopress-pkg' ),
438                        number_format_i18n( $video_count )
439                    )
440                )
441            );
442        }
443
444        $poster = sprintf(
445            '<span class="videopress-all-playlists__deck" aria-hidden="true"></span>' .
446            '<span class="videopress-all-playlists__poster-frame">' .
447                '<img class="videopress-all-playlists__poster-image" alt="" loading="lazy" hidden />' .
448                '<span class="videopress-all-playlists__poster-missing">%1$s</span>%2$s' .
449            '</span>',
450            esc_html__( 'No poster available', 'jetpack-videopress-pkg' ),
451            $badge
452        );
453        $poster = $permalink
454            ? sprintf(
455                '<a class="videopress-all-playlists__poster" href="%1$s" aria-label="%2$s"%3$s>%4$s</a>',
456                esc_url( $permalink ),
457                esc_attr( $title ),
458                $first_guid ? ' data-guid="' . esc_attr( $first_guid ) . '"' : '',
459                $poster
460            )
461            : sprintf(
462                '<span class="videopress-all-playlists__poster"%1$s>%2$s</span>',
463                $first_guid ? ' data-guid="' . esc_attr( $first_guid ) . '"' : '',
464                $poster
465            );
466
467        $body = sprintf(
468            '<h3 class="videopress-all-playlists__title">%s</h3>',
469            $permalink
470                ? sprintf( '<a href="%1$s">%2$s</a>', esc_url( $permalink ), esc_html( $title ) )
471                : esc_html( $title )
472        );
473        if ( $settings['show_description'] && '' !== $playlist['description'] ) {
474            $body .= sprintf( '<p class="videopress-all-playlists__description">%s</p>', esc_html( $playlist['description'] ) );
475        }
476        $body .= sprintf(
477            '<span class="videopress-all-playlists__poster-note">%s</span>',
478            esc_html__( 'First video is private or was deleted.', 'jetpack-videopress-pkg' )
479        );
480        if ( $permalink ) {
481            $body .= sprintf(
482                '<a class="videopress-all-playlists__link" href="%1$s">%2$s</a>',
483                esc_url( $permalink ),
484                esc_html__( 'View full playlist →', 'jetpack-videopress-pkg' )
485            );
486        }
487
488        return sprintf(
489            '<li class="%1$s" data-playlist="%2$s" data-page="%3$d"%4$s>%5$s<div class="videopress-all-playlists__body">%6$s</div></li>',
490            esc_attr( implode( ' ', $item_classes ) ),
491            esc_attr( $playlist['key'] ),
492            $page,
493            $hidden ? ' hidden' : '',
494            $poster,
495            $body
496        );
497    }
498
499    /**
500     * Render the numbered pagination.
501     *
502     * @param int $current Current page.
503     * @param int $pages   Number of pages.
504     *
505     * @return string Pagination markup.
506     */
507    private static function render_numbered_pagination( $current, $pages ) {
508        $url_for = function ( $page ) {
509            return 1 === $page
510                ? remove_query_arg( self::PAGE_QUERY_ARG )
511                : add_query_arg( self::PAGE_QUERY_ARG, $page );
512        };
513
514        $links = $current > 1
515            ? sprintf(
516                '<a class="videopress-all-playlists__page videopress-all-playlists__page--prev" href="%1$s">%2$s</a>',
517                esc_url( $url_for( $current - 1 ) ),
518                esc_html__( '← Previous', 'jetpack-videopress-pkg' )
519            )
520            : sprintf(
521                '<span class="videopress-all-playlists__page videopress-all-playlists__page--prev is-disabled" aria-disabled="true">%s</span>',
522                esc_html__( '← Previous', 'jetpack-videopress-pkg' )
523            );
524
525        for ( $page = 1; $page <= $pages; $page++ ) {
526            $links .= $page === $current
527                ? sprintf(
528                    '<span class="videopress-all-playlists__page videopress-all-playlists__page--number is-current" aria-current="page">%d</span>',
529                    $page
530                )
531                : sprintf(
532                    '<a class="videopress-all-playlists__page videopress-all-playlists__page--number" href="%1$s">%2$d</a>',
533                    esc_url( $url_for( $page ) ),
534                    $page
535                );
536        }
537
538        $links .= $current < $pages
539            ? sprintf(
540                '<a class="videopress-all-playlists__page videopress-all-playlists__page--next" href="%1$s">%2$s</a>',
541                esc_url( $url_for( $current + 1 ) ),
542                esc_html__( 'Next →', 'jetpack-videopress-pkg' )
543            )
544            : sprintf(
545                '<span class="videopress-all-playlists__page videopress-all-playlists__page--next is-disabled" aria-disabled="true">%s</span>',
546                esc_html__( 'Next →', 'jetpack-videopress-pkg' )
547            );
548
549        return sprintf(
550            '<nav class="videopress-all-playlists__pagination" aria-label="%1$s">%2$s</nav>',
551            esc_attr__( 'Playlists pagination', 'jetpack-videopress-pkg' ),
552            $links
553        );
554    }
555
556    /**
557     * Render the "Load more" button.
558     *
559     * @param int $remaining Playlists not shown yet.
560     * @param int $per_page  Playlists revealed per click.
561     *
562     * @return string Button markup.
563     */
564    private static function render_load_more( $remaining, $per_page ) {
565        /* translators: %s: number of playlists the button reveals. */
566        $label = __( 'Load %s more', 'jetpack-videopress-pkg' );
567
568        return sprintf(
569            '<div class="videopress-all-playlists__load-more"><button type="button" class="videopress-all-playlists__load-more-button" data-label="%1$s">%2$s</button></div>',
570            esc_attr( $label ),
571            esc_html( sprintf( $label, number_format_i18n( min( $remaining, $per_page ) ) ) )
572        );
573    }
574}