array( 'effect' => 'fade', 'fadeEffect' => array( 'crossFade' => true ), 'slidesPerView' => 1, 'spaceBetween' => 0, 'breakpoints' => null, ), 'cards' => array( 'effect' => 'slide', 'grabCursor' => true, ), 'grid' => array( 'effect' => 'slide', 'slidesPerView' => 1, 'spaceBetween' => 24, 'grabCursor' => true, 'breakpoints' => null, ), 'featured' => array( 'effect' => 'fade', 'fadeEffect' => array( 'crossFade' => true ), 'slidesPerView' => 1, 'spaceBetween' => 0, 'breakpoints' => null, ), 'timeline' => array( 'effect' => 'slide', 'grabCursor' => true, ), ); /** All designs ('' = the original slider). */ const DESIGNS = array( 'magazine', 'cards', 'grid', 'featured', 'timeline' ); /** Option holding the cache version; bumped whenever posts change. */ const CACHE_VERSION_OPTION = 'gutslider_post_slider_cache_v'; /** Fallback badge colours, picked by term id (same list as the editor). */ const BADGE_PALETTE = array( '#2563eb', '#db2777', '#059669', '#d97706', '#7c3aed', '#dc2626', '#0891b2', '#4f46e5' ); /** * Constructor. */ public function __construct() { add_action( 'rest_api_init', array( $this, 'register_routes' ) ); // Anything that can change what a slider shows invalidates cached results. 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 ) { add_action( $hook, array( __CLASS__, 'flush_cache' ) ); } add_action( 'transition_post_status', array( $this, 'on_status_change' ), 10, 3 ); add_action( 'updated_post_meta', array( $this, 'on_meta_change' ), 10, 3 ); add_action( 'added_post_meta', array( $this, 'on_meta_change' ), 10, 3 ); add_action( 'deleted_post_meta', array( $this, 'on_meta_change' ), 10, 3 ); } /* --------------------------------------------------------------------- * Attributes * ------------------------------------------------------------------ */ /** * Attributes with defaults; scalars sanitised as text (booleans become '1' / ''). * * @param array $attributes Block attributes. * @return array */ public static function normalize( $attributes ) { $defaults = array( 'showTitle' => true, 'showCat' => true, 'showExcerpt' => true, 'showBtn' => true, 'linkTitle' => true, 'titleTag' => 'h2', 'btnLabel' => __( 'Read More', 'slider-blocks' ), 'enableOverlay' => true, 'sliderType' => 'slider', 'uniqueId' => '', 'navContainerPosition' => 'nav_inside', 'navPosition' => 'nav_cc', 'catAnimation' => '', 'titleAnimation' => '', 'exptAnimation' => '', 'btnAnimation' => '', 'showNavigation' => true, 'showPagination' => true, 'customNavIcon' => false, 'navIconSource' => 'library', 'navPrevIcon' => 'arrowLeft', 'navNextIcon' => 'arrowRight', 'customPrevSVG' => '', 'customNextSVG' => '', 'excerptLength' => 20, 'enableRemoteNav' => false, 'remotePrevSelector' => '', 'remoteNextSelector' => '', 'detachContent' => false, 'showThumbs' => false, 'thumbContent' => 'image', 'thumbTitlePosition' => 'below', 'hideWhenEmpty' => true, 'emptyMessage' => '', 'showDate' => false, 'showAuthor' => false, 'showAvatar' => false, 'showReadingTime' => false, 'showComments' => false, 'cardLink' => false, 'imageSize' => 'full', 'preset' => '', 'catStyle' => '', 'catPosition' => 'above-title', 'highlightCurrent' => false, 'schemaMarkup' => false, 'loadMore' => false, 'loadMoreMax' => 30, 'infiniteLoop' => true, ); $atts = wp_parse_args( $attributes, $defaults ); foreach ( $atts as $key => $value ) { if ( is_scalar( $value ) || null === $value ) { $atts[ $key ] = sanitize_text_field( (string) $value ); } } $atts['sliderOptions'] = isset( $attributes['sliderOptions'] ) ? $attributes['sliderOptions'] : array(); $atts['thumbsOptions'] = isset( $attributes['thumbsOptions'] ) ? $attributes['thumbsOptions'] : array(); $atts['focusPoints'] = isset( $attributes['focusPoints'] ) && is_array( $attributes['focusPoints'] ) ? $attributes['focusPoints'] : array(); $atts['preset'] = in_array( $atts['preset'], self::DESIGNS, true ) ? $atts['preset'] : ''; return $atts; } /** * Post type to query: any public post type, 'post' otherwise. * * @param array $attributes Block attributes. * @return string */ public static function post_type( $attributes ) { $type = isset( $attributes['postType'] ) ? sanitize_key( $attributes['postType'] ) : 'post'; return ( $type && post_type_exists( $type ) && is_post_type_viewable( $type ) && 'attachment' !== $type ) ? $type : 'post'; } /** * Taxonomies of a post type that can filter or label slides. * * @param string $post_type Post type. * @return string[] */ public static function taxonomies_for( $post_type ) { $names = array(); foreach ( get_object_taxonomies( $post_type, 'objects' ) as $taxonomy ) { if ( $taxonomy->public && $taxonomy->show_ui && 'post_format' !== $taxonomy->name ) { $names[] = $taxonomy->name; } } return $names; } /** * Taxonomies whose terms label a slide: the ones the slider is filtered by, else the * post type's categories (core `category`, or its first hierarchical taxonomy). * * @param array $attributes Block attributes. * @return string[] */ public static function label_taxonomies( $attributes ) { $query_type = isset( $attributes['queryType'] ) ? sanitize_key( $attributes['queryType'] ) : 'latest_posts'; if ( 'specific_categories' === $query_type && ! empty( $attributes['taxQuery'] ) && is_array( $attributes['taxQuery'] ) ) { return array_map( 'sanitize_key', array_keys( $attributes['taxQuery'] ) ); } $post_type = self::post_type( $attributes ); $taxonomies = self::taxonomies_for( $post_type ); // The post type's own categories first (e.g. product_cat), then any hierarchical one. foreach ( array( 'category', $post_type . '_cat', $post_type . '_category' ) as $preferred ) { if ( in_array( $preferred, $taxonomies, true ) ) { return array( $preferred ); } } foreach ( $taxonomies as $taxonomy ) { if ( is_taxonomy_hierarchical( $taxonomy ) ) { return array( $taxonomy ); } } return $taxonomies ? array( $taxonomies[0] ) : array(); } /* --------------------------------------------------------------------- * Query * ------------------------------------------------------------------ */ /** * WP_Query arguments from the block's saved query settings. * * @param array $attributes Block attributes. * @param int $current Post the slider is shown on (0 if none). * @return array */ public static function query_args( $attributes, $current = 0 ) { $query_type = isset( $attributes['queryType'] ) ? sanitize_key( $attributes['queryType'] ) : 'latest_posts'; $post_type = self::post_type( $attributes ); $total = isset( $attributes['totalPosts'] ) ? (int) $attributes['totalPosts'] : 3; $orderby = isset( $attributes['postOrderBy'] ) ? sanitize_key( $attributes['postOrderBy'] ) : 'date'; $order = isset( $attributes['postOrder'] ) && 'asc' === strtolower( (string) $attributes['postOrder'] ) ? 'ASC' : 'DESC'; $args = array( 'post_type' => $post_type, 'post_status' => 'publish', // An empty "Total Posts" meant every post; cap it like the REST API does. 'posts_per_page' => $total > 0 ? min( $total, 100 ) : 100, 'orderby' => in_array( $orderby, array( 'date', 'title', 'modified', 'comment_count', 'rand' ), true ) ? $orderby : 'date', 'order' => $order, 'ignore_sticky_posts' => true, 'no_found_rows' => true, ); $is_specific = 'specific_posts' === $query_type && ! empty( $attributes['specificPosts'] ) && is_array( $attributes['specificPosts'] ); $not_in = array(); // Skip the first N posts (not for hand-picked posts). $offset = isset( $attributes['postOffset'] ) ? absint( $attributes['postOffset'] ) : 0; if ( $offset && ! $is_specific ) { $args['offset'] = $offset; } // Leave out the post this slider is shown on (always, in Related mode). if ( $current && ( ! empty( $attributes['excludeCurrent'] ) || 'related' === $query_type ) ) { $not_in[] = (int) $current; } // Sticky posts: normal place (default), first (see self::query_ids()), or hidden. if ( ! $is_specific && isset( $attributes['stickyPosts'] ) && 'exclude' === $attributes['stickyPosts'] ) { $not_in = array_merge( $not_in, array_map( 'absint', (array) get_option( 'sticky_posts', array() ) ) ); } if ( $not_in ) { $args['post__not_in'] = array_values( array_unique( $not_in ) ); // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in } if ( ! empty( $attributes['onlyWithImage'] ) ) { $args['meta_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query array( 'key' => '_thumbnail_id', 'compare' => 'EXISTS', ), ); } $tax_query = array(); if ( $is_specific ) { $ids = array_filter( array_map( 'absint', wp_list_pluck( $attributes['specificPosts'], 'value' ) ) ); if ( $ids ) { $args['post__in'] = $ids; $args['posts_per_page'] = count( $ids ); } } if ( 'specific_categories' === $query_type ) { // `taxQuery` ({ taxonomy: [{ label, value }] }) covers every taxonomy; // `specificCategories` is the legacy core-category selection. $selected = isset( $attributes['taxQuery'] ) && is_array( $attributes['taxQuery'] ) && ! empty( $attributes['taxQuery'] ) ? $attributes['taxQuery'] : ( ! empty( $attributes['specificCategories'] ) && is_array( $attributes['specificCategories'] ) ? array( 'category' => $attributes['specificCategories'] ) : array() ); foreach ( $selected as $taxonomy => $terms ) { $taxonomy = sanitize_key( $taxonomy ); $term_ids = is_array( $terms ) ? array_filter( array_map( 'absint', wp_list_pluck( $terms, 'value' ) ) ) : array(); if ( $term_ids && taxonomy_exists( $taxonomy ) ) { $tax_query[] = array( 'taxonomy' => $taxonomy, 'field' => 'term_id', 'terms' => $term_ids, ); } } } // Related: posts sharing any of the current post's terms. if ( 'related' === $query_type && $current ) { $related = array(); foreach ( self::related_taxonomies( $attributes, $post_type ) as $taxonomy ) { $term_ids = wp_get_post_terms( $current, $taxonomy, array( 'fields' => 'ids' ) ); if ( $term_ids && ! is_wp_error( $term_ids ) ) { $related[] = array( 'taxonomy' => $taxonomy, 'field' => 'term_id', 'terms' => $term_ids, ); } } if ( $related ) { $tax_query[] = array_merge( array( 'relation' => 'OR' ), $related ); } } // Leave out posts in these terms, for any query but hand-picked posts. if ( ! $is_specific && ! empty( $attributes['excludeTerms'] ) && is_array( $attributes['excludeTerms'] ) ) { foreach ( $attributes['excludeTerms'] as $taxonomy => $terms ) { $taxonomy = sanitize_key( $taxonomy ); $term_ids = is_array( $terms ) ? array_filter( array_map( 'absint', wp_list_pluck( $terms, 'value' ) ) ) : array(); if ( $term_ids && taxonomy_exists( $taxonomy ) ) { $tax_query[] = array( 'taxonomy' => $taxonomy, 'field' => 'term_id', 'terms' => $term_ids, 'operator' => 'NOT IN', ); } } } if ( $tax_query ) { // Every group must match (same as the REST query the editor previews with). $args['tax_query'] = array_merge( array( 'relation' => 'AND' ), $tax_query ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query } /** * Filters the query behind a Blog Post slider. * * @param array $args WP_Query arguments. * @param array $attributes Block attributes. */ return apply_filters( 'gutslider_post_slider_query_args', $args, $attributes ); } /** * Taxonomies Related mode matches on. * * @param array $attributes Block attributes. * @param string $post_type Post type. * @return string[] */ public static function related_taxonomies( $attributes, $post_type ) { $available = self::taxonomies_for( $post_type ); if ( 'post' === $post_type ) { $by = isset( $attributes['relatedBy'] ) ? sanitize_key( $attributes['relatedBy'] ) : 'both'; $wanted = 'category' === $by ? array( 'category' ) : ( 'post_tag' === $by ? array( 'post_tag' ) : array( 'category', 'post_tag' ) ); return array_values( array_intersect( $wanted, $available ) ); } return $available; } /** * Post ids for a page of the slider (page 0 = what the page shows first), * cached until any post, term, comment or sticky post changes. * * @param array $attributes Block attributes. * @param int $current Post the slider is shown on. * @param int $page Page (for "Load more"). * @return array { ids: int[], more: bool } */ public static function query_ids( $attributes, $current = 0, $page = 0 ) { $args = self::query_args( $attributes, $current ); $per_page = (int) $args['posts_per_page']; $offset = isset( $args['offset'] ) ? (int) $args['offset'] : 0; $sticky = isset( $attributes['stickyPosts'] ) && 'first' === $attributes['stickyPosts'] && empty( $args['post__in'] ); $max = ! empty( $attributes['loadMore'] ) ? max( $per_page, min( 100, absint( $attributes['loadMoreMax'] ?? 30 ) ) ) : $per_page; // Hand-picked posts have no further pages. if ( $page > 0 && ! empty( $args['post__in'] ) && ! $sticky ) { return array( 'ids' => array(), 'more' => false ); } $use_cache = 'rand' !== $args['orderby'] && apply_filters( 'gutslider_post_slider_use_cache', true, $attributes ); $cache_key = 'gs_ps_' . md5( wp_json_encode( array( $args, $sticky, $page, $max, self::cache_version() ) ) ); if ( $use_cache ) { $cached = get_transient( $cache_key ); if ( is_array( $cached ) && isset( $cached['ids'] ) ) { return $cached; } } $start = $offset + $page * $per_page; $want = $per_page + 1; // One extra tells whether there is more. if ( $sticky ) { $sticky_ids = array_filter( array_map( 'absint', (array) get_option( 'sticky_posts', array() ) ) ); $lookup = array_merge( $args, array( 'fields' => 'ids', 'offset' => 0, 'posts_per_page' => $start + $want, 'suppress_filters' => false ) ); $stickies = $sticky_ids ? get_posts( array_merge( $lookup, array( 'post__in' => $sticky_ids ) ) ) : array(); $others = get_posts( array_merge( $lookup, array( 'post__not_in' => array_merge( $args['post__not_in'] ?? array(), $sticky_ids ) ) ) ); $ids = array_slice( array_merge( $stickies, $others ), $start, $want ); } else { $query = new \WP_Query( array_merge( $args, array( 'fields' => 'ids', 'offset' => $start, 'posts_per_page' => $want ) ) ); $ids = array_map( 'intval', $query->posts ); } $more = count( $ids ) > $per_page; $ids = array_slice( $ids, 0, $per_page ); // "Load more" stops at the chosen maximum. $shown_after = ( $page + 1 ) * $per_page; if ( $shown_after >= $max ) { $ids = array_slice( $ids, 0, max( 0, $max - $page * $per_page ) ); $more = false; } $result = array( 'ids' => array_map( 'intval', $ids ), 'more' => $more && ! empty( $attributes['loadMore'] ), ); if ( $use_cache ) { set_transient( $cache_key, $result, (int) apply_filters( 'gutslider_post_slider_cache_ttl', 12 * HOUR_IN_SECONDS ) ); } return $result; } /** * Everything a slide needs, for the given post ids (in that order). * * @param int[] $ids Post ids. * @param array $attributes Block attributes. * @param int $current Post the slider is shown on. * @return array[] */ public static function items( $ids, $attributes, $current = 0 ) { if ( ! $ids ) { return array(); } $taxonomies = self::label_taxonomies( $attributes ); $excerpt_length = isset( $attributes['excerptLength'] ) ? (int) $attributes['excerptLength'] : 20; // Plain text only, but keep a leading space (" Read more"). $excerpt_ending = isset( $attributes['excerptEnding'] ) ? wp_kses( (string) $attributes['excerptEnding'], array() ) : '...'; $query = new \WP_Query( array( 'post_type' => 'any', 'post_status' => 'publish', 'post__in' => $ids, 'orderby' => 'post__in', 'posts_per_page' => count( $ids ), 'ignore_sticky_posts' => true, 'no_found_rows' => true, ) ); $items = array(); while ( $query->have_posts() ) { $query->the_post(); $post_id = get_the_ID(); $terms = array(); foreach ( $taxonomies as $taxonomy ) { $post_terms = get_the_terms( $post_id, $taxonomy ); if ( $post_terms && ! is_wp_error( $post_terms ) ) { $terms = array_merge( $terms, $post_terms ); } } // Excerpt: the post's own excerpt, or its opening words when it has none, // cut to the chosen length with the chosen ending. Protected posts keep it private. if ( post_password_required() ) { $excerpt = ''; } elseif ( $excerpt_length > 0 ) { $source = has_excerpt() ? get_the_excerpt() : wp_strip_all_tags( excerpt_remove_blocks( strip_shortcodes( get_the_content() ) ) ); $excerpt = wp_trim_words( $source, $excerpt_length, $excerpt_ending ); } else { $excerpt = apply_filters( 'the_excerpt', get_the_excerpt() ); } $author_id = (int) get_post_field( 'post_author', $post_id ); $words = count( preg_split( '/\s+/u', trim( wp_strip_all_tags( strip_shortcodes( get_the_content() ) ) ), -1, PREG_SPLIT_NO_EMPTY ) ); $items[] = array( 'id' => $post_id, 'type' => get_post_type(), 'title' => get_the_title(), 'excerpt' => $excerpt, 'link' => get_permalink(), 'image_id' => (int) get_post_thumbnail_id(), 'terms' => $terms, 'date' => get_the_date(), 'date_iso' => get_the_date( 'c' ), 'modified' => get_the_modified_date( 'c' ), 'author' => $author_id ? get_the_author_meta( 'display_name', $author_id ) : '', 'author_id' => $author_id, // About 200 words a minute; at least one minute. 'reading' => max( 1, (int) ceil( $words / 200 ) ), 'comments' => (int) get_comments_number(), 'is_current' => $current && $post_id === (int) $current, ); } wp_reset_postdata(); return $items; } /** * Whether the slide background was chosen by the user (not the untouched #efefef default). * Featured + List and Timeline only show a chosen one, as a card. Keep in sync with * hasCustomSlideBg() in presets.js. * * @param array $attributes Block attributes. * @return bool */ public static function has_custom_slide_bg( $attributes ) { if ( isset( $attributes['bgType'] ) && 'gradient' === $attributes['bgType'] ) { return ! empty( $attributes['bgGradient'] ); } if ( ! empty( $attributes['bgImage']['url'] ) ) { return true; } $color = isset( $attributes['bgColor'] ) ? strtolower( (string) $attributes['bgColor'] ) : ''; return '' !== $color && '#efefef' !== $color; } /** * Badge colour of a term: its own (set on the term screen), else one from the palette. * * @param \WP_Term $term Term. * @return string Hex colour. */ public static function term_color( $term ) { $color = sanitize_hex_color( (string) get_term_meta( $term->term_id, 'gutslider_color', true ) ); return $color ? $color : self::BADGE_PALETTE[ $term->term_id % count( self::BADGE_PALETTE ) ]; } /* --------------------------------------------------------------------- * Markup * ------------------------------------------------------------------ */ /** * Post the slider is shown on (the post whose content or template holds it). * * @return int */ public static function current_post_id() { $id = get_the_ID(); if ( ! $id && is_singular() ) { $id = get_queried_object_id(); } return (int) $id; } /** * Whole block. * * @param array $attributes Block attributes. * @return string HTML. */ public static function render( $attributes ) { $atts = self::normalize( $attributes ); $current = self::current_post_id(); $result = self::query_ids( $attributes, $current ); $items = self::items( $result['ids'], $attributes, $current ); // Nothing matched: hide the block (default), or say so. if ( empty( $items ) ) { if ( $atts['hideWhenEmpty'] ) { return ''; } return sprintf( '