Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.13% covered (success)
99.13%
343 / 346
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%
343 / 346
92.31% covered (success)
92.31%
12 / 13
107
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%
70 / 70
100.00% covered (success)
100.00%
1 / 1
13
 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        $has_any   = (bool) $playlists;
324        if ( $settings['exclude_current'] ) {
325            $current   = Channel::queried_playlist();
326            $playlists = $current
327                ? array_values(
328                    array_filter(
329                        $playlists,
330                        function ( $playlist ) use ( $current ) {
331                            return $playlist['key'] !== $current['key'];
332                        }
333                    )
334                )
335                : $playlists;
336        }
337        $total = count( $playlists );
338        if ( ! $total ) {
339            // Only a site without any playlist is empty; "More playlists" next to the only one stays hidden.
340            return $has_any ? '' : Channel_Blocks::render_empty_state( 'playlists', $block_attributes, 'videopress-all-playlists' );
341        }
342
343        $per_page     = $settings['per_page'];
344        $pages        = (int) ceil( $total / $per_page );
345        $is_load_more = 'load-more' === $settings['pagination'];
346        $current_page = $is_load_more ? 1 : min( $pages, self::requested_page() );
347
348        $items = '';
349        foreach ( $playlists as $index => $playlist ) {
350            $page = (int) floor( $index / $per_page ) + 1;
351            if ( ! $is_load_more && $page !== $current_page ) {
352                continue;
353            }
354            $items .= self::render_item( $playlist, $settings, $page, $is_load_more && $page > 1 );
355        }
356
357        if ( $is_load_more ) {
358            $shown   = min( $per_page, $total );
359            $summary = sprintf(
360                /* translators: 1: number of playlists shown. 2: number of playlists on the site. */
361                __( 'Showing %1$s of %2$s', 'jetpack-videopress-pkg' ),
362                number_format_i18n( $shown ),
363                number_format_i18n( $total )
364            );
365            $pagination = $total > $shown ? self::render_load_more( $total - $shown, $per_page ) : '';
366        } else {
367            $summary = sprintf(
368                /* translators: %s: number of playlists on the site. */
369                _n( '%s playlist', '%s playlists', $total, 'jetpack-videopress-pkg' ),
370                number_format_i18n( $total )
371            );
372            $pagination = $pages > 1 ? self::render_numbered_pagination( $current_page, $pages ) : '';
373        }
374
375        $style_vars = self::block_style_vars( $block_attributes );
376        $classes    = array_merge(
377            array(
378                'videopress-all-playlists',
379                'is-layout-' . $settings['layout'],
380                'is-pagination-' . $settings['pagination'],
381            ),
382            $style_vars['classes']
383        );
384
385        $wrapper_attributes = get_block_wrapper_attributes(
386            array(
387                'class'               => implode( ' ', $classes ),
388                'style'               => '--vpap-columns:' . $settings['columns'] . ';' . $style_vars['style'],
389                'data-playlist-total' => (string) $total,
390                'data-per-page'       => (string) $per_page,
391                /* translators: 1: number of playlists shown. 2: number of playlists on the site. */
392                'data-summary'        => __( 'Showing %1$s of %2$s', 'jetpack-videopress-pkg' ),
393            )
394        );
395
396        return sprintf(
397            '<div %1$s>' .
398                '<div class="videopress-all-playlists__header">%2$s<span class="videopress-all-playlists__summary">%3$s</span></div>' .
399                '<ul class="videopress-all-playlists__items">%4$s</ul>%5$s' .
400            '</div>',
401            $wrapper_attributes,
402            self::render_heading( $content ),
403            esc_html( $summary ),
404            $items,
405            $pagination
406        );
407    }
408
409    /**
410     * The header's heading: the core Heading block saved with the post, or a
411     * plain fallback for content saved before the heading became editable.
412     *
413     * @param string $content Rendered inner blocks.
414     *
415     * @return string Heading markup.
416     */
417    private static function render_heading( $content ) {
418        $content = is_string( $content ) ? trim( $content ) : '';
419        if ( '' !== $content ) {
420            return $content;
421        }
422
423        return sprintf(
424            '<h2 class="videopress-all-playlists__heading">%s</h2>',
425            esc_html__( 'Playlists', 'jetpack-videopress-pkg' )
426        );
427    }
428
429    /**
430     * Render one playlist card.
431     *
432     * @param array $playlist A record from ordered_playlists().
433     * @param array $settings Normalized settings.
434     * @param int   $page     The page the card belongs to.
435     * @param bool  $hidden   Whether the card starts hidden, waiting for "Load more".
436     *
437     * @return string Card markup.
438     */
439    private static function render_item( $playlist, $settings, $page, $hidden ) {
440        $title       = '' !== $playlist['title'] ? $playlist['title'] : __( 'Untitled playlist', 'jetpack-videopress-pkg' );
441        $permalink   = $playlist['post'] ? self::playlist_link( $playlist ) : '';
442        $video_count = count( $playlist['videos'] );
443        $first_guid  = $video_count && isset( $playlist['videos'][0]['guid'] ) && is_string( $playlist['videos'][0]['guid'] )
444            ? $playlist['videos'][0]['guid']
445            : '';
446
447        $item_classes   = array( 'videopress-all-playlists__item' );
448        $item_classes[] = $first_guid ? 'is-poster-loading' : 'is-poster-missing';
449
450        $badge = '';
451        if ( $settings['show_video_count'] ) {
452            $badge = sprintf(
453                '<span class="videopress-all-playlists__badge"><span class="videopress-all-playlists__badge-icon" aria-hidden="true">≡</span>%s</span>',
454                esc_html(
455                    sprintf(
456                        /* translators: %s: number of videos in the playlist. */
457                        _n( '%s video', '%s videos', $video_count, 'jetpack-videopress-pkg' ),
458                        number_format_i18n( $video_count )
459                    )
460                )
461            );
462        }
463
464        $poster = sprintf(
465            '<span class="videopress-all-playlists__deck" aria-hidden="true"></span>' .
466            '<span class="videopress-all-playlists__poster-frame">' .
467                '<img class="videopress-all-playlists__poster-image" alt="" loading="lazy" hidden />' .
468                '<span class="videopress-all-playlists__poster-missing">%1$s</span>%2$s' .
469            '</span>',
470            esc_html__( 'No poster available', 'jetpack-videopress-pkg' ),
471            $badge
472        );
473        $poster = $permalink
474            ? sprintf(
475                '<a class="videopress-all-playlists__poster" href="%1$s" aria-label="%2$s"%3$s>%4$s</a>',
476                esc_url( $permalink ),
477                esc_attr( $title ),
478                $first_guid ? ' data-guid="' . esc_attr( $first_guid ) . '"' : '',
479                $poster
480            )
481            : sprintf(
482                '<span class="videopress-all-playlists__poster"%1$s>%2$s</span>',
483                $first_guid ? ' data-guid="' . esc_attr( $first_guid ) . '"' : '',
484                $poster
485            );
486
487        $body = sprintf(
488            '<h3 class="videopress-all-playlists__title">%s</h3>',
489            $permalink
490                ? sprintf( '<a href="%1$s">%2$s</a>', esc_url( $permalink ), esc_html( $title ) )
491                : esc_html( $title )
492        );
493        if ( $settings['show_description'] && '' !== $playlist['description'] ) {
494            $body .= sprintf( '<p class="videopress-all-playlists__description">%s</p>', esc_html( $playlist['description'] ) );
495        }
496        if ( $settings['show_updated'] ) {
497            $meta = self::updated_meta( $playlist );
498            if ( '' !== $meta ) {
499                $body .= sprintf( '<span class="videopress-all-playlists__meta">%s</span>', esc_html( $meta ) );
500            }
501        }
502        $body .= sprintf(
503            '<span class="videopress-all-playlists__poster-note">%s</span>',
504            esc_html__( 'First video is private or was deleted.', 'jetpack-videopress-pkg' )
505        );
506        if ( $permalink ) {
507            $body .= sprintf(
508                '<a class="videopress-all-playlists__link" href="%1$s">%2$s</a>',
509                esc_url( $permalink ),
510                esc_html__( 'View full playlist →', 'jetpack-videopress-pkg' )
511            );
512        }
513
514        return sprintf(
515            '<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>',
516            esc_attr( implode( ' ', $item_classes ) ),
517            esc_attr( $playlist['key'] ),
518            $page,
519            $hidden ? ' hidden' : '',
520            $poster,
521            $body
522        );
523    }
524
525    /**
526     * Where a card links: the playlist's page on a channel site, else the post holding it.
527     *
528     * @param array $playlist A record from ordered_playlists() with a published post.
529     *
530     * @return string
531     */
532    private static function playlist_link( $playlist ) {
533        return Channel::is_enabled() ? Channel::playlist_url( $playlist['key'] ) : (string) get_permalink( $playlist['post'] );
534    }
535
536    /**
537     * "Updated 2 weeks ago · 1 hr 12 min" for a card.
538     *
539     * @param array $playlist A record from ordered_playlists().
540     *
541     * @return string
542     */
543    public static function updated_meta( $playlist ) {
544        $parts = array();
545        if ( $playlist['post'] instanceof WP_Post ) {
546            $ago = Channel_Data::ago( (int) strtotime( $playlist['post']->post_modified_gmt . ' UTC' ) );
547            if ( '' !== $ago ) {
548                /* translators: %s: how long ago, e.g. "2 weeks ago". */
549                $parts[] = sprintf( __( 'Updated %s', 'jetpack-videopress-pkg' ), $ago );
550            }
551        }
552        $total_ms = 0;
553        foreach ( Initializer::sanitize_playlist_entries( $playlist['videos'] ) as $entry ) {
554            $total_ms += $entry['durationMs'];
555        }
556        $runtime = Initializer::playlist_runtime_label( $total_ms );
557        if ( '' !== $runtime ) {
558            $parts[] = $runtime;
559        }
560        return implode( ' · ', $parts );
561    }
562
563    /**
564     * Render the numbered pagination.
565     *
566     * @param int $current Current page.
567     * @param int $pages   Number of pages.
568     *
569     * @return string Pagination markup.
570     */
571    private static function render_numbered_pagination( $current, $pages ) {
572        $url_for = function ( $page ) {
573            return 1 === $page
574                ? remove_query_arg( self::PAGE_QUERY_ARG )
575                : add_query_arg( self::PAGE_QUERY_ARG, $page );
576        };
577
578        $links = $current > 1
579            ? sprintf(
580                '<a class="videopress-all-playlists__page videopress-all-playlists__page--prev" href="%1$s">%2$s</a>',
581                esc_url( $url_for( $current - 1 ) ),
582                esc_html__( '← Previous', 'jetpack-videopress-pkg' )
583            )
584            : sprintf(
585                '<span class="videopress-all-playlists__page videopress-all-playlists__page--prev is-disabled" aria-disabled="true">%s</span>',
586                esc_html__( '← Previous', 'jetpack-videopress-pkg' )
587            );
588
589        for ( $page = 1; $page <= $pages; $page++ ) {
590            $links .= $page === $current
591                ? sprintf(
592                    '<span class="videopress-all-playlists__page videopress-all-playlists__page--number is-current" aria-current="page">%d</span>',
593                    $page
594                )
595                : sprintf(
596                    '<a class="videopress-all-playlists__page videopress-all-playlists__page--number" href="%1$s">%2$d</a>',
597                    esc_url( $url_for( $page ) ),
598                    $page
599                );
600        }
601
602        $links .= $current < $pages
603            ? sprintf(
604                '<a class="videopress-all-playlists__page videopress-all-playlists__page--next" href="%1$s">%2$s</a>',
605                esc_url( $url_for( $current + 1 ) ),
606                esc_html__( 'Next →', 'jetpack-videopress-pkg' )
607            )
608            : sprintf(
609                '<span class="videopress-all-playlists__page videopress-all-playlists__page--next is-disabled" aria-disabled="true">%s</span>',
610                esc_html__( 'Next →', 'jetpack-videopress-pkg' )
611            );
612
613        return sprintf(
614            '<nav class="videopress-all-playlists__pagination" aria-label="%1$s">%2$s</nav>',
615            esc_attr__( 'Playlists pagination', 'jetpack-videopress-pkg' ),
616            $links
617        );
618    }
619
620    /**
621     * Render the "Load more" button.
622     *
623     * @param int $remaining Playlists not shown yet.
624     * @param int $per_page  Playlists revealed per click.
625     *
626     * @return string Button markup.
627     */
628    private static function render_load_more( $remaining, $per_page ) {
629        /* translators: %s: number of playlists the button reveals. */
630        $label = __( 'Load %s more', 'jetpack-videopress-pkg' );
631
632        return sprintf(
633            '<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>',
634            esc_attr( $label ),
635            esc_html( sprintf( $label, number_format_i18n( min( $remaining, $per_page ) ) ) )
636        );
637    }
638}