$max ) { return (int) $max; } return $value; } /** * Pick a value from an allowlist. * * @param mixed $value Raw value. * @param string[] $allowed Allowed values. * @param string $fallback Fallback. * @return string */ public static function pickFrom( $value, array $allowed, $fallback ) { $value = is_string( $value ) ? trim( $value ) : ''; return in_array( $value, $allowed, true ) ? $value : $fallback; } /** * Sanitize a comma-separated list of positive integers to an array of ints. * * @param mixed $value Raw comma-separated string. * @return int[] */ public static function parseIdList( $value ) { if ( is_array( $value ) ) { $parts = $value; } else { $parts = explode( ',', (string) $value ); } $ids = array_map( 'absint', $parts ); $ids = array_filter( $ids ); return array_values( array_unique( $ids ) ); } /** * Map an aspect-ratio token to a CSS aspect-ratio value. * * @param string $ratio Ratio token. * @return string */ public static function ratioToCss( $ratio ) { $map = [ 'auto' => 'auto', '1-1' => '1 / 1', '4-3' => '4 / 3', '16-9' => '16 / 9', '3-2' => '3 / 2', ]; return $map[ $ratio ] ?? '16 / 9'; } /** * Normalize and sanitize the full attribute set into a safe, typed array. * * @param array $attributes Raw block attributes. * @return array */ public static function resolveAttributes( array $attributes ) { $postTypeRaw = isset( $attributes['postType'] ) ? sanitize_key( (string) $attributes['postType'] ) : 'post'; $postType = post_type_exists( $postTypeRaw ) ? $postTypeRaw : 'post'; $filterLabel = isset( $attributes['filterLabel'] ) ? wp_strip_all_tags( (string) $attributes['filterLabel'] ) : ''; $filterLabel = '' !== trim( $filterLabel ) ? $filterLabel : __( 'All', 'b-blocks' ); $loadMoreLabel = isset( $attributes['loadMoreLabel'] ) ? wp_strip_all_tags( (string) $attributes['loadMoreLabel'] ) : ''; $loadMoreLabel = '' !== trim( $loadMoreLabel ) ? $loadMoreLabel : __( 'Load More', 'b-blocks' ); $readMoreLabel = isset( $attributes['readMoreLabel'] ) ? wp_strip_all_tags( (string) $attributes['readMoreLabel'] ) : ''; $readMoreLabel = '' !== trim( $readMoreLabel ) ? $readMoreLabel : __( 'Read More', 'b-blocks' ); $noPostsText = isset( $attributes['noPostsText'] ) ? wp_strip_all_tags( (string) $attributes['noPostsText'] ) : ''; $noPostsText = '' !== trim( $noPostsText ) ? $noPostsText : __( 'No posts found.', 'b-blocks' ); return [ 'postType' => $postType, 'postsPerPage' => self::clampInt( $attributes['postsPerPage'] ?? 9, 1, 48, 9 ), 'orderBy' => self::pickFrom( $attributes['orderBy'] ?? 'date', self::ORDERBY, 'date' ), 'order' => self::pickFrom( strtoupper( (string) ( $attributes['order'] ?? 'DESC' ) ), self::ORDER, 'DESC' ), 'categoryIds' => self::parseIdList( $attributes['categoryIds'] ?? [] ), 'tagIds' => self::parseIdList( $attributes['tagIds'] ?? [] ), 'excludeIds' => self::parseIdList( $attributes['excludeIds'] ?? '' ), 'filterEnabled' => ! isset( $attributes['filterEnabled'] ) || (bool) $attributes['filterEnabled'], 'filterLabel' => $filterLabel, 'loadMoreEnabled' => ! isset( $attributes['loadMoreEnabled'] ) || (bool) $attributes['loadMoreEnabled'], 'loadMoreLabel' => $loadMoreLabel, 'loadMoreStep' => self::clampInt( $attributes['loadMoreStep'] ?? 6, 1, 24, 6 ), 'cardStyle' => self::pickFrom( $attributes['cardStyle'] ?? 'image-top', self::CARD_STYLES, 'image-top' ), 'imageRatio' => self::pickFrom( $attributes['imageRatio'] ?? '16-9', self::RATIOS, '16-9' ), 'imageSize' => self::pickFrom( $attributes['imageSize'] ?? 'large', self::IMAGE_SIZES, 'large' ), 'showExcerpt' => ! isset( $attributes['showExcerpt'] ) || (bool) $attributes['showExcerpt'], 'excerptLength' => self::clampInt( $attributes['excerptLength'] ?? 20, 5, 60, 20 ), 'showDate' => ! isset( $attributes['showDate'] ) || (bool) $attributes['showDate'], 'showAuthor' => ! empty( $attributes['showAuthor'] ), 'showCategory' => ! isset( $attributes['showCategory'] ) || (bool) $attributes['showCategory'], 'showReadMore' => ! isset( $attributes['showReadMore'] ) || (bool) $attributes['showReadMore'], 'readMoreLabel' => $readMoreLabel, 'colsDesktop' => self::clampInt( $attributes['colsDesktop'] ?? 3, 1, 5, 3 ), 'colsTablet' => self::clampInt( $attributes['colsTablet'] ?? 2, 1, 4, 2 ), 'colsMobile' => self::clampInt( $attributes['colsMobile'] ?? 1, 1, 2, 1 ), 'gap' => self::clampInt( $attributes['gap'] ?? 24, 0, 80, 24 ), 'maxWidth' => self::clampInt( $attributes['maxWidth'] ?? 0, 0, 1600, 0 ), 'align' => self::pickFrom( $attributes['align'] ?? 'center', self::ALIGNMENTS, 'center' ), 'cardBg' => self::sanitizeColor( $attributes['cardBg'] ?? '', '#ffffff' ), 'cardRadius' => self::clampInt( $attributes['cardRadius'] ?? 8, 0, 32, 8 ), 'cardShadow' => self::pickFrom( $attributes['cardShadow'] ?? 'small', self::SHADOWS, 'small' ), 'accentColor' => self::sanitizeColor( $attributes['accentColor'] ?? '', '#2563eb' ), 'titleColor' => self::sanitizeColor( $attributes['titleColor'] ?? '', 'inherit' ), 'metaColor' => self::sanitizeColor( $attributes['metaColor'] ?? '', 'inherit' ), 'excerptColor' => self::sanitizeColor( $attributes['excerptColor'] ?? '', 'inherit' ), 'titleFontSize' => self::clampInt( $attributes['titleFontSize'] ?? 18, 12, 40, 18 ), 'titleFontWeight' => self::pickFrom( (string) ( $attributes['titleFontWeight'] ?? '600' ), self::FONT_WEIGHTS, '600' ), 'metaFontSize' => self::clampInt( $attributes['metaFontSize'] ?? 13, 10, 20, 13 ), 'excerptFontSize' => self::clampInt( $attributes['excerptFontSize'] ?? 14, 10, 22, 14 ), 'overlayColor' => self::sanitizeColor( $attributes['overlayColor'] ?? '', 'rgba(0,0,0,0.45)' ), 'filterBarAlignment' => self::pickFrom( $attributes['filterBarAlignment'] ?? 'left', self::ALIGNMENTS, 'left' ), 'filterBarGap' => self::clampInt( $attributes['filterBarGap'] ?? 8, 0, 32, 8 ), 'filterActiveBg' => self::sanitizeColor( $attributes['filterActiveBg'] ?? '', '' ), 'filterActiveText' => self::sanitizeColor( $attributes['filterActiveText'] ?? '', '#ffffff' ), 'filterInactiveBg' => self::sanitizeColor( $attributes['filterInactiveBg'] ?? '', '#f3f4f6' ), 'filterInactiveText' => self::sanitizeColor( $attributes['filterInactiveText'] ?? '', '#374151' ), 'filterPillRadius' => self::clampInt( $attributes['filterPillRadius'] ?? 9999, 0, 9999, 9999 ), 'loadMoreBg' => self::sanitizeColor( $attributes['loadMoreBg'] ?? '', '' ), 'loadMoreText' => self::sanitizeColor( $attributes['loadMoreText'] ?? '', '#ffffff' ), 'loadMoreRadius' => self::clampInt( $attributes['loadMoreRadius'] ?? 6, 0, 50, 6 ), 'noPostsText' => $noPostsText, ]; } /* ---------------------------------------------------------------------- * Query * ------------------------------------------------------------------- */ /** * Build sanitized WP_Query args. * * @param array $a Resolved attributes. * @param int $paged Page number (1-based). * @return array */ public static function buildQueryArgs( array $a, $paged ) { $paged = max( 1, absint( $paged ) ); $args = [ 'post_type' => $a['postType'], 'posts_per_page' => $a['postsPerPage'], 'paged' => $paged, 'post_status' => 'publish', 'orderby' => $a['orderBy'], 'order' => $a['order'], 'has_password' => false, 'ignore_sticky_posts' => true, ]; if ( ! empty( $a['excludeIds'] ) ) { $args['post__not_in'] = $a['excludeIds']; } $taxQuery = []; if ( ! empty( $a['categoryIds'] ) && taxonomy_exists( 'category' ) ) { $taxQuery[] = [ 'taxonomy' => 'category', 'field' => 'term_id', 'terms' => $a['categoryIds'], ]; } if ( ! empty( $a['tagIds'] ) && taxonomy_exists( 'post_tag' ) ) { $taxQuery[] = [ 'taxonomy' => 'post_tag', 'field' => 'term_id', 'terms' => $a['tagIds'], ]; } if ( ! empty( $taxQuery ) ) { if ( count( $taxQuery ) > 1 ) { $taxQuery['relation'] = 'AND'; } $args['tax_query'] = $taxQuery; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query } return $args; } /* ---------------------------------------------------------------------- * Card rendering * ------------------------------------------------------------------- */ /** * Render the cards for a query as an escaped HTML fragment. * * Each card is an `
  • ` so the container can be a `