Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.13% covered (success)
99.13%
342 / 345
92.31% covered (success)
92.31%
12 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
All_Playlists_Block
99.13% covered (success)
99.13%
342 / 345
92.31% covered (success)
92.31%
12 / 13
106
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%
25 / 25
100.00% covered (success)
100.00%
1 / 1
7
 ordered_playlists
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
1 / 1
24
 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%
69 / 69
100.00% covered (success)
100.00%
1 / 1
12
 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%
72 / 72
100.00% covered (success)
100.00%
1 / 1
18
 playlist_link
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 updated_meta
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 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', 'updated' );
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, exclude_current: bool, show_updated: bool}
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            'exclude_current'  => $flag( 'excludeCurrent', false ),
139            'show_updated'     => $flag( 'showUpdated', false ),
140        );
141    }
142
143    /**
144     * The indexed playlists in display order, each with its source post resolved.
145     *
146     * @param string $order_by One of ORDERS.
147     *
148     * @return array[] Records with `key`, `title`, `description`, `videos` and `post` (WP_Post|null).
149     */
150    public static function ordered_playlists( $order_by ) {
151        $playlists = array();
152        $post_ids  = array();
153        foreach ( Playlist_Index::get_playlists() as $key => $record ) {
154            if ( ! is_array( $record ) ) {
155                continue;
156            }
157            $post_id     = isset( $record['post_id'] ) ? absint( $record['post_id'] ) : 0;
158            $playlists[] = array(
159                'key'         => (string) $key,
160                'title'       => isset( $record['title'] ) && is_string( $record['title'] ) ? $record['title'] : '',
161                'description' => isset( $record['description'] ) && is_string( $record['description'] ) ? $record['description'] : '',
162                'videos'      => isset( $record['videos'] ) && is_array( $record['videos'] ) ? $record['videos'] : array(),
163                'post_id'     => $post_id,
164                'post'        => null,
165            );
166            if ( $post_id ) {
167                $post_ids[] = $post_id;
168            }
169        }
170
171        if ( $post_ids ) {
172            _prime_post_caches( array_unique( $post_ids ), false, false );
173            foreach ( $playlists as &$playlist ) {
174                $post = $playlist['post_id'] ? get_post( $playlist['post_id'] ) : null;
175                if ( $post instanceof WP_Post && 'publish' === $post->post_status ) {
176                    $playlist['post'] = $post;
177                }
178            }
179            unset( $playlist );
180        }
181
182        if ( 'title' === $order_by ) {
183            usort(
184                $playlists,
185                function ( $a, $b ) {
186                    $result = strnatcasecmp( $a['title'], $b['title'] );
187                    return 0 !== $result ? $result : strcmp( $a['key'], $b['key'] );
188                }
189            );
190        } else {
191            // By the source post's publish ( or, for "updated", modified ) date; playlists without a post go last.
192            $date_of = function ( $playlist ) use ( $order_by ) {
193                if ( ! $playlist['post'] ) {
194                    return '';
195                }
196                return 'updated' === $order_by ? $playlist['post']->post_modified_gmt : $playlist['post']->post_date_gmt;
197            };
198            usort(
199                $playlists,
200                function ( $a, $b ) use ( $date_of, $order_by ) {
201                    $date_a = $date_of( $a );
202                    $date_b = $date_of( $b );
203                    if ( '' === $date_a || '' === $date_b ) {
204                        $result = strcmp( $date_b, $date_a );
205                    } else {
206                        $result = 'oldest' === $order_by ? strcmp( $date_a, $date_b ) : strcmp( $date_b, $date_a );
207                    }
208                    return 0 !== $result ? $result : strcmp( $a['key'], $b['key'] );
209                }
210            );
211        }
212
213        return $playlists;
214    }
215
216    /**
217     * The typography and color settings the Styles tab stores on the block, as
218     * CSS custom properties plus a marker class per property.
219     *
220     * Block supports put these on the wrapper, but headings inside it keep the
221     * theme's heading styles instead of inheriting, so the stylesheet applies the
222     * variables to the titles and the header heading only when a value is set.
223     *
224     * @param mixed $block_attributes Block attributes.
225     *
226     * @return array{classes: string[], style: string}
227     */
228    public static function block_style_vars( $block_attributes ) {
229        $block_attributes = is_array( $block_attributes ) ? $block_attributes : array();
230        $style_attribute  = isset( $block_attributes['style'] ) && is_array( $block_attributes['style'] ) ? $block_attributes['style'] : array();
231        $typography       = isset( $style_attribute['typography'] ) && is_array( $style_attribute['typography'] ) ? $style_attribute['typography'] : array();
232        $color            = isset( $style_attribute['color'] ) && is_array( $style_attribute['color'] ) ? $style_attribute['color'] : array();
233
234        $preset = function ( $type, $slug ) {
235            return is_string( $slug ) && preg_match( '/^[a-z0-9-]+$/i', $slug )
236                ? 'var(--wp--preset--' . $type . '--' . strtolower( $slug ) . ')'
237                : null;
238        };
239
240        $values = array(
241            'font-family'     => $preset( 'font-family', $block_attributes['fontFamily'] ?? null ) ?? ( $typography['fontFamily'] ?? null ),
242            'font-size'       => $preset( 'font-size', $block_attributes['fontSize'] ?? null ) ?? ( $typography['fontSize'] ?? null ),
243            'font-style'      => $typography['fontStyle'] ?? null,
244            'font-weight'     => $typography['fontWeight'] ?? null,
245            'line-height'     => $typography['lineHeight'] ?? null,
246            'letter-spacing'  => $typography['letterSpacing'] ?? null,
247            'text-transform'  => $typography['textTransform'] ?? null,
248            'text-decoration' => $typography['textDecoration'] ?? null,
249            'color'           => $preset( 'color', $block_attributes['textColor'] ?? null ) ?? ( $color['text'] ?? null ),
250        );
251
252        $classes = array();
253        $style   = '';
254        foreach ( $values as $property => $value ) {
255            $value = self::sanitize_style_value( $value );
256            if ( null === $value ) {
257                continue;
258            }
259            $classes[] = 'has-vpap-' . $property;
260            $style    .= '--vpap-' . $property . ':' . $value . ';';
261        }
262
263        return array(
264            'classes' => $classes,
265            'style'   => $style,
266        );
267    }
268
269    /**
270     * Keep a style value to what a font or color setting can hold: presets
271     * (including the `var:preset|type|slug` notation) and plain CSS values.
272     *
273     * @param mixed $value Raw attribute value.
274     *
275     * @return string|null The value, or null when it is empty or unsafe.
276     */
277    private static function sanitize_style_value( $value ) {
278        if ( is_int( $value ) || is_float( $value ) ) {
279            $value = (string) $value;
280        }
281        if ( ! is_string( $value ) ) {
282            return null;
283        }
284
285        $value = trim( $value );
286        if ( preg_match( '/^var:preset\|([a-z0-9-]+)\|([a-z0-9-]+)$/i', $value, $matches ) ) {
287            $value = 'var(--wp--preset--' . strtolower( $matches[1] ) . '--' . strtolower( $matches[2] ) . ')';
288        }
289
290        if ( '' === $value
291            || ! preg_match( '/^[A-Za-z0-9 ,.%#\/+"\'()-]+$/', $value )
292            || preg_match( '/(url|expression|image)\s*\(/i', $value )
293        ) {
294            return null;
295        }
296
297        return $value;
298    }
299
300    /**
301     * The page a numbered pagination is showing, from the request.
302     *
303     * @return int 1-based page number.
304     */
305    private static function requested_page() {
306        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only pagination argument, like core's paged.
307        $page = isset( $_GET[ self::PAGE_QUERY_ARG ] ) ? absint( wp_unslash( $_GET[ self::PAGE_QUERY_ARG ] ) ) : 1;
308
309        return max( 1, $page );
310    }
311
312    /**
313     * Render callback.
314     *
315     * @param array  $block_attributes Block attributes.
316     * @param string $content          The rendered inner blocks: the heading the user edits in the editor.
317     *
318     * @return string Block markup, or an empty string when the site has no playlists.
319     */
320    public static function render( $block_attributes, $content = '' ) {
321        $settings  = self::settings( $block_attributes );
322        $playlists = self::ordered_playlists( $settings['order_by'] );
323        if ( $settings['exclude_current'] ) {
324            $current   = Channel::queried_playlist();
325            $playlists = $current
326                ? array_values(
327                    array_filter(
328                        $playlists,
329                        function ( $playlist ) use ( $current ) {
330                            return $playlist['key'] !== $current['key'];
331                        }
332                    )
333                )
334                : $playlists;
335        }
336        $total = count( $playlists );
337        if ( ! $total ) {
338            return '';
339        }
340
341        $per_page     = $settings['per_page'];
342        $pages        = (int) ceil( $total / $per_page );
343        $is_load_more = 'load-more' === $settings['pagination'];
344        $current_page = $is_load_more ? 1 : min( $pages, self::requested_page() );
345
346        $items = '';
347        foreach ( $playlists as $index => $playlist ) {
348            $page = (int) floor( $index / $per_page ) + 1;
349            if ( ! $is_load_more && $page !== $current_page ) {
350                continue;
351            }
352            $items .= self::render_item( $playlist, $settings, $page, $is_load_more && $page > 1 );
353        }
354
355        if ( $is_load_more ) {
356            $shown   = min( $per_page, $total );
357            $summary = sprintf(
358                /* translators: 1: number of playlists shown. 2: number of playlists on the site. */
359                __( 'Showing %1$s of %2$s', 'jetpack-videopress-pkg' ),
360                number_format_i18n( $shown ),
361                number_format_i18n( $total )
362            );
363            $pagination = $total > $shown ? self::render_load_more( $total - $shown, $per_page ) : '';
364        } else {
365            $summary = sprintf(
366                /* translators: %s: number of playlists on the site. */
367                _n( '%s playlist', '%s playlists', $total, 'jetpack-videopress-pkg' ),
368                number_format_i18n( $total )
369            );
370            $pagination = $pages > 1 ? self::render_numbered_pagination( $current_page, $pages ) : '';
371        }
372
373        $style_vars = self::block_style_vars( $block_attributes );
374        $classes    = array_merge(
375            array(
376                'videopress-all-playlists',
377                'is-layout-' . $settings['layout'],
378                'is-pagination-' . $settings['pagination'],
379            ),
380            $style_vars['classes']
381        );
382
383        $wrapper_attributes = get_block_wrapper_attributes(
384            array(
385                'class'               => implode( ' ', $classes ),
386                'style'               => '--vpap-columns:' . $settings['columns'] . ';' . $style_vars['style'],
387                'data-playlist-total' => (string) $total,
388                'data-per-page'       => (string) $per_page,
389                /* translators: 1: number of playlists shown. 2: number of playlists on the site. */
390                'data-summary'        => __( 'Showing %1$s of %2$s', 'jetpack-videopress-pkg' ),
391            )
392        );
393
394        return sprintf(
395            '<div %1$s>' .
396                '<div class="videopress-all-playlists__header">%2$s<span class="videopress-all-playlists__summary">%3$s</span></div>' .
397                '<ul class="videopress-all-playlists__items">%4$s</ul>%5$s' .
398            '</div>',
399            $wrapper_attributes,
400            self::render_heading( $content ),
401            esc_html( $summary ),
402            $items,
403            $pagination
404        );
405    }
406
407    /**
408     * The header's heading: the core Heading block saved with the post, or a
409     * plain fallback for content saved before the heading became editable.
410     *
411     * @param string $content Rendered inner blocks.
412     *
413     * @return string Heading markup.
414     */
415    private static function render_heading( $content ) {
416        $content = is_string( $content ) ? trim( $content ) : '';
417        if ( '' !== $content ) {
418            return $content;
419        }
420
421        return sprintf(
422            '<h2 class="videopress-all-playlists__heading">%s</h2>',
423            esc_html__( 'Playlists', 'jetpack-videopress-pkg' )
424        );
425    }
426
427    /**
428     * Render one playlist card.
429     *
430     * @param array $playlist A record from ordered_playlists().
431     * @param array $settings Normalized settings.
432     * @param int   $page     The page the card belongs to.
433     * @param bool  $hidden   Whether the card starts hidden, waiting for "Load more".
434     *
435     * @return string Card markup.
436     */
437    private static function render_item( $playlist, $settings, $page, $hidden ) {
438        $title       = '' !== $playlist['title'] ? $playlist['title'] : __( 'Untitled playlist', 'jetpack-videopress-pkg' );
439        $permalink   = $playlist['post'] ? self::playlist_link( $playlist ) : '';
440        $video_count = count( $playlist['videos'] );
441        $first_guid  = $video_count && isset( $playlist['videos'][0]['guid'] ) && is_string( $playlist['videos'][0]['guid'] )
442            ? $playlist['videos'][0]['guid']
443            : '';
444
445        $item_classes   = array( 'videopress-all-playlists__item' );
446        $item_classes[] = $first_guid ? 'is-poster-loading' : 'is-poster-missing';
447
448        $badge = '';
449        if ( $settings['show_video_count'] ) {
450            $badge = sprintf(
451                '<span class="videopress-all-playlists__badge"><span class="videopress-all-playlists__badge-icon" aria-hidden="true">≡</span>%s</span>',
452                esc_html(
453                    sprintf(
454                        /* translators: %s: number of videos in the playlist. */
455                        _n( '%s video', '%s videos', $video_count, 'jetpack-videopress-pkg' ),
456                        number_format_i18n( $video_count )
457                    )
458                )
459            );
460        }
461
462        $poster = sprintf(
463            '<span class="videopress-all-playlists__deck" aria-hidden="true"></span>' .
464            '<span class="videopress-all-playlists__poster-frame">' .
465                '<img class="videopress-all-playlists__poster-image" alt="" loading="lazy" hidden />' .
466                '<span class="videopress-all-playlists__poster-missing">%1$s</span>%2$s' .
467            '</span>',
468            esc_html__( 'No poster available', 'jetpack-videopress-pkg' ),
469            $badge
470        );
471        $poster = $permalink
472            ? sprintf(
473                '<a class="videopress-all-playlists__poster" href="%1$s" aria-label="%2$s"%3$s>%4$s</a>',
474                esc_url( $permalink ),
475                esc_attr( $title ),
476                $first_guid ? ' data-guid="' . esc_attr( $first_guid ) . '"' : '',
477                $poster
478            )
479            : sprintf(
480                '<span class="videopress-all-playlists__poster"%1$s>%2$s</span>',
481                $first_guid ? ' data-guid="' . esc_attr( $first_guid ) . '"' : '',
482                $poster
483            );
484
485        $body = sprintf(
486            '<h3 class="videopress-all-playlists__title">%s</h3>',
487            $permalink
488                ? sprintf( '<a href="%1$s">%2$s</a>', esc_url( $permalink ), esc_html( $title ) )
489                : esc_html( $title )
490        );
491        if ( $settings['show_description'] && '' !== $playlist['description'] ) {
492            $body .= sprintf( '<p class="videopress-all-playlists__description">%s</p>', esc_html( $playlist['description'] ) );
493        }
494        if ( $settings['show_updated'] ) {
495            $meta = self::updated_meta( $playlist );
496            if ( '' !== $meta ) {
497                $body .= sprintf( '<span class="videopress-all-playlists__meta">%s</span>', esc_html( $meta ) );
498            }
499        }
500        $body .= sprintf(
501            '<span class="videopress-all-playlists__poster-note">%s</span>',
502            esc_html__( 'First video is private or was deleted.', 'jetpack-videopress-pkg' )
503        );
504        if ( $permalink ) {
505            $body .= sprintf(
506                '<a class="videopress-all-playlists__link" href="%1$s">%2$s</a>',
507                esc_url( $permalink ),
508                esc_html__( 'View full playlist →', 'jetpack-videopress-pkg' )
509            );
510        }
511
512        return sprintf(
513            '<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>',
514            esc_attr( implode( ' ', $item_classes ) ),
515            esc_attr( $playlist['key'] ),
516            $page,
517            $hidden ? ' hidden' : '',
518            $poster,
519            $body
520        );
521    }
522
523    /**
524     * Where a card links: the playlist's page on a channel site, else the post holding it.
525     *
526     * @param array $playlist A record from ordered_playlists() with a published post.
527     *
528     * @return string
529     */
530    private static function playlist_link( $playlist ) {
531        return Channel::is_enabled() ? Channel::playlist_url( $playlist['key'] ) : (string) get_permalink( $playlist['post'] );
532    }
533
534    /**
535     * "Updated 2 weeks ago · 1 hr 12 min" for a card.
536     *
537     * @param array $playlist A record from ordered_playlists().
538     *
539     * @return string
540     */
541    public static function updated_meta( $playlist ) {
542        $parts = array();
543        if ( $playlist['post'] instanceof WP_Post ) {
544            $ago = Channel_Data::ago( (int) strtotime( $playlist['post']->post_modified_gmt . ' UTC' ) );
545            if ( '' !== $ago ) {
546                /* translators: %s: how long ago, e.g. "2 weeks ago". */
547                $parts[] = sprintf( __( 'Updated %s', 'jetpack-videopress-pkg' ), $ago );
548            }
549        }
550        $total_ms = 0;
551        foreach ( Initializer::sanitize_playlist_entries( $playlist['videos'] ) as $entry ) {
552            $total_ms += $entry['durationMs'];
553        }
554        $runtime = Initializer::playlist_runtime_label( $total_ms );
555        if ( '' !== $runtime ) {
556            $parts[] = $runtime;
557        }
558        return implode( ' · ', $parts );
559    }
560
561    /**
562     * Render the numbered pagination.
563     *
564     * @param int $current Current page.
565     * @param int $pages   Number of pages.
566     *
567     * @return string Pagination markup.
568     */
569    private static function render_numbered_pagination( $current, $pages ) {
570        $url_for = function ( $page ) {
571            return 1 === $page
572                ? remove_query_arg( self::PAGE_QUERY_ARG )
573                : add_query_arg( self::PAGE_QUERY_ARG, $page );
574        };
575
576        $links = $current > 1
577            ? sprintf(
578                '<a class="videopress-all-playlists__page videopress-all-playlists__page--prev" href="%1$s">%2$s</a>',
579                esc_url( $url_for( $current - 1 ) ),
580                esc_html__( '← Previous', 'jetpack-videopress-pkg' )
581            )
582            : sprintf(
583                '<span class="videopress-all-playlists__page videopress-all-playlists__page--prev is-disabled" aria-disabled="true">%s</span>',
584                esc_html__( '← Previous', 'jetpack-videopress-pkg' )
585            );
586
587        for ( $page = 1; $page <= $pages; $page++ ) {
588            $links .= $page === $current
589                ? sprintf(
590                    '<span class="videopress-all-playlists__page videopress-all-playlists__page--number is-current" aria-current="page">%d</span>',
591                    $page
592                )
593                : sprintf(
594                    '<a class="videopress-all-playlists__page videopress-all-playlists__page--number" href="%1$s">%2$d</a>',
595                    esc_url( $url_for( $page ) ),
596                    $page
597                );
598        }
599
600        $links .= $current < $pages
601            ? sprintf(
602                '<a class="videopress-all-playlists__page videopress-all-playlists__page--next" href="%1$s">%2$s</a>',
603                esc_url( $url_for( $current + 1 ) ),
604                esc_html__( 'Next →', 'jetpack-videopress-pkg' )
605            )
606            : sprintf(
607                '<span class="videopress-all-playlists__page videopress-all-playlists__page--next is-disabled" aria-disabled="true">%s</span>',
608                esc_html__( 'Next →', 'jetpack-videopress-pkg' )
609            );
610
611        return sprintf(
612            '<nav class="videopress-all-playlists__pagination" aria-label="%1$s">%2$s</nav>',
613            esc_attr__( 'Playlists pagination', 'jetpack-videopress-pkg' ),
614            $links
615        );
616    }
617
618    /**
619     * Render the "Load more" button.
620     *
621     * @param int $remaining Playlists not shown yet.
622     * @param int $per_page  Playlists revealed per click.
623     *
624     * @return string Button markup.
625     */
626    private static function render_load_more( $remaining, $per_page ) {
627        /* translators: %s: number of playlists the button reveals. */
628        $label = __( 'Load %s more', 'jetpack-videopress-pkg' );
629
630        return sprintf(
631            '<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>',
632            esc_attr( $label ),
633            esc_html( sprintf( $label, number_format_i18n( min( $remaining, $per_page ) ) ) )
634        );
635    }
636}