PluginProbe
GutSlider – All in One Slider and Carousel Blocks for Gutenberg / 3.2.0
GutSlider – All in One Slider and Carousel Blocks for Gutenberg v3.2.0
3.2.0 3.1.0 3.0.0 2.13.2 2.13.1 2.13.0 trunk 1.0.0 2.1.0 2.10.0 2.10.1 2.11.0 2.11.1 2.11.2 2.11.3 2.11.4 2.12.0 2.2.1 2.2.2 2.3.0 2.4.0 2.5.1 2.5.3 2.5.4 2.5.5 All 57 releases
slider-blocks / includes / Blocks / PostSlider.php

PostSlider.php in GutSlider – All in One Slider and Carousel Blocks for Gutenberg 3.2.0, at includes/Blocks/PostSlider.php

1,252 lines 45.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 declare( strict_types=1 );
3
4 namespace GutSlider\Blocks;
5
6 if ( ! defined( 'ABSPATH' ) ) {
7 exit;
8 }
9
10 /**
11 * Blog Post block: query, cache, markup, "Load more" endpoint and schema.
12 *
13 * The block is rendered on the server (render.php calls self::render()), so
14 * posts are always current. Every option added here is off by default: a
15 * slider saved before renders exactly as it did.
16 *
17 * @package GutSlider\Blocks
18 * @since 3.1.1
19 */
20 final class PostSlider {
21
22 /** Overlay Grid: posts per page (one large tile, four small). Same as GRID_PAGE_SIZE in presets.js. */
23 const GRID_PAGE_SIZE = 5;
24
25 /** Designs with their own navigation instead of thumbnails. */
26 const NO_THUMBS_DESIGNS = array( 'grid', 'featured' );
27
28 /** Featured + List navigates with its list; "Load more" would only make it longer. */
29 const NO_LOAD_MORE_DESIGNS = array( 'featured' );
30
31 /**
32 * Swiper options each design needs, over the saved ones (so a block saved with an
33 * older version of a design still slides right). Same as PRESET_SWIPER_OPTIONS in
34 * presets.js; `null` removes the option (the carousel columns).
35 */
36 const PRESET_SWIPER_OPTIONS = array(
37 'magazine' => array(
38 'effect' => 'fade',
39 'fadeEffect' => array( 'crossFade' => true ),
40 'slidesPerView' => 1,
41 'spaceBetween' => 0,
42 'breakpoints' => null,
43 ),
44 'cards' => array(
45 'effect' => 'slide',
46 'grabCursor' => true,
47 ),
48 'grid' => array(
49 'effect' => 'slide',
50 'slidesPerView' => 1,
51 'spaceBetween' => 24,
52 'grabCursor' => true,
53 'breakpoints' => null,
54 ),
55 'featured' => array(
56 'effect' => 'fade',
57 'fadeEffect' => array( 'crossFade' => true ),
58 'slidesPerView' => 1,
59 'spaceBetween' => 0,
60 'breakpoints' => null,
61 ),
62 'timeline' => array(
63 'effect' => 'slide',
64 'grabCursor' => true,
65 ),
66 );
67
68 /** All designs ('' = the original slider). */
69 const DESIGNS = array( 'magazine', 'cards', 'grid', 'featured', 'timeline' );
70
71 /** Option holding the cache version; bumped whenever posts change. */
72 const CACHE_VERSION_OPTION = 'gutslider_post_slider_cache_v';
73
74 /** Fallback badge colours, picked by term id (same list as the editor). */
75 const BADGE_PALETTE = array( '#2563eb', '#db2777', '#059669', '#d97706', '#7c3aed', '#dc2626', '#0891b2', '#4f46e5' );
76
77 /**
78 * Constructor.
79 */
80 public function __construct() {
81 add_action( 'rest_api_init', array( $this, 'register_routes' ) );
82
83 // Anything that can change what a slider shows invalidates cached results.
84 foreach ( array( 'save_post', 'deleted_post', 'trashed_post', 'untrashed_post', 'set_object_terms', 'edited_term', 'delete_term', 'wp_update_comment_count', 'update_option_sticky_posts' ) as $hook ) {
85 add_action( $hook, array( __CLASS__, 'flush_cache' ) );
86 }
87 add_action( 'transition_post_status', array( $this, 'on_status_change' ), 10, 3 );
88 add_action( 'updated_post_meta', array( $this, 'on_meta_change' ), 10, 3 );
89 add_action( 'added_post_meta', array( $this, 'on_meta_change' ), 10, 3 );
90 add_action( 'deleted_post_meta', array( $this, 'on_meta_change' ), 10, 3 );
91 }
92
93 /* ---------------------------------------------------------------------
94 * Attributes
95 * ------------------------------------------------------------------ */
96
97 /**
98 * Attributes with defaults; scalars sanitised as text (booleans become '1' / '').
99 *
100 * @param array $attributes Block attributes.
101 * @return array
102 */
103 public static function normalize( $attributes ) {
104 $defaults = array(
105 'showTitle' => true,
106 'showCat' => true,
107 'showExcerpt' => true,
108 'showBtn' => true,
109 'linkTitle' => true,
110 'titleTag' => 'h2',
111 'btnLabel' => __( 'Read More', 'slider-blocks' ),
112 'enableOverlay' => true,
113 'sliderType' => 'slider',
114 'uniqueId' => '',
115 'navContainerPosition' => 'nav_inside',
116 'navPosition' => 'nav_cc',
117 'catAnimation' => '',
118 'titleAnimation' => '',
119 'exptAnimation' => '',
120 'btnAnimation' => '',
121 'showNavigation' => true,
122 'showPagination' => true,
123 'customNavIcon' => false,
124 'navIconSource' => 'library',
125 'navPrevIcon' => 'arrowLeft',
126 'navNextIcon' => 'arrowRight',
127 'customPrevSVG' => '',
128 'customNextSVG' => '',
129 'excerptLength' => 20,
130 'enableRemoteNav' => false,
131 'remotePrevSelector' => '',
132 'remoteNextSelector' => '',
133 'detachContent' => false,
134 'showThumbs' => false,
135 'thumbContent' => 'image',
136 'thumbTitlePosition' => 'below',
137 'hideWhenEmpty' => true,
138 'emptyMessage' => '',
139 'showDate' => false,
140 'showAuthor' => false,
141 'showAvatar' => false,
142 'showReadingTime' => false,
143 'showComments' => false,
144 'cardLink' => false,
145 'imageSize' => 'full',
146 'preset' => '',
147 'catStyle' => '',
148 'catPosition' => 'above-title',
149 'highlightCurrent' => false,
150 'schemaMarkup' => false,
151 'loadMore' => false,
152 'loadMoreMax' => 30,
153 'infiniteLoop' => true,
154 );
155
156 $atts = wp_parse_args( $attributes, $defaults );
157 foreach ( $atts as $key => $value ) {
158 if ( is_scalar( $value ) || null === $value ) {
159 $atts[ $key ] = sanitize_text_field( (string) $value );
160 }
161 }
162
163 $atts['sliderOptions'] = isset( $attributes['sliderOptions'] ) ? $attributes['sliderOptions'] : array();
164 $atts['thumbsOptions'] = isset( $attributes['thumbsOptions'] ) ? $attributes['thumbsOptions'] : array();
165 $atts['focusPoints'] = isset( $attributes['focusPoints'] ) && is_array( $attributes['focusPoints'] ) ? $attributes['focusPoints'] : array();
166 $atts['preset'] = in_array( $atts['preset'], self::DESIGNS, true ) ? $atts['preset'] : '';
167
168 return $atts;
169 }
170
171 /**
172 * Post type to query: any public post type, 'post' otherwise.
173 *
174 * @param array $attributes Block attributes.
175 * @return string
176 */
177 public static function post_type( $attributes ) {
178 $type = isset( $attributes['postType'] ) ? sanitize_key( $attributes['postType'] ) : 'post';
179 return ( $type && post_type_exists( $type ) && is_post_type_viewable( $type ) && 'attachment' !== $type ) ? $type : 'post';
180 }
181
182 /**
183 * Taxonomies of a post type that can filter or label slides.
184 *
185 * @param string $post_type Post type.
186 * @return string[]
187 */
188 public static function taxonomies_for( $post_type ) {
189 $names = array();
190 foreach ( get_object_taxonomies( $post_type, 'objects' ) as $taxonomy ) {
191 if ( $taxonomy->public && $taxonomy->show_ui && 'post_format' !== $taxonomy->name ) {
192 $names[] = $taxonomy->name;
193 }
194 }
195 return $names;
196 }
197
198 /**
199 * Taxonomies whose terms label a slide: the ones the slider is filtered by, else the
200 * post type's categories (core `category`, or its first hierarchical taxonomy).
201 *
202 * @param array $attributes Block attributes.
203 * @return string[]
204 */
205 public static function label_taxonomies( $attributes ) {
206 $query_type = isset( $attributes['queryType'] ) ? sanitize_key( $attributes['queryType'] ) : 'latest_posts';
207 if ( 'specific_categories' === $query_type && ! empty( $attributes['taxQuery'] ) && is_array( $attributes['taxQuery'] ) ) {
208 return array_map( 'sanitize_key', array_keys( $attributes['taxQuery'] ) );
209 }
210
211 $post_type = self::post_type( $attributes );
212 $taxonomies = self::taxonomies_for( $post_type );
213 // The post type's own categories first (e.g. product_cat), then any hierarchical one.
214 foreach ( array( 'category', $post_type . '_cat', $post_type . '_category' ) as $preferred ) {
215 if ( in_array( $preferred, $taxonomies, true ) ) {
216 return array( $preferred );
217 }
218 }
219 foreach ( $taxonomies as $taxonomy ) {
220 if ( is_taxonomy_hierarchical( $taxonomy ) ) {
221 return array( $taxonomy );
222 }
223 }
224 return $taxonomies ? array( $taxonomies[0] ) : array();
225 }
226
227 /* ---------------------------------------------------------------------
228 * Query
229 * ------------------------------------------------------------------ */
230
231 /**
232 * WP_Query arguments from the block's saved query settings.
233 *
234 * @param array $attributes Block attributes.
235 * @param int $current Post the slider is shown on (0 if none).
236 * @return array
237 */
238 public static function query_args( $attributes, $current = 0 ) {
239 $query_type = isset( $attributes['queryType'] ) ? sanitize_key( $attributes['queryType'] ) : 'latest_posts';
240 $post_type = self::post_type( $attributes );
241 $total = isset( $attributes['totalPosts'] ) ? (int) $attributes['totalPosts'] : 3;
242 $orderby = isset( $attributes['postOrderBy'] ) ? sanitize_key( $attributes['postOrderBy'] ) : 'date';
243 $order = isset( $attributes['postOrder'] ) && 'asc' === strtolower( (string) $attributes['postOrder'] ) ? 'ASC' : 'DESC';
244
245 $args = array(
246 'post_type' => $post_type,
247 'post_status' => 'publish',
248 // An empty "Total Posts" meant every post; cap it like the REST API does.
249 'posts_per_page' => $total > 0 ? min( $total, 100 ) : 100,
250 'orderby' => in_array( $orderby, array( 'date', 'title', 'modified', 'comment_count', 'rand' ), true ) ? $orderby : 'date',
251 'order' => $order,
252 'ignore_sticky_posts' => true,
253 'no_found_rows' => true,
254 );
255
256 $is_specific = 'specific_posts' === $query_type && ! empty( $attributes['specificPosts'] ) && is_array( $attributes['specificPosts'] );
257 $not_in = array();
258
259 // Skip the first N posts (not for hand-picked posts).
260 $offset = isset( $attributes['postOffset'] ) ? absint( $attributes['postOffset'] ) : 0;
261 if ( $offset && ! $is_specific ) {
262 $args['offset'] = $offset;
263 }
264
265 // Leave out the post this slider is shown on (always, in Related mode).
266 if ( $current && ( ! empty( $attributes['excludeCurrent'] ) || 'related' === $query_type ) ) {
267 $not_in[] = (int) $current;
268 }
269
270 // Sticky posts: normal place (default), first (see self::query_ids()), or hidden.
271 if ( ! $is_specific && isset( $attributes['stickyPosts'] ) && 'exclude' === $attributes['stickyPosts'] ) {
272 $not_in = array_merge( $not_in, array_map( 'absint', (array) get_option( 'sticky_posts', array() ) ) );
273 }
274
275 if ( $not_in ) {
276 $args['post__not_in'] = array_values( array_unique( $not_in ) ); // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in
277 }
278
279 if ( ! empty( $attributes['onlyWithImage'] ) ) {
280 $args['meta_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
281 array(
282 'key' => '_thumbnail_id',
283 'compare' => 'EXISTS',
284 ),
285 );
286 }
287
288 $tax_query = array();
289
290 if ( $is_specific ) {
291 $ids = array_filter( array_map( 'absint', wp_list_pluck( $attributes['specificPosts'], 'value' ) ) );
292 if ( $ids ) {
293 $args['post__in'] = $ids;
294 $args['posts_per_page'] = count( $ids );
295 }
296 }
297
298 if ( 'specific_categories' === $query_type ) {
299 // `taxQuery` ({ taxonomy: [{ label, value }] }) covers every taxonomy;
300 // `specificCategories` is the legacy core-category selection.
301 $selected = isset( $attributes['taxQuery'] ) && is_array( $attributes['taxQuery'] ) && ! empty( $attributes['taxQuery'] )
302 ? $attributes['taxQuery']
303 : ( ! empty( $attributes['specificCategories'] ) && is_array( $attributes['specificCategories'] ) ? array( 'category' => $attributes['specificCategories'] ) : array() );
304
305 foreach ( $selected as $taxonomy => $terms ) {
306 $taxonomy = sanitize_key( $taxonomy );
307 $term_ids = is_array( $terms ) ? array_filter( array_map( 'absint', wp_list_pluck( $terms, 'value' ) ) ) : array();
308 if ( $term_ids && taxonomy_exists( $taxonomy ) ) {
309 $tax_query[] = array(
310 'taxonomy' => $taxonomy,
311 'field' => 'term_id',
312 'terms' => $term_ids,
313 );
314 }
315 }
316 }
317
318 // Related: posts sharing any of the current post's terms.
319 if ( 'related' === $query_type && $current ) {
320 $related = array();
321 foreach ( self::related_taxonomies( $attributes, $post_type ) as $taxonomy ) {
322 $term_ids = wp_get_post_terms( $current, $taxonomy, array( 'fields' => 'ids' ) );
323 if ( $term_ids && ! is_wp_error( $term_ids ) ) {
324 $related[] = array(
325 'taxonomy' => $taxonomy,
326 'field' => 'term_id',
327 'terms' => $term_ids,
328 );
329 }
330 }
331 if ( $related ) {
332 $tax_query[] = array_merge( array( 'relation' => 'OR' ), $related );
333 }
334 }
335
336 // Leave out posts in these terms, for any query but hand-picked posts.
337 if ( ! $is_specific && ! empty( $attributes['excludeTerms'] ) && is_array( $attributes['excludeTerms'] ) ) {
338 foreach ( $attributes['excludeTerms'] as $taxonomy => $terms ) {
339 $taxonomy = sanitize_key( $taxonomy );
340 $term_ids = is_array( $terms ) ? array_filter( array_map( 'absint', wp_list_pluck( $terms, 'value' ) ) ) : array();
341 if ( $term_ids && taxonomy_exists( $taxonomy ) ) {
342 $tax_query[] = array(
343 'taxonomy' => $taxonomy,
344 'field' => 'term_id',
345 'terms' => $term_ids,
346 'operator' => 'NOT IN',
347 );
348 }
349 }
350 }
351
352 if ( $tax_query ) {
353 // Every group must match (same as the REST query the editor previews with).
354 $args['tax_query'] = array_merge( array( 'relation' => 'AND' ), $tax_query ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
355 }
356
357 /**
358 * Filters the query behind a Blog Post slider.
359 *
360 * @param array $args WP_Query arguments.
361 * @param array $attributes Block attributes.
362 */
363 return apply_filters( 'gutslider_post_slider_query_args', $args, $attributes );
364 }
365
366 /**
367 * Taxonomies Related mode matches on.
368 *
369 * @param array $attributes Block attributes.
370 * @param string $post_type Post type.
371 * @return string[]
372 */
373 public static function related_taxonomies( $attributes, $post_type ) {
374 $available = self::taxonomies_for( $post_type );
375 if ( 'post' === $post_type ) {
376 $by = isset( $attributes['relatedBy'] ) ? sanitize_key( $attributes['relatedBy'] ) : 'both';
377 $wanted = 'category' === $by ? array( 'category' ) : ( 'post_tag' === $by ? array( 'post_tag' ) : array( 'category', 'post_tag' ) );
378 return array_values( array_intersect( $wanted, $available ) );
379 }
380 return $available;
381 }
382
383 /**
384 * Post ids for a page of the slider (page 0 = what the page shows first),
385 * cached until any post, term, comment or sticky post changes.
386 *
387 * @param array $attributes Block attributes.
388 * @param int $current Post the slider is shown on.
389 * @param int $page Page (for "Load more").
390 * @return array { ids: int[], more: bool }
391 */
392 public static function query_ids( $attributes, $current = 0, $page = 0 ) {
393 $args = self::query_args( $attributes, $current );
394 $per_page = (int) $args['posts_per_page'];
395 $offset = isset( $args['offset'] ) ? (int) $args['offset'] : 0;
396 $sticky = isset( $attributes['stickyPosts'] ) && 'first' === $attributes['stickyPosts'] && empty( $args['post__in'] );
397 $max = ! empty( $attributes['loadMore'] ) ? max( $per_page, min( 100, absint( $attributes['loadMoreMax'] ?? 30 ) ) ) : $per_page;
398
399 // Hand-picked posts have no further pages.
400 if ( $page > 0 && ! empty( $args['post__in'] ) && ! $sticky ) {
401 return array( 'ids' => array(), 'more' => false );
402 }
403
404 $use_cache = 'rand' !== $args['orderby'] && apply_filters( 'gutslider_post_slider_use_cache', true, $attributes );
405 $cache_key = 'gs_ps_' . md5( wp_json_encode( array( $args, $sticky, $page, $max, self::cache_version() ) ) );
406 if ( $use_cache ) {
407 $cached = get_transient( $cache_key );
408 if ( is_array( $cached ) && isset( $cached['ids'] ) ) {
409 return $cached;
410 }
411 }
412
413 $start = $offset + $page * $per_page;
414 $want = $per_page + 1; // One extra tells whether there is more.
415
416 if ( $sticky ) {
417 $sticky_ids = array_filter( array_map( 'absint', (array) get_option( 'sticky_posts', array() ) ) );
418 $lookup = array_merge( $args, array( 'fields' => 'ids', 'offset' => 0, 'posts_per_page' => $start + $want, 'suppress_filters' => false ) );
419 $stickies = $sticky_ids ? get_posts( array_merge( $lookup, array( 'post__in' => $sticky_ids ) ) ) : array();
420 $others = get_posts( array_merge( $lookup, array( 'post__not_in' => array_merge( $args['post__not_in'] ?? array(), $sticky_ids ) ) ) );
421 $ids = array_slice( array_merge( $stickies, $others ), $start, $want );
422 } else {
423 $query = new \WP_Query( array_merge( $args, array( 'fields' => 'ids', 'offset' => $start, 'posts_per_page' => $want ) ) );
424 $ids = array_map( 'intval', $query->posts );
425 }
426
427 $more = count( $ids ) > $per_page;
428 $ids = array_slice( $ids, 0, $per_page );
429
430 // "Load more" stops at the chosen maximum.
431 $shown_after = ( $page + 1 ) * $per_page;
432 if ( $shown_after >= $max ) {
433 $ids = array_slice( $ids, 0, max( 0, $max - $page * $per_page ) );
434 $more = false;
435 }
436
437 $result = array(
438 'ids' => array_map( 'intval', $ids ),
439 'more' => $more && ! empty( $attributes['loadMore'] ),
440 );
441
442 if ( $use_cache ) {
443 set_transient( $cache_key, $result, (int) apply_filters( 'gutslider_post_slider_cache_ttl', 12 * HOUR_IN_SECONDS ) );
444 }
445
446 return $result;
447 }
448
449 /**
450 * Everything a slide needs, for the given post ids (in that order).
451 *
452 * @param int[] $ids Post ids.
453 * @param array $attributes Block attributes.
454 * @param int $current Post the slider is shown on.
455 * @return array[]
456 */
457 public static function items( $ids, $attributes, $current = 0 ) {
458 if ( ! $ids ) {
459 return array();
460 }
461
462 $taxonomies = self::label_taxonomies( $attributes );
463 $excerpt_length = isset( $attributes['excerptLength'] ) ? (int) $attributes['excerptLength'] : 20;
464 // Plain text only, but keep a leading space (" Read more").
465 $excerpt_ending = isset( $attributes['excerptEnding'] ) ? wp_kses( (string) $attributes['excerptEnding'], array() ) : '...';
466
467 $query = new \WP_Query(
468 array(
469 'post_type' => 'any',
470 'post_status' => 'publish',
471 'post__in' => $ids,
472 'orderby' => 'post__in',
473 'posts_per_page' => count( $ids ),
474 'ignore_sticky_posts' => true,
475 'no_found_rows' => true,
476 )
477 );
478
479 $items = array();
480 while ( $query->have_posts() ) {
481 $query->the_post();
482 $post_id = get_the_ID();
483
484 $terms = array();
485 foreach ( $taxonomies as $taxonomy ) {
486 $post_terms = get_the_terms( $post_id, $taxonomy );
487 if ( $post_terms && ! is_wp_error( $post_terms ) ) {
488 $terms = array_merge( $terms, $post_terms );
489 }
490 }
491
492 // Excerpt: the post's own excerpt, or its opening words when it has none,
493 // cut to the chosen length with the chosen ending. Protected posts keep it private.
494 if ( post_password_required() ) {
495 $excerpt = '';
496 } elseif ( $excerpt_length > 0 ) {
497 $source = has_excerpt() ? get_the_excerpt() : wp_strip_all_tags( excerpt_remove_blocks( strip_shortcodes( get_the_content() ) ) );
498 $excerpt = wp_trim_words( $source, $excerpt_length, $excerpt_ending );
499 } else {
500 $excerpt = apply_filters( 'the_excerpt', get_the_excerpt() );
501 }
502
503 $author_id = (int) get_post_field( 'post_author', $post_id );
504 $words = count( preg_split( '/\s+/u', trim( wp_strip_all_tags( strip_shortcodes( get_the_content() ) ) ), -1, PREG_SPLIT_NO_EMPTY ) );
505
506 $items[] = array(
507 'id' => $post_id,
508 'type' => get_post_type(),
509 'title' => get_the_title(),
510 'excerpt' => $excerpt,
511 'link' => get_permalink(),
512 'image_id' => (int) get_post_thumbnail_id(),
513 'terms' => $terms,
514 'date' => get_the_date(),
515 'date_iso' => get_the_date( 'c' ),
516 'modified' => get_the_modified_date( 'c' ),
517 'author' => $author_id ? get_the_author_meta( 'display_name', $author_id ) : '',
518 'author_id' => $author_id,
519 // About 200 words a minute; at least one minute.
520 'reading' => max( 1, (int) ceil( $words / 200 ) ),
521 'comments' => (int) get_comments_number(),
522 'is_current' => $current && $post_id === (int) $current,
523 );
524 }
525 wp_reset_postdata();
526
527 return $items;
528 }
529
530 /**
531 * Whether the slide background was chosen by the user (not the untouched #efefef default).
532 * Featured + List and Timeline only show a chosen one, as a card. Keep in sync with
533 * hasCustomSlideBg() in presets.js.
534 *
535 * @param array $attributes Block attributes.
536 * @return bool
537 */
538 public static function has_custom_slide_bg( $attributes ) {
539 if ( isset( $attributes['bgType'] ) && 'gradient' === $attributes['bgType'] ) {
540 return ! empty( $attributes['bgGradient'] );
541 }
542 if ( ! empty( $attributes['bgImage']['url'] ) ) {
543 return true;
544 }
545 $color = isset( $attributes['bgColor'] ) ? strtolower( (string) $attributes['bgColor'] ) : '';
546 return '' !== $color && '#efefef' !== $color;
547 }
548
549 /**
550 * Badge colour of a term: its own (set on the term screen), else one from the palette.
551 *
552 * @param \WP_Term $term Term.
553 * @return string Hex colour.
554 */
555 public static function term_color( $term ) {
556 $color = sanitize_hex_color( (string) get_term_meta( $term->term_id, 'gutslider_color', true ) );
557 return $color ? $color : self::BADGE_PALETTE[ $term->term_id % count( self::BADGE_PALETTE ) ];
558 }
559
560 /* ---------------------------------------------------------------------
561 * Markup
562 * ------------------------------------------------------------------ */
563
564 /**
565 * Post the slider is shown on (the post whose content or template holds it).
566 *
567 * @return int
568 */
569 public static function current_post_id() {
570 $id = get_the_ID();
571 if ( ! $id && is_singular() ) {
572 $id = get_queried_object_id();
573 }
574 return (int) $id;
575 }
576
577 /**
578 * Whole block.
579 *
580 * @param array $attributes Block attributes.
581 * @return string HTML.
582 */
583 public static function render( $attributes ) {
584 $atts = self::normalize( $attributes );
585 $current = self::current_post_id();
586 $result = self::query_ids( $attributes, $current );
587 $items = self::items( $result['ids'], $attributes, $current );
588
589 // Nothing matched: hide the block (default), or say so.
590 if ( empty( $items ) ) {
591 if ( $atts['hideWhenEmpty'] ) {
592 return '';
593 }
594 return sprintf(
595 '<div %1$s><p class="gutslider-post-empty">%2$s</p></div>',
596 get_block_wrapper_attributes( array( 'class' => esc_attr( $atts['uniqueId'] ) ) ),
597 esc_html( '' !== $atts['emptyMessage'] ? $atts['emptyMessage'] : __( 'No posts found.', 'slider-blocks' ) )
598 );
599 }
600
601 $show_thumbs = '1' === $atts['showThumbs'] && ! in_array( $atts['preset'], self::NO_THUMBS_DESIGNS, true );
602
603 // "Load more" needs the block in this post's own content (the endpoint looks it up
604 // there) and no infinite loop.
605 $load_more = $result['more'] && $atts['uniqueId'] && $current
606 && ! $atts['infiniteLoop']
607 && ! in_array( $atts['preset'], self::NO_LOAD_MORE_DESIGNS, true )
608 && false !== strpos( (string) get_post_field( 'post_content', $current ), '"uniqueId":"' . $atts['uniqueId'] . '"' );
609
610 $slider_options = $atts['sliderOptions'];
611 if ( is_array( $slider_options ) && isset( self::PRESET_SWIPER_OPTIONS[ $atts['preset'] ] ) ) {
612 foreach ( self::PRESET_SWIPER_OPTIONS[ $atts['preset'] ] as $key => $value ) {
613 if ( null === $value ) {
614 unset( $slider_options[ $key ] );
615 } else {
616 $slider_options[ $key ] = $value;
617 }
618 }
619 }
620
621 $block_classes = array(
622 esc_attr( $atts['uniqueId'] ),
623 esc_attr( $atts['navContainerPosition'] ),
624 esc_attr( $atts['navPosition'] ),
625 $atts['preset'] ? 'gutslider-preset-' . $atts['preset'] : '',
626 'badge' === $atts['catStyle'] ? 'has-category-badges' : '',
627 in_array( $atts['preset'], array( 'featured', 'timeline' ), true ) && self::has_custom_slide_bg( $attributes ) ? 'has-slide-bg' : '',
628 );
629 $block_props = get_block_wrapper_attributes( array( 'class' => implode( ' ', array_filter( $block_classes ) ) ) );
630
631 ob_start();
632 ?>
633 <div <?php echo wp_kses_data( $block_props ); ?> data-swiper-options="<?php echo esc_attr( wp_json_encode( $slider_options ) ); ?>"
634 <?php
635 if ( $show_thumbs ) {
636 echo 'data-thumbs-options="' . esc_attr( wp_json_encode( $atts['thumbsOptions'] ) ) . '" ';
637 }
638 if ( $atts['enableRemoteNav'] ) {
639 echo 'data-rprev="' . esc_attr( $atts['remotePrevSelector'] ) . '" ';
640 echo 'data-rnext="' . esc_attr( $atts['remoteNextSelector'] ) . '" ';
641 }
642 if ( $load_more ) {
643 printf(
644 'data-load-more="%1$s" data-post="%2$d" data-block="%3$s" data-label-loading="%4$s" data-label-loaded="%5$s" ',
645 esc_url( rest_url( 'gutslider/v1/post-slider/more' ) ),
646 (int) $current,
647 esc_attr( $atts['uniqueId'] ),
648 esc_attr__( 'Loading…', 'slider-blocks' ),
649 /* translators: %d: number of posts just added. */
650 esc_attr__( '%d more posts loaded', 'slider-blocks' )
651 );
652 }
653 ?>
654 >
655 <div class="swiper">
656 <div class="swiper-wrapper">
657 <?php echo self::render_slides( $items, $atts, 0 ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
658 </div>
659 <?php
660 if ( '1' === $atts['showNavigation'] && 'nav_inside' === $atts['navContainerPosition'] && '1' !== $atts['enableRemoteNav'] ) {
661 echo self::render_nav( $atts, 'nav_inside' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
662 }
663 if ( '1' === $atts['showPagination'] ) {
664 echo '<div class="swiper-pagination"></div>';
665 }
666 ?>
667 </div>
668 <?php
669 if ( '1' === $atts['showNavigation'] && 'nav_outside' === $atts['navContainerPosition'] && '1' !== $atts['enableRemoteNav'] ) {
670 echo self::render_nav( $atts, 'nav_outside' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
671 }
672 if ( $show_thumbs ) {
673 echo self::render_thumbs( $items, $atts ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
674 }
675 if ( 'featured' === $atts['preset'] ) {
676 echo self::render_list( $items, $atts ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
677 }
678 if ( $load_more ) {
679 echo '<span class="gs-post-more__status screen-reader-text" role="status" aria-live="polite"></span>';
680 }
681 ?>
682 </div>
683 <?php
684 $html = ob_get_clean();
685
686 if ( '1' === $atts['schemaMarkup'] ) {
687 $html .= self::schema( $items );
688 }
689
690 return $html;
691 }
692
693 /**
694 * Slides (also used by the "Load more" endpoint).
695 *
696 * @param array[] $items Slides data.
697 * @param array $atts Normalised attributes.
698 * @param int $start Position of the first slide in the whole list.
699 * @return string HTML.
700 */
701 public static function render_slides( $items, $atts, $start = 0 ) {
702 // Responsive images: slides fill the slider in "slider" mode, share the row otherwise.
703 $image_sizes = 'slider' === $atts['sliderType'] && ! $atts['preset'] ? '100vw' : '(max-width: 767px) 100vw, 50vw';
704
705 // Largest image to use. Browsers still pick smaller ones through srcset.
706 $image_size = in_array( $atts['imageSize'], array( 'medium', 'large', 'full' ), true ) ? $atts['imageSize'] : 'full';
707 $cap_srcset = null;
708 if ( 'full' !== $image_size ) {
709 $cap_width = (int) get_option( $image_size . '_size_w' );
710 $cap_srcset = static function ( $sources ) use ( $cap_width ) {
711 return $cap_width ? array_filter( $sources, static fn( $width ) => $width <= $cap_width, ARRAY_FILTER_USE_KEY ) : $sources;
712 };
713 add_filter( 'wp_calculate_image_srcset', $cap_srcset );
714 }
715
716 $html = '';
717 if ( 'grid' !== $atts['preset'] ) {
718 foreach ( $items as $i => $item ) {
719 $html .= self::render_slide( $item, $atts, $start + $i + 1, $image_size, $image_sizes );
720 }
721 } else {
722 // Overlay Grid slides whole pages of tiles.
723 foreach ( array_chunk( $items, self::GRID_PAGE_SIZE ) as $p => $page ) {
724 $html .= '<div class="swiper-slide gs-post-page is-count-' . count( $page ) . '">';
725 foreach ( $page as $i => $item ) {
726 $html .= self::render_slide( $item, $atts, $start + $p * self::GRID_PAGE_SIZE + $i + 1, $image_size, $image_sizes );
727 }
728 $html .= '</div>';
729 }
730 }
731
732 if ( $cap_srcset ) {
733 remove_filter( 'wp_calculate_image_srcset', $cap_srcset );
734 }
735
736 return $html;
737 }
738
739 /**
740 * One slide.
741 *
742 * @param array $item Slide data.
743 * @param array $atts Normalised attributes.
744 * @param int $index 1-based position.
745 * @param string $image_size Image size.
746 * @param string $image_sizes `sizes` attribute.
747 * @return string HTML.
748 */
749 private static function render_slide( $item, $atts, $index, $image_size, $image_sizes ) {
750 $post_title = $item['title'];
751 $post_link = $item['link'];
752 $post_image_id = $item['image_id'];
753 $post_image = $post_image_id ? wp_get_attachment_image_url( $post_image_id, $image_size ) : '';
754 // The first slide is likely the largest thing on screen: load it right away.
755 $image_loading = 1 === $index ? 'eager' : 'lazy';
756 $has_meta = $atts['showDate'] || $atts['showAuthor'] || $atts['showReadingTime'] || $atts['showComments'];
757 $is_current = $item['is_current'] && '1' === $atts['highlightCurrent'];
758 $badges = 'badge' === $atts['catStyle'];
759 // Badges sit on the image only when there is one; otherwise above the title.
760 $cats_on_image = $badges && $post_image && ! $atts['detachContent'] && in_array( $atts['catPosition'], array( 'top-left', 'top-right' ), true );
761
762 $focus = $atts['focusPoints'][ $index - 1 ] ?? null;
763 $object_position = isset( $focus['x'], $focus['y'] )
764 ? sprintf( 'object-position: %d%% %d%%;', round( $focus['x'] * 100 ), round( $focus['y'] * 100 ) )
765 : 'object-position: center center;';
766
767 $classes = array( 'swiper-slide' );
768 if ( 'slider' === $atts['sliderType'] ) {
769 $classes[] = 'slide-mode';
770 }
771 if ( ! empty( $atts['detachContent'] ) ) {
772 $classes[] = 'detach-content';
773 }
774 if ( ! $post_image && $atts['preset'] ) {
775 $classes[] = 'no-image';
776 }
777 if ( $is_current ) {
778 $classes[] = 'is-current';
779 }
780
781 $categories = '';
782 if ( $atts['showCat'] && ! empty( $item['terms'] ) ) {
783 $categories .= '<div class="post-categories ' . esc_attr( $atts['catAnimation'] ) . ( $cats_on_image ? ' is-on-image is-' . esc_attr( $atts['catPosition'] ) : '' ) . '">';
784 foreach ( $item['terms'] as $term ) {
785 if ( $badges ) {
786 $categories .= sprintf( '<span class="post-category is-badge" style="--gs-badge:%1$s">%2$s</span>', esc_attr( self::term_color( $term ) ), esc_html( $term->name ) );
787 } else {
788 $categories .= sprintf( '<span class="post-category">%s</span>', esc_html( $term->name ) );
789 }
790 }
791 $categories .= '</div>';
792 }
793
794 ob_start();
795 ?>
796 <div class="<?php echo esc_attr( implode( ' ', $classes ) ); ?>"<?php echo $is_current ? ' aria-current="page"' : ''; ?>>
797 <div class="swiper-container-outer">
798 <?php
799 // Featured image as a real, responsive image layer: srcset, lazy loading, focal point.
800 if ( ! $atts['detachContent'] && $post_image ) {
801 echo wp_get_attachment_image( // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
802 $post_image_id,
803 $image_size,
804 false,
805 array(
806 'class' => 'gutslider-post-bg',
807 // Decorative: the title right on top already names the slide.
808 'alt' => '',
809 'sizes' => $image_sizes,
810 'loading' => $image_loading,
811 'decoding' => 'async',
812 'style' => $object_position,
813 )
814 );
815 }
816 ?>
817 <?php if ( $atts['enableOverlay'] ) : ?>
818 <div class="gutslider-overlay"></div>
819 <?php endif; ?>
820 <?php if ( $atts['cardLink'] ) : ?>
821 <?php // Whole slide clickable. Hidden from keyboards and screen readers: the title and button links cover them. ?>
822 <a class="post-card-link" href="<?php echo esc_url( $post_link ); ?>" tabindex="-1" aria-hidden="true"></a>
823 <?php endif; ?>
824 <?php if ( $is_current ) : ?>
825 <span class="post-now-reading"><?php esc_html_e( 'Now reading', 'slider-blocks' ); ?></span>
826 <?php endif; ?>
827 <?php
828 if ( $cats_on_image ) {
829 echo $categories; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
830 }
831 if ( $atts['detachContent'] && $post_image ) {
832 $image_alt = get_post_meta( $post_image_id, '_wp_attachment_image_alt', true );
833 ?>
834 <a class="detached-featured-image" href="<?php echo esc_url( $post_link ); ?>">
835 <?php
836 echo wp_get_attachment_image( // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
837 $post_image_id,
838 $image_size,
839 false,
840 array(
841 // The image is a link to the post, so it needs a name.
842 'alt' => $image_alt ? $image_alt : wp_strip_all_tags( $post_title ),
843 'sizes' => $image_sizes,
844 'loading' => $image_loading,
845 'decoding' => 'async',
846 'style' => $object_position,
847 )
848 );
849 ?>
850 </a>
851 <?php
852 }
853 ?>
854 <div class="gutslider-content-wrapper<?php echo $atts['detachContent'] ? ' detach-content' : ''; ?>">
855 <div class="gutslider-content-inner">
856 <?php
857 if ( ! $cats_on_image ) {
858 echo $categories; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
859 }
860 ?>
861
862 <?php
863 if ( $atts['showTitle'] ) {
864 $title_tag = tag_escape( $atts['titleTag'] );
865 $title_content = sprintf(
866 '<%1$s class="post-title %2$s">%3$s</%1$s>',
867 $title_tag,
868 esc_attr( $atts['titleAnimation'] ),
869 esc_html( $post_title )
870 );
871
872 if ( $atts['linkTitle'] ) {
873 printf(
874 '<a href="%s" class="post-title-link">%s</a>',
875 esc_url( $post_link ),
876 $title_content // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
877 );
878 } else {
879 echo wp_kses_post( $title_content );
880 }
881 }
882 ?>
883
884 <?php if ( $has_meta ) : ?>
885 <div class="post-meta">
886 <?php if ( $atts['showAuthor'] && $item['author'] ) : ?>
887 <span class="post-meta__author">
888 <?php
889 if ( $atts['showAvatar'] ) {
890 echo get_avatar( $item['author_id'], 48, '', '', array( 'class' => 'post-meta__avatar', 'loading' => 'lazy' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
891 }
892 ?>
893 <span><?php echo esc_html( $item['author'] ); ?></span>
894 </span>
895 <?php endif; ?>
896 <?php if ( $atts['showDate'] ) : ?>
897 <time class="post-meta__date" datetime="<?php echo esc_attr( $item['date_iso'] ); ?>"><?php echo esc_html( $item['date'] ); ?></time>
898 <?php endif; ?>
899 <?php if ( $atts['showReadingTime'] ) : ?>
900 <span class="post-meta__reading">
901 <?php
902 /* translators: %d: minutes. */
903 echo esc_html( sprintf( _n( '%d min read', '%d min read', $item['reading'], 'slider-blocks' ), $item['reading'] ) );
904 ?>
905 </span>
906 <?php endif; ?>
907 <?php if ( $atts['showComments'] ) : ?>
908 <span class="post-meta__comments">
909 <?php
910 /* translators: %s: number of comments. */
911 echo esc_html( sprintf( _n( '%s comment', '%s comments', $item['comments'], 'slider-blocks' ), number_format_i18n( $item['comments'] ) ) );
912 ?>
913 </span>
914 <?php endif; ?>
915 </div>
916 <?php endif; ?>
917
918 <?php if ( $atts['showExcerpt'] ) : ?>
919 <div class="post-excerpt <?php echo esc_attr( $atts['exptAnimation'] ); ?>">
920 <?php echo wp_kses_post( $item['excerpt'] ); ?>
921 </div>
922 <?php endif; ?>
923
924 <?php if ( $atts['showBtn'] ) : ?>
925 <div class="post-cta-wrapper <?php echo esc_attr( $atts['btnAnimation'] ); ?>">
926 <a href="<?php echo esc_url( $post_link ); ?>" class="post-cta" aria-label="<?php
927 /* translators: 1: button label, e.g. "Read More"; 2: post title. */
928 echo esc_attr( sprintf( __( '%1$s: %2$s', 'slider-blocks' ), $atts['btnLabel'], wp_strip_all_tags( $post_title ) ) );
929 ?>">
930 <?php echo esc_html( $atts['btnLabel'] ); ?>
931 </a>
932 </div>
933 <?php endif; ?>
934 </div>
935 </div>
936 </div>
937 </div>
938 <?php
939 return (string) ob_get_clean();
940 }
941
942 /**
943 * Arrows.
944 *
945 * @param array $atts Normalised attributes.
946 * @param string $container nav_inside | nav_outside.
947 * @return string HTML.
948 */
949 private static function render_nav( $atts, $container ) {
950 ob_start();
951 ?>
952 <div class="gutslider-nav <?php echo esc_attr( $container . ' ' . $atts['navPosition'] ); ?>">
953 <?php if ( $atts['customNavIcon'] ) : ?>
954 <div class="gutslider-prev">
955 <?php
956 if ( 'library' === $atts['navIconSource'] ) {
957 echo wp_kses( gutslider_display_icon( $atts['navPrevIcon'] ), gutslider_allowed_svg_tags() );
958 } else {
959 echo wp_kses( $atts['customPrevSVG'], gutslider_allowed_svg_tags() );
960 }
961 ?>
962 </div>
963 <div class="gutslider-next">
964 <?php
965 if ( 'library' === $atts['navIconSource'] ) {
966 echo wp_kses( gutslider_display_icon( $atts['navNextIcon'] ), gutslider_allowed_svg_tags() );
967 } else {
968 echo wp_kses( $atts['customNextSVG'], gutslider_allowed_svg_tags() );
969 }
970 ?>
971 </div>
972 <?php else : ?>
973 <div class="swiper-button-prev"></div>
974 <div class="swiper-button-next"></div>
975 <?php endif; ?>
976 </div>
977 <?php
978 return (string) ob_get_clean();
979 }
980
981 /**
982 * Featured + List: the list beside the lead story, which it navigates.
983 *
984 * @param array[] $items Slides data.
985 * @param array $atts Normalised attributes.
986 * @return string HTML.
987 */
988 private static function render_list( $items, $atts ) {
989 ob_start();
990 ?>
991 <div class="gs-post-list" role="group" aria-label="<?php esc_attr_e( 'Choose a story', 'slider-blocks' ); ?>">
992 <?php
993 foreach ( $items as $index => $item ) :
994 $is_current = $item['is_current'] && '1' === $atts['highlightCurrent'];
995 ?>
996 <button type="button" class="gs-post-list__item<?php echo 0 === $index ? ' is-active' : ''; ?><?php echo $is_current ? ' is-current' : ''; ?>"<?php echo 0 === $index ? ' aria-current="true"' : ''; ?>>
997 <?php if ( $item['image_id'] ) : ?>
998 <span class="gs-post-list__thumb">
999 <?php echo wp_get_attachment_image( $item['image_id'], 'thumbnail', false, array( 'alt' => '', 'loading' => 'lazy', 'decoding' => 'async' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
1000 </span>
1001 <?php endif; ?>
1002 <span class="gs-post-list__text">
1003 <?php if ( $atts['showCat'] && ! empty( $item['terms'] ) ) : ?>
1004 <span class="gs-post-list__cat" style="--gs-badge:<?php echo esc_attr( self::term_color( $item['terms'][0] ) ); ?>"><?php echo esc_html( $item['terms'][0]->name ); ?></span>
1005 <?php endif; ?>
1006 <span class="gs-post-list__title"><?php echo esc_html( wp_strip_all_tags( $item['title'] ) ); ?></span>
1007 <?php if ( $is_current ) : ?>
1008 <span class="gs-post-list__now"><?php esc_html_e( 'Now reading', 'slider-blocks' ); ?></span>
1009 <?php endif; ?>
1010 <?php if ( $atts['showDate'] ) : ?>
1011 <time class="gs-post-list__date" datetime="<?php echo esc_attr( $item['date_iso'] ); ?>"><?php echo esc_html( $item['date'] ); ?></time>
1012 <?php endif; ?>
1013 </span>
1014 <span class="gs-post-list__progress" aria-hidden="true"></span>
1015 </button>
1016 <?php endforeach; ?>
1017 </div>
1018 <?php
1019 return (string) ob_get_clean();
1020 }
1021
1022 /**
1023 * Thumbnail strip.
1024 *
1025 * @param array[] $items Slides data.
1026 * @param array $atts Normalised attributes.
1027 * @return string HTML.
1028 */
1029 private static function render_thumbs( $items, $atts ) {
1030 $classes = array( 'swiper', 'gutslider-thumbs', 'thumb-content-' . $atts['thumbContent'] );
1031 if ( 'both' === $atts['thumbContent'] ) {
1032 $classes[] = 'thumb-title-' . $atts['thumbTitlePosition'];
1033 }
1034 $placeholder = GUTSLIDER_URL . 'assets/images/placeholder.svg';
1035
1036 ob_start();
1037 ?>
1038 <div class="<?php echo esc_attr( implode( ' ', $classes ) ); ?>">
1039 <div class="swiper-wrapper">
1040 <?php
1041 foreach ( $items as $item ) {
1042 $thumb_url = $item['image_id'] ? wp_get_attachment_image_url( $item['image_id'], 'medium' ) : '';
1043 $thumb_alt = $item['image_id'] ? get_post_meta( $item['image_id'], '_wp_attachment_image_alt', true ) : '';
1044 ?>
1045 <div class="swiper-slide">
1046 <?php if ( 'title' !== $atts['thumbContent'] ) : ?>
1047 <img src="<?php echo esc_url( $thumb_url ? $thumb_url : $placeholder ); ?>" alt="<?php echo esc_attr( $thumb_alt ); ?>" loading="lazy" decoding="async" />
1048 <?php endif; ?>
1049 <?php if ( 'image' !== $atts['thumbContent'] && $item['title'] ) : ?>
1050 <span class="gutslider-thumb-title"><?php echo wp_kses_post( $item['title'] ); ?></span>
1051 <?php endif; ?>
1052 </div>
1053 <?php
1054 }
1055 ?>
1056 </div>
1057 </div>
1058 <?php
1059 return (string) ob_get_clean();
1060 }
1061
1062 /**
1063 * JSON-LD: the slider as an ItemList of posts.
1064 *
1065 * @param array[] $items Slides data.
1066 * @return string <script> element.
1067 */
1068 public static function schema( $items ) {
1069 $list = array();
1070 foreach ( $items as $position => $item ) {
1071 $entry = array(
1072 '@type' => 'post' === $item['type'] ? 'BlogPosting' : ( 'page' === $item['type'] ? 'WebPage' : 'CreativeWork' ),
1073 'headline' => wp_strip_all_tags( $item['title'] ),
1074 'url' => $item['link'],
1075 'datePublished' => $item['date_iso'],
1076 'dateModified' => $item['modified'],
1077 );
1078 if ( $item['author'] ) {
1079 $entry['author'] = array(
1080 '@type' => 'Person',
1081 'name' => $item['author'],
1082 );
1083 }
1084 if ( $item['image_id'] ) {
1085 $image = wp_get_attachment_image_url( $item['image_id'], 'large' );
1086 if ( $image ) {
1087 $entry['image'] = $image;
1088 }
1089 }
1090 $list[] = array(
1091 '@type' => 'ListItem',
1092 'position' => $position + 1,
1093 'item' => $entry,
1094 );
1095 }
1096
1097 $data = array(
1098 '@context' => 'https://schema.org',
1099 '@type' => 'ItemList',
1100 'itemListElement' => $list,
1101 );
1102
1103 return '<script type="application/ld+json">' . wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP ) . '</script>';
1104 }
1105
1106 /* ---------------------------------------------------------------------
1107 * "Load more" endpoint
1108 * ------------------------------------------------------------------ */
1109
1110 /**
1111 * GET gutslider/v1/post-slider/more?post=ID&block=UNIQUE_ID&page=N
1112 *
1113 * Public and read only. The block's settings are read from the post it was saved
1114 * in, never taken from the request, and only published, unprotected posts count.
1115 */
1116 public function register_routes() {
1117 register_rest_route(
1118 'gutslider/v1',
1119 '/post-slider/more',
1120 array(
1121 'methods' => 'GET',
1122 'callback' => array( $this, 'more' ),
1123 'permission_callback' => '__return_true',
1124 'args' => array(
1125 'post' => array(
1126 'type' => 'integer',
1127 'required' => true,
1128 'minimum' => 1,
1129 ),
1130 'block' => array(
1131 'type' => 'string',
1132 'required' => true,
1133 'sanitize_callback' => 'sanitize_html_class',
1134 ),
1135 'page' => array(
1136 'type' => 'integer',
1137 'required' => true,
1138 'minimum' => 1,
1139 'maximum' => 50,
1140 ),
1141 ),
1142 )
1143 );
1144 }
1145
1146 /**
1147 * Next slides.
1148 *
1149 * @param \WP_REST_Request $request Request.
1150 * @return \WP_REST_Response|\WP_Error
1151 */
1152 public function more( $request ) {
1153 $post = get_post( (int) $request['post'] );
1154 if ( ! $post || 'publish' !== $post->post_status || post_password_required( $post ) || ! is_post_type_viewable( $post->post_type ) ) {
1155 return new \WP_Error( 'gutslider_not_found', __( 'Not found.', 'slider-blocks' ), array( 'status' => 404 ) );
1156 }
1157
1158 $attributes = self::find_block( parse_blocks( $post->post_content ), (string) $request['block'] );
1159 if ( ! $attributes || empty( $attributes['loadMore'] ) ) {
1160 return new \WP_Error( 'gutslider_not_found', __( 'Not found.', 'slider-blocks' ), array( 'status' => 404 ) );
1161 }
1162
1163 // Render as if on that post (related posts, "now reading", excluded current post).
1164 $GLOBALS['post'] = $post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
1165 setup_postdata( $post );
1166
1167 $page = (int) $request['page'];
1168 $result = self::query_ids( $attributes, $post->ID, $page );
1169 $items = self::items( $result['ids'], $attributes, $post->ID );
1170 $atts = self::normalize( $attributes );
1171 $per = max( 1, (int) self::query_args( $attributes, $post->ID )['posts_per_page'] );
1172 $html = self::render_slides( $items, $atts, $page * $per );
1173
1174 wp_reset_postdata();
1175
1176 return rest_ensure_response(
1177 array(
1178 'html' => $html,
1179 'count' => count( $items ),
1180 'more' => $result['more'],
1181 )
1182 );
1183 }
1184
1185 /**
1186 * Attributes of the Blog Post block with this unique id.
1187 *
1188 * @param array $blocks Parsed blocks.
1189 * @param string $unique_id Block unique id.
1190 * @return array|null
1191 */
1192 private static function find_block( $blocks, $unique_id ) {
1193 foreach ( $blocks as $block ) {
1194 if ( 'gutsliders/post-slider' === $block['blockName'] && ( $block['attrs']['uniqueId'] ?? '' ) === $unique_id ) {
1195 return $block['attrs'];
1196 }
1197 if ( ! empty( $block['innerBlocks'] ) ) {
1198 $found = self::find_block( $block['innerBlocks'], $unique_id );
1199 if ( $found ) {
1200 return $found;
1201 }
1202 }
1203 }
1204 return null;
1205 }
1206
1207 /* ---------------------------------------------------------------------
1208 * Cache
1209 * ------------------------------------------------------------------ */
1210
1211 /**
1212 * Current cache version.
1213 *
1214 * @return string
1215 */
1216 private static function cache_version() {
1217 return (string) get_option( self::CACHE_VERSION_OPTION, '1' );
1218 }
1219
1220 /**
1221 * Invalidate every cached slider result (old entries simply expire).
1222 */
1223 public static function flush_cache() {
1224 update_option( self::CACHE_VERSION_OPTION, (string) microtime( true ), true );
1225 }
1226
1227 /**
1228 * Publishing, unpublishing or scheduling changes what sliders show.
1229 *
1230 * @param string $new_status New status.
1231 * @param string $old_status Old status.
1232 */
1233 public function on_status_change( $new_status, $old_status ) {
1234 if ( $new_status !== $old_status && ( 'publish' === $new_status || 'publish' === $old_status ) ) {
1235 self::flush_cache();
1236 }
1237 }
1238
1239 /**
1240 * A featured image change matters ("Only posts with a featured image").
1241 *
1242 * @param mixed $meta_ids Meta ids.
1243 * @param int $object_id Post id.
1244 * @param string $meta_key Meta key.
1245 */
1246 public function on_meta_change( $meta_ids, $object_id, $meta_key ) {
1247 if ( '_thumbnail_id' === $meta_key ) {
1248 self::flush_cache();
1249 }
1250 }
1251 }
1252