PluginProbe
bBlocks – Essential Gutenberg Blocks & Patterns Collection / trunk
bBlocks – Essential Gutenberg Blocks & Patterns Collection vtrunk
2.1.6 2.1.5 2.1.4 2.1.3 2.1.2 2.1.1 2.1.0 2.0.43 2.0.42 2.0.41 2.0.40 2.0.39 2.0.38 trunk 1.0 1.1 1.2 1.3 1.4 1.5 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 All 106 releases
b-blocks / includes / blocks / post-filter-grid / PostFilterGrid.php

PostFilterGrid.php in bBlocks – Essential Gutenberg Blocks & Patterns Collection trunk, at includes/blocks/post-filter-grid/PostFilterGrid.php

452 lines 16.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Post Filter Grid — shared server logic.
4 *
5 * Provides attribute sanitization, query building, card markup rendering, and a
6 * hardened `admin-ajax.php` endpoint (`bb_pfg_query`) used by the frontend
7 * `view.js` to swap cards on filter / Load More. The same card renderer is used
8 * by `render.php` (initial server render) and by the AJAX handler so markup is
9 * identical and escaping lives in exactly one place.
10 *
11 * Security model for `bb_pfg_query`:
12 * - Nonce verified on every request via check_ajax_referer().
13 * - All inputs sanitized: post_type (sanitize_key + post_type_exists),
14 * taxonomy (sanitize_key + taxonomy_exists), term IDs (absint),
15 * paged (absint), orderby/order (strict allowlists).
16 * - All output escaped (esc_html / esc_url / esc_attr / wp_kses_post).
17 * - Responses use wp_send_json_success / wp_send_json_error; wp_reset_postdata().
18 *
19 * @package bBlocks
20 */
21
22 namespace BBlocks\Inc\Blocks;
23
24 if ( ! defined( 'ABSPATH' ) ) {
25 exit;
26 }
27
28 class PostFilterGrid {
29
30 /**
31 * Allowed orderby values.
32 *
33 * @var string[]
34 */
35 const ORDERBY = [ 'date', 'title', 'rand', 'menu_order' ];
36
37 /**
38 * Allowed order values (uppercased).
39 *
40 * @var string[]
41 */
42 const ORDER = [ 'ASC', 'DESC' ];
43
44 /**
45 * Allowed aspect ratios.
46 *
47 * @var string[]
48 */
49 const RATIOS = [ '16/9', '4/3', '1/1', '3/2' ];
50
51 /**
52 * Allowed title tags.
53 *
54 * @var string[]
55 */
56 const TITLE_TAGS = [ 'h2', 'h3', 'h4' ];
57
58 /**
59 * Hook the AJAX endpoint (public + logged-in).
60 */
61 public function __construct() {
62 add_action( 'wp_ajax_bb_pfg_query', [ $this, 'ajaxQuery' ] );
63 add_action( 'wp_ajax_nopriv_bb_pfg_query', [ $this, 'ajaxQuery' ] );
64 }
65
66 /* ----------------------------------------------------------------------
67 * Sanitizers
68 * ------------------------------------------------------------------- */
69
70 /**
71 * Sanitize a CSS color value (hex, rgb/hsl, var(), or a CSS keyword).
72 *
73 * @param mixed $color Raw color.
74 * @param string $fallback Fallback when invalid.
75 * @return string
76 */
77 public static function sanitizeColor( $color, $fallback = '' ) {
78 $color = trim( (string) $color );
79 if ( '' === $color ) {
80 return $fallback;
81 }
82 if ( preg_match( '/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/', $color ) ) {
83 return $color;
84 }
85 if ( preg_match( '/^(rgb|rgba|hsl|hsla)\s*\([0-9\s,%.\/]+\)$/i', $color ) ) {
86 return $color;
87 }
88 if ( preg_match( '/^var\(\s*--[a-zA-Z0-9\-_]+\s*(,\s*[a-zA-Z0-9 #%.,\-_\/]+)?\s*\)$/', $color ) ) {
89 return $color;
90 }
91 if ( preg_match( '/^[a-zA-Z]{1,30}$/', $color ) ) {
92 return $color;
93 }
94 return $fallback;
95 }
96
97 /**
98 * Clamp a value to an integer range.
99 *
100 * @param mixed $value Raw value.
101 * @param int $min Minimum.
102 * @param int $max Maximum.
103 * @param int $fallback Fallback when non-numeric.
104 * @return int
105 */
106 public static function clampInt( $value, $min, $max, $fallback ) {
107 if ( ! is_numeric( $value ) ) {
108 return (int) $fallback;
109 }
110 $value = (int) $value;
111 if ( $value < $min ) {
112 return (int) $min;
113 }
114 if ( $value > $max ) {
115 return (int) $max;
116 }
117 return $value;
118 }
119
120 /**
121 * Pick a value from an allowlist.
122 *
123 * @param mixed $value Raw value.
124 * @param string[] $allowed Allowed values.
125 * @param string $fallback Fallback.
126 * @return string
127 */
128 public static function pickFrom( $value, array $allowed, $fallback ) {
129 $value = is_string( $value ) ? trim( $value ) : '';
130 return in_array( $value, $allowed, true ) ? $value : $fallback;
131 }
132
133 /**
134 * Normalize and sanitize the full attribute set into a safe, typed array.
135 *
136 * @param array $attributes Raw block attributes.
137 * @return array
138 */
139 public static function resolveAttributes( array $attributes ) {
140 $postTypeRaw = isset( $attributes['postType'] ) ? sanitize_key( (string) $attributes['postType'] ) : 'post';
141 $postType = post_type_exists( $postTypeRaw ) ? $postTypeRaw : 'post';
142
143 $taxonomyRaw = isset( $attributes['filterTaxonomy'] ) ? sanitize_key( (string) $attributes['filterTaxonomy'] ) : 'category';
144 $taxonomy = taxonomy_exists( $taxonomyRaw ) ? $taxonomyRaw : 'category';
145
146 $columns = (array) ( $attributes['columns'] ?? [] );
147 $titleFont = (array) ( $attributes['titleFontSize'] ?? [] );
148
149 $readMoreLabel = isset( $attributes['readMoreLabel'] ) ? wp_strip_all_tags( (string) $attributes['readMoreLabel'] ) : '';
150 $readMoreLabel = '' !== trim( $readMoreLabel ) ? $readMoreLabel : __( 'Read More', 'b-blocks' );
151
152 $loadMoreLabel = isset( $attributes['loadMoreLabel'] ) ? wp_strip_all_tags( (string) $attributes['loadMoreLabel'] ) : '';
153 $loadMoreLabel = '' !== trim( $loadMoreLabel ) ? $loadMoreLabel : __( 'Load More', 'b-blocks' );
154
155 $allButtonLabel = isset( $attributes['allButtonLabel'] ) ? wp_strip_all_tags( (string) $attributes['allButtonLabel'] ) : '';
156 $allButtonLabel = '' !== trim( $allButtonLabel ) ? $allButtonLabel : __( 'All', 'b-blocks' );
157
158 $noPostsMessage = isset( $attributes['noPostsMessage'] ) ? wp_strip_all_tags( (string) $attributes['noPostsMessage'] ) : '';
159 $noPostsMessage = '' !== trim( $noPostsMessage ) ? $noPostsMessage : __( 'No posts found.', 'b-blocks' );
160
161 return [
162 'postType' => $postType,
163 'postsPerPage' => self::clampInt( $attributes['postsPerPage'] ?? 6, 1, 24, 6 ),
164 'defaultCategory' => absint( $attributes['defaultCategory'] ?? 0 ),
165 'orderBy' => self::pickFrom( $attributes['orderBy'] ?? 'date', self::ORDERBY, 'date' ),
166 'order' => self::pickFrom( strtoupper( (string) ( $attributes['order'] ?? 'desc' ) ), self::ORDER, 'DESC' ),
167 'excludeCurrentPost' => ! empty( $attributes['excludeCurrentPost'] ),
168 'currentPostId' => isset( $attributes['currentPostId'] ) ? absint( $attributes['currentPostId'] ) : 0,
169
170 'showFilterBar' => ! isset( $attributes['showFilterBar'] ) || (bool) $attributes['showFilterBar'],
171 'filterTaxonomy' => $taxonomy,
172 'showAllButton' => ! isset( $attributes['showAllButton'] ) || (bool) $attributes['showAllButton'],
173 'allButtonLabel' => $allButtonLabel,
174
175 'layout' => self::pickFrom( $attributes['layout'] ?? 'grid', [ 'grid', 'masonry' ], 'grid' ),
176 'columnsDesktop' => self::clampInt( $columns['desktop'] ?? 3, 1, 6, 3 ),
177 'columnsTablet' => self::clampInt( $columns['tablet'] ?? 2, 1, 4, 2 ),
178 'columnsMobile' => self::clampInt( $columns['mobile'] ?? 1, 1, 2, 1 ),
179 'columnGap' => self::clampInt( $attributes['columnGap'] ?? 24, 0, 80, 24 ),
180 'rowGap' => self::clampInt( $attributes['rowGap'] ?? 24, 0, 80, 24 ),
181
182 'cardStyle' => self::pickFrom( $attributes['cardStyle'] ?? 'boxed', [ 'boxed', 'flat' ], 'boxed' ),
183 'cardRadius' => self::clampInt( $attributes['cardRadius'] ?? 8, 0, 32, 8 ),
184 'cardBackground' => self::sanitizeColor( $attributes['cardBackground'] ?? '', '#ffffff' ),
185 'cardBorderColor' => self::sanitizeColor( $attributes['cardBorderColor'] ?? '', '#e5e7eb' ),
186 'cardShadow' => self::pickFrom( $attributes['cardShadow'] ?? 'sm', [ 'none', 'sm', 'md', 'lg' ], 'sm' ),
187
188 'showFeaturedImage' => ! isset( $attributes['showFeaturedImage'] ) || (bool) $attributes['showFeaturedImage'],
189 'imageRatio' => self::pickFrom( $attributes['imageRatio'] ?? '16/9', self::RATIOS, '16/9' ),
190 'imageRadius' => self::clampInt( $attributes['imageRadius'] ?? 8, 0, 32, 8 ),
191
192 'showCategory' => ! isset( $attributes['showCategory'] ) || (bool) $attributes['showCategory'],
193 'showTitle' => ! isset( $attributes['showTitle'] ) || (bool) $attributes['showTitle'],
194 'titleTag' => self::pickFrom( $attributes['titleTag'] ?? 'h3', self::TITLE_TAGS, 'h3' ),
195 'titleLines' => self::clampInt( $attributes['titleLines'] ?? 0, 0, 4, 0 ),
196
197 'showExcerpt' => ! isset( $attributes['showExcerpt'] ) || (bool) $attributes['showExcerpt'],
198 'excerptLength' => self::clampInt( $attributes['excerptLength'] ?? 20, 5, 50, 20 ),
199 'showMeta' => ! isset( $attributes['showMeta'] ) || (bool) $attributes['showMeta'],
200 'showAuthor' => ! isset( $attributes['showAuthor'] ) || (bool) $attributes['showAuthor'],
201 'showDate' => ! isset( $attributes['showDate'] ) || (bool) $attributes['showDate'],
202 'showReadMore' => ! isset( $attributes['showReadMore'] ) || (bool) $attributes['showReadMore'],
203 'readMoreLabel' => $readMoreLabel,
204
205 'showLoadMore' => ! empty( $attributes['showLoadMore'] ),
206 'loadMoreLabel' => $loadMoreLabel,
207 'noPostsMessage' => $noPostsMessage,
208
209 'accentColor' => self::sanitizeColor( $attributes['accentColor'] ?? '', '#146EF5' ),
210 'accentTextColor' => self::sanitizeColor( $attributes['accentTextColor'] ?? '', '#ffffff' ),
211 'titleColor' => self::sanitizeColor( $attributes['titleColor'] ?? '', 'inherit' ),
212 'excerptColor' => self::sanitizeColor( $attributes['excerptColor'] ?? '', 'inherit' ),
213 'metaColor' => self::sanitizeColor( $attributes['metaColor'] ?? '', 'inherit' ),
214
215 'titleSizeDesktop' => self::clampInt( preg_replace( '/[^0-9]/', '', (string) ( $titleFont['desktop'] ?? '18' ) ), 12, 48, 18 ),
216 'titleSizeTablet' => self::clampInt( preg_replace( '/[^0-9]/', '', (string) ( $titleFont['tablet'] ?? '17' ) ), 12, 40, 17 ),
217 'titleSizeMobile' => self::clampInt( preg_replace( '/[^0-9]/', '', (string) ( $titleFont['mobile'] ?? '16' ) ), 12, 36, 16 ),
218 ];
219 }
220
221 /* ----------------------------------------------------------------------
222 * Query
223 * ------------------------------------------------------------------- */
224
225 /**
226 * Build sanitized WP_Query args.
227 *
228 * @param array $a Resolved attributes.
229 * @param int $termId Term ID to filter by (0 = none).
230 * @param int $paged Page number (1-based).
231 * @return array
232 */
233 public static function buildQueryArgs( array $a, $termId, $paged ) {
234 $termId = absint( $termId );
235 $paged = max( 1, absint( $paged ) );
236
237 $args = [
238 'post_type' => $a['postType'],
239 'posts_per_page' => $a['postsPerPage'],
240 'paged' => $paged,
241 'post_status' => 'publish',
242 'orderby' => $a['orderBy'],
243 'order' => $a['order'],
244 'has_password' => false,
245 'ignore_sticky_posts' => true,
246 ];
247
248 if ( $a['excludeCurrentPost'] && $a['currentPostId'] > 0 ) {
249 $args['post__not_in'] = [ $a['currentPostId'] ];
250 }
251
252 if ( $termId > 0 && taxonomy_exists( $a['filterTaxonomy'] ) ) {
253 $args['tax_query'] = [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
254 [
255 'taxonomy' => $a['filterTaxonomy'],
256 'field' => 'term_id',
257 'terms' => [ $termId ],
258 ],
259 ];
260 }
261
262 return $args;
263 }
264
265 /* ----------------------------------------------------------------------
266 * Card rendering
267 * ------------------------------------------------------------------- */
268
269 /**
270 * Render the cards for a query as an escaped HTML fragment.
271 *
272 * @param \WP_Query $query The query.
273 * @param array $a Resolved attributes.
274 * @return string Escaped HTML.
275 */
276 public static function renderCards( $query, array $a ) {
277 ob_start();
278
279 while ( $query->have_posts() ) :
280 $query->the_post();
281 $postId = get_the_ID();
282 $permalink = get_permalink( $postId );
283 $titleText = get_the_title( $postId );
284
285 // Featured image.
286 $imageUrl = '';
287 $imageAlt = '';
288 if ( $a['showFeaturedImage'] && has_post_thumbnail( $postId ) ) {
289 $thumbUrl = get_the_post_thumbnail_url( $postId, 'large' );
290 if ( $thumbUrl ) {
291 $imageUrl = $thumbUrl;
292 $thumbId = get_post_thumbnail_id( $postId );
293 if ( $thumbId ) {
294 $metaAlt = get_post_meta( $thumbId, '_wp_attachment_image_alt', true );
295 if ( is_string( $metaAlt ) ) {
296 $imageAlt = trim( wp_strip_all_tags( $metaAlt ) );
297 }
298 }
299 if ( '' === $imageAlt ) {
300 $imageAlt = $titleText;
301 }
302 }
303 }
304
305 // Primary term from the filter taxonomy.
306 $termName = '';
307 $termLink = '';
308 if ( $a['showCategory'] ) {
309 $terms = get_the_terms( $postId, $a['filterTaxonomy'] );
310 if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) {
311 $firstTerm = $terms[0];
312 $termName = $firstTerm->name;
313 $link = get_term_link( $firstTerm );
314 if ( ! is_wp_error( $link ) ) {
315 $termLink = $link;
316 }
317 }
318 }
319
320 // Excerpt.
321 $excerptText = '';
322 if ( $a['showExcerpt'] ) {
323 $excerptText = wp_trim_words( get_the_excerpt( $postId ), $a['excerptLength'], '&hellip;' );
324 }
325 ?>
326 <article class='bb-pfg-card'>
327 <?php if ( '' !== $imageUrl ) : ?>
328 <a class='bb-pfg-image-link' href='<?php echo esc_url( $permalink ); ?>' tabindex='-1' aria-hidden='true'>
329 <img class='bb-pfg-image' src='<?php echo esc_url( $imageUrl ); ?>' alt='<?php echo esc_attr( $imageAlt ); ?>' loading='lazy' decoding='async' />
330 </a>
331 <?php endif; ?>
332
333 <div class='bb-pfg-card-body'>
334 <?php if ( $a['showCategory'] && '' !== $termName ) : ?>
335 <?php
336 $badgeLabel = sprintf(
337 /* translators: %s: term name. */
338 __( 'Category: %s', 'b-blocks' ),
339 $termName
340 );
341 if ( '' !== $termLink ) :
342 ?>
343 <a class='bb-pfg-badge' href='<?php echo esc_url( $termLink ); ?>' aria-label='<?php echo esc_attr( $badgeLabel ); ?>'>
344 <?php echo esc_html( $termName ); ?>
345 </a>
346 <?php else : ?>
347 <span class='bb-pfg-badge' aria-label='<?php echo esc_attr( $badgeLabel ); ?>'>
348 <?php echo esc_html( $termName ); ?>
349 </span>
350 <?php endif; ?>
351 <?php endif; ?>
352
353 <?php if ( $a['showTitle'] && '' !== $titleText ) : ?>
354 <<?php echo esc_attr( $a['titleTag'] ); ?> class='bb-pfg-title'>
355 <a class='bb-pfg-title-link' href='<?php echo esc_url( $permalink ); ?>'>
356 <?php echo esc_html( $titleText ); ?>
357 </a>
358 </<?php echo esc_attr( $a['titleTag'] ); ?>>
359 <?php endif; ?>
360
361 <?php if ( $a['showMeta'] && ( $a['showAuthor'] || $a['showDate'] ) ) : ?>
362 <div class='bb-pfg-meta'>
363 <?php if ( $a['showAuthor'] ) : ?>
364 <span class='bb-pfg-meta-author'>
365 <span class='screen-reader-text'><?php echo esc_html__( 'Author:', 'b-blocks' ); ?> </span>
366 <?php echo esc_html( get_the_author_meta( 'display_name', (int) get_post_field( 'post_author', $postId ) ) ); ?>
367 </span>
368 <?php endif; ?>
369 <?php if ( $a['showAuthor'] && $a['showDate'] ) : ?>
370 <span class='bb-pfg-meta-sep' aria-hidden='true'>&middot;</span>
371 <?php endif; ?>
372 <?php if ( $a['showDate'] ) : ?>
373 <time class='bb-pfg-meta-date' datetime='<?php echo esc_attr( get_the_date( 'c', $postId ) ); ?>'>
374 <?php echo esc_html( get_the_date( '', $postId ) ); ?>
375 </time>
376 <?php endif; ?>
377 </div>
378 <?php endif; ?>
379
380 <?php if ( $a['showExcerpt'] && '' !== $excerptText ) : ?>
381 <p class='bb-pfg-excerpt'><?php echo wp_kses_post( $excerptText ); ?></p>
382 <?php endif; ?>
383
384 <?php if ( $a['showReadMore'] ) : ?>
385 <a class='bb-pfg-readmore' href='<?php echo esc_url( $permalink ); ?>'>
386 <?php echo esc_html( $a['readMoreLabel'] ); ?>
387 <span class='screen-reader-text'> &mdash; <?php echo esc_html( $titleText ); ?></span>
388 </a>
389 <?php endif; ?>
390 </div>
391 </article>
392 <?php
393 endwhile;
394
395 return ob_get_clean();
396 }
397
398 /* ----------------------------------------------------------------------
399 * AJAX endpoint
400 * ------------------------------------------------------------------- */
401
402 /**
403 * Handle the `bb_pfg_query` AJAX request (filter / Load More).
404 *
405 * Returns JSON: { html, page, hasMore, count }.
406 */
407 public function ajaxQuery() {
408 // Nonce — shared plugin action handle. Dies with 403 on failure.
409 check_ajax_referer( 'wp_ajax', 'nonce' );
410
411 $raw = isset( $_POST['attributes'] ) ? wp_unslash( $_POST['attributes'] ) : ''; // phpcs:ignore WordPress.Security.ValidationSanitization.MissingUnslash, WordPress.Security.ValidationSanitization.InputNotSanitized -- JSON decoded then each field individually sanitized in resolveAttributes().
412 $attributes = is_string( $raw ) ? json_decode( $raw, true ) : [];
413 if ( ! is_array( $attributes ) ) {
414 $attributes = [];
415 }
416
417 $a = self::resolveAttributes( $attributes );
418
419 $termId = isset( $_POST['termId'] ) ? absint( wp_unslash( $_POST['termId'] ) ) : 0;
420 $paged = isset( $_POST['paged'] ) ? max( 1, absint( wp_unslash( $_POST['paged'] ) ) ) : 1;
421
422 $args = self::buildQueryArgs( $a, $termId, $paged );
423 $query = new \WP_Query( $args );
424
425 $html = '';
426 $count = 0;
427 if ( $query->have_posts() ) {
428 $html = self::renderCards( $query, $a );
429 $count = (int) $query->post_count;
430 }
431 wp_reset_postdata();
432
433 $maxPages = (int) $query->max_num_pages;
434 $hasMore = $a['showLoadMore'] && $paged < $maxPages;
435
436 if ( '' === $html ) {
437 $html = '<p class="bb-pfg-empty">' . esc_html( $a['noPostsMessage'] ) . '</p>';
438 }
439
440 wp_send_json_success(
441 [
442 'html' => $html,
443 'page' => $paged,
444 'hasMore' => $hasMore,
445 'count' => $count,
446 ]
447 );
448 }
449 }
450
451 new PostFilterGrid();
452