I’ll build this as a strong inc/ajax/filter-posts.php module for your RX theme: secure AJAX + REST support, taxonomy/meta/search/date filters, sorting, pagination, layout switching, nonce checks, sanitization, and JSON responses.
Below is an advanced, complete inc/ajax/filter-posts.php file for your RX Theme.
It includes:
AJAX post filtering, REST API endpoint, nonce security, category/tag/taxonomy filters, meta filters, date filters, search, author filter, pagination, load more, sorting, layout support, excerpt/image/schema helpers, cache support, and safe sanitization.
Create this file:
rx-theme/inc/ajax/filter-posts.php
Then paste this code:
<?php
/**
* RX Theme - Advanced AJAX / REST Post Filtering
*
* File: inc/ajax/filter-posts.php
*
* Features:
* - WordPress AJAX post filtering
* - REST API filtering endpoint
* - Nonce security
* - Category, tag, author, taxonomy filters
* - Meta query filters
* - Date query filters
* - Search query support
* - Post type support
* - Pagination and load more
* - Multiple layouts: grid, list, card, minimal
* - Sort by date, title, modified, comment count, menu order, random, meta value
* - Sticky post control
* - Featured image support
* - Reading time support
* - Schema-friendly output
* - Object cache support
* - Clean JSON response
*
* @package RX_Theme
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'RX_Ajax_Filter_Posts' ) ) :
final class RX_Ajax_Filter_Posts {
/**
* AJAX action name.
*/
const AJAX_ACTION = 'rx_filter_posts';
/**
* Nonce action.
*/
const NONCE_ACTION = 'rx_filter_posts_nonce_action';
/**
* Nonce field name.
*/
const NONCE_NAME = 'nonce';
/**
* REST namespace.
*/
const REST_NAMESPACE = 'rx-theme/v1';
/**
* REST route.
*/
const REST_ROUTE = '/filter-posts';
/**
* Cache group.
*/
const CACHE_GROUP = 'rx_filter_posts';
/**
* Constructor.
*/
public function __construct() {
add_action( 'wp_ajax_' . self::AJAX_ACTION, array( $this, 'ajax_filter_posts' ) );
add_action( 'wp_ajax_nopriv_' . self::AJAX_ACTION, array( $this, 'ajax_filter_posts' ) );
add_action( 'rest_api_init', array( $this, 'register_rest_route' ) );
add_action( 'wp_enqueue_scripts', array( $this, 'localize_filter_data' ), 20 );
}
/**
* Register REST endpoint.
*/
public function register_rest_route() {
register_rest_route(
self::REST_NAMESPACE,
self::REST_ROUTE,
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'rest_filter_posts' ),
'permission_callback' => '__return_true',
'args' => $this->get_rest_args(),
)
);
}
/**
* REST args.
*
* @return array
*/
private function get_rest_args() {
return array(
'post_type' => array(
'type' => 'string',
'default' => 'post',
'sanitize_callback' => 'sanitize_key',
),
'page' => array(
'type' => 'integer',
'default' => 1,
'sanitize_callback' => 'absint',
),
'per_page' => array(
'type' => 'integer',
'default' => 10,
'sanitize_callback' => 'absint',
),
'search' => array(
'type' => 'string',
'default' => '',
'sanitize_callback' => 'sanitize_text_field',
),
'category' => array(
'type' => 'string',
'default' => '',
'sanitize_callback' => 'sanitize_text_field',
),
'tag' => array(
'type' => 'string',
'default' => '',
'sanitize_callback' => 'sanitize_text_field',
),
'author' => array(
'type' => 'integer',
'default' => 0,
'sanitize_callback' => 'absint',
),
'orderby' => array(
'type' => 'string',
'default' => 'date',
'sanitize_callback' => 'sanitize_key',
),
'order' => array(
'type' => 'string',
'default' => 'DESC',
'sanitize_callback' => 'sanitize_key',
),
);
}
/**
* Localize data for frontend scripts.
*
* This assumes your main theme JS handle is rx-theme-main.
* Change the handle if your enqueue.php uses another name.
*/
public function localize_filter_data() {
$handle = apply_filters( 'rx_filter_posts_script_handle', 'rx-theme-main' );
if ( wp_script_is( $handle, 'enqueued' ) || wp_script_is( $handle, 'registered' ) ) {
wp_localize_script(
$handle,
'rxFilterPosts',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'restUrl' => esc_url_raw( rest_url( self::REST_NAMESPACE . self::REST_ROUTE ) ),
'nonce' => wp_create_nonce( self::NONCE_ACTION ),
'action' => self::AJAX_ACTION,
'loadingText' => esc_html__( 'Loading posts...', 'rx-theme' ),
'noPostsText' => esc_html__( 'No posts found.', 'rx-theme' ),
'errorText' => esc_html__( 'Something went wrong. Please try again.', 'rx-theme' ),
)
);
}
}
/**
* AJAX callback.
*/
public function ajax_filter_posts() {
$this->verify_ajax_nonce();
$params = $this->sanitize_request_params( $_POST );
$data = $this->get_filtered_posts_response( $params );
wp_send_json( $data );
}
/**
* REST callback.
*
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response
*/
public function rest_filter_posts( WP_REST_Request $request ) {
$params = $this->sanitize_request_params( $request->get_params() );
$data = $this->get_filtered_posts_response( $params );
return rest_ensure_response( $data );
}
/**
* Verify AJAX nonce.
*/
private function verify_ajax_nonce() {
$nonce = isset( $_POST[ self::NONCE_NAME ] )
? sanitize_text_field( wp_unslash( $_POST[ self::NONCE_NAME ] ) )
: '';
if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
wp_send_json_error(
array(
'message' => esc_html__( 'Security check failed.', 'rx-theme' ),
),
403
);
}
}
/**
* Sanitize request params.
*
* @param array $input Raw input.
* @return array
*/
private function sanitize_request_params( $input ) {
$input = is_array( $input ) ? wp_unslash( $input ) : array();
$defaults = array(
'post_type' => 'post',
'post_status' => 'publish',
'page' => 1,
'per_page' => 10,
'search' => '',
'category' => '',
'category_id' => 0,
'tag' => '',
'tag_id' => 0,
'author' => 0,
'taxonomy' => '',
'term' => '',
'term_id' => 0,
'tax_relation' => 'AND',
'meta_key' => '',
'meta_value' => '',
'meta_compare' => '=',
'meta_type' => 'CHAR',
'date_after' => '',
'date_before' => '',
'year' => 0,
'month' => 0,
'day' => 0,
'orderby' => 'date',
'order' => 'DESC',
'layout' => 'grid',
'image_size' => 'medium_large',
'excerpt_length' => 24,
'show_excerpt' => true,
'show_image' => true,
'show_author' => true,
'show_date' => true,
'show_category' => true,
'show_comments' => true,
'show_reading_time' => true,
'ignore_sticky_posts' => true,
'include_sticky_posts' => false,
'exclude' => '',
'include' => '',
'offset' => 0,
'load_more' => false,
'cache' => true,
'cache_ttl' => 300,
);
$args = wp_parse_args( $input, $defaults );
$args['post_type'] = $this->sanitize_post_type( $args['post_type'] );
$args['post_status'] = sanitize_key( $args['post_status'] );
$args['page'] = max( 1, absint( $args['page'] ) );
$args['per_page'] = min( 50, max( 1, absint( $args['per_page'] ) ) );
$args['search'] = sanitize_text_field( $args['search'] );
$args['category'] = sanitize_text_field( $args['category'] );
$args['category_id'] = absint( $args['category_id'] );
$args['tag'] = sanitize_text_field( $args['tag'] );
$args['tag_id'] = absint( $args['tag_id'] );
$args['author'] = absint( $args['author'] );
$args['taxonomy'] = sanitize_key( $args['taxonomy'] );
$args['term'] = sanitize_text_field( $args['term'] );
$args['term_id'] = absint( $args['term_id'] );
$args['tax_relation'] = $this->sanitize_relation( $args['tax_relation'] );
$args['meta_key'] = sanitize_key( $args['meta_key'] );
$args['meta_value'] = sanitize_text_field( $args['meta_value'] );
$args['meta_compare'] = $this->sanitize_meta_compare( $args['meta_compare'] );
$args['meta_type'] = $this->sanitize_meta_type( $args['meta_type'] );
$args['date_after'] = sanitize_text_field( $args['date_after'] );
$args['date_before'] = sanitize_text_field( $args['date_before'] );
$args['year'] = absint( $args['year'] );
$args['month'] = absint( $args['month'] );
$args['day'] = absint( $args['day'] );
$args['orderby'] = $this->sanitize_orderby( $args['orderby'] );
$args['order'] = $this->sanitize_order( $args['order'] );
$args['layout'] = $this->sanitize_layout( $args['layout'] );
$args['image_size'] = sanitize_key( $args['image_size'] );
$args['excerpt_length'] = min( 80, max( 5, absint( $args['excerpt_length'] ) ) );
$args['show_excerpt'] = filter_var( $args['show_excerpt'], FILTER_VALIDATE_BOOLEAN );
$args['show_image'] = filter_var( $args['show_image'], FILTER_VALIDATE_BOOLEAN );
$args['show_author'] = filter_var( $args['show_author'], FILTER_VALIDATE_BOOLEAN );
$args['show_date'] = filter_var( $args['show_date'], FILTER_VALIDATE_BOOLEAN );
$args['show_category'] = filter_var( $args['show_category'], FILTER_VALIDATE_BOOLEAN );
$args['show_comments'] = filter_var( $args['show_comments'], FILTER_VALIDATE_BOOLEAN );
$args['show_reading_time'] = filter_var( $args['show_reading_time'], FILTER_VALIDATE_BOOLEAN );
$args['ignore_sticky_posts'] = filter_var( $args['ignore_sticky_posts'], FILTER_VALIDATE_BOOLEAN );
$args['include_sticky_posts'] = filter_var( $args['include_sticky_posts'], FILTER_VALIDATE_BOOLEAN );
$args['exclude'] = $this->sanitize_id_list( $args['exclude'] );
$args['include'] = $this->sanitize_id_list( $args['include'] );
$args['offset'] = absint( $args['offset'] );
$args['load_more'] = filter_var( $args['load_more'], FILTER_VALIDATE_BOOLEAN );
$args['cache'] = filter_var( $args['cache'], FILTER_VALIDATE_BOOLEAN );
$args['cache_ttl'] = min( DAY_IN_SECONDS, max( 60, absint( $args['cache_ttl'] ) ) );
return apply_filters( 'rx_filter_posts_sanitized_params', $args, $input );
}
/**
* Build filtered posts response.
*
* @param array $params Sanitized params.
* @return array
*/
private function get_filtered_posts_response( $params ) {
$cache_key = $this->get_cache_key( $params );
if ( $params['cache'] ) {
$cached = wp_cache_get( $cache_key, self::CACHE_GROUP );
if ( false !== $cached ) {
return $cached;
}
}
$query_args = $this->build_query_args( $params );
$query = new WP_Query( $query_args );
ob_start();
if ( $query->have_posts() ) {
echo '<div class="' . esc_attr( $this->get_wrapper_classes( $params ) ) . '" data-rx-filter-results="true">';
while ( $query->have_posts() ) {
$query->the_post();
$this->render_post_item( get_the_ID(), $params );
}
echo '</div>';
} else {
$this->render_no_posts();
}
$html = ob_get_clean();
$pagination_html = $this->get_pagination_html( $query, $params );
$response = array(
'success' => true,
'html' => $html,
'pagination' => $pagination_html,
'found_posts' => absint( $query->found_posts ),
'max_pages' => absint( $query->max_num_pages ),
'current_page' => absint( $params['page'] ),
'per_page' => absint( $params['per_page'] ),
'has_more' => $params['page'] < $query->max_num_pages,
'next_page' => $params['page'] < $query->max_num_pages ? $params['page'] + 1 : null,
'query_args' => current_user_can( 'manage_options' ) ? $query_args : array(),
'message' => $query->have_posts()
? esc_html__( 'Posts loaded successfully.', 'rx-theme' )
: esc_html__( 'No posts found.', 'rx-theme' ),
);
wp_reset_postdata();
$response = apply_filters( 'rx_filter_posts_response', $response, $query, $params );
if ( $params['cache'] ) {
wp_cache_set( $cache_key, $response, self::CACHE_GROUP, $params['cache_ttl'] );
}
return $response;
}
/**
* Build WP_Query args.
*
* @param array $params Params.
* @return array
*/
private function build_query_args( $params ) {
$args = array(
'post_type' => $params['post_type'],
'post_status' => $params['post_status'],
'paged' => $params['page'],
'posts_per_page' => $params['per_page'],
'ignore_sticky_posts' => $params['ignore_sticky_posts'],
'no_found_rows' => false,
'update_post_meta_cache' => true,
'update_post_term_cache' => true,
);
if ( ! empty( $params['search'] ) ) {
$args['s'] = $params['search'];
}
if ( ! empty( $params['author'] ) ) {
$args['author'] = $params['author'];
}
if ( ! empty( $params['include'] ) ) {
$args['post__in'] = $params['include'];
}
if ( ! empty( $params['exclude'] ) ) {
$args['post__not_in'] = $params['exclude'];
}
if ( ! empty( $params['offset'] ) ) {
$args['offset'] = $params['offset'];
}
if ( $params['include_sticky_posts'] ) {
$sticky_posts = get_option( 'sticky_posts' );
if ( ! empty( $sticky_posts ) ) {
$args['post__in'] = ! empty( $args['post__in'] )
? array_intersect( $args['post__in'], $sticky_posts )
: $sticky_posts;
}
}
$tax_query = $this->build_tax_query( $params );
if ( ! empty( $tax_query ) ) {
$args['tax_query'] = $tax_query;
}
$meta_query = $this->build_meta_query( $params );
if ( ! empty( $meta_query ) ) {
$args['meta_query'] = $meta_query;
}
$date_query = $this->build_date_query( $params );
if ( ! empty( $date_query ) ) {
$args['date_query'] = $date_query;
}
$args = $this->apply_order_args( $args, $params );
return apply_filters( 'rx_filter_posts_query_args', $args, $params );
}
/**
* Build taxonomy query.
*
* @param array $params Params.
* @return array
*/
private function build_tax_query( $params ) {
$tax_query = array();
if ( ! empty( $params['category_id'] ) ) {
$tax_query[] = array(
'taxonomy' => 'category',
'field' => 'term_id',
'terms' => array( $params['category_id'] ),
);
} elseif ( ! empty( $params['category'] ) ) {
$tax_query[] = array(
'taxonomy' => 'category',
'field' => is_numeric( $params['category'] ) ? 'term_id' : 'slug',
'terms' => is_numeric( $params['category'] ) ? array( absint( $params['category'] ) ) : array( sanitize_title( $params['category'] ) ),
);
}
if ( ! empty( $params['tag_id'] ) ) {
$tax_query[] = array(
'taxonomy' => 'post_tag',
'field' => 'term_id',
'terms' => array( $params['tag_id'] ),
);
} elseif ( ! empty( $params['tag'] ) ) {
$tax_query[] = array(
'taxonomy' => 'post_tag',
'field' => is_numeric( $params['tag'] ) ? 'term_id' : 'slug',
'terms' => is_numeric( $params['tag'] ) ? array( absint( $params['tag'] ) ) : array( sanitize_title( $params['tag'] ) ),
);
}
if ( ! empty( $params['taxonomy'] ) && taxonomy_exists( $params['taxonomy'] ) ) {
if ( ! empty( $params['term_id'] ) ) {
$tax_query[] = array(
'taxonomy' => $params['taxonomy'],
'field' => 'term_id',
'terms' => array( $params['term_id'] ),
);
} elseif ( ! empty( $params['term'] ) ) {
$tax_query[] = array(
'taxonomy' => $params['taxonomy'],
'field' => is_numeric( $params['term'] ) ? 'term_id' : 'slug',
'terms' => is_numeric( $params['term'] ) ? array( absint( $params['term'] ) ) : array( sanitize_title( $params['term'] ) ),
);
}
}
if ( count( $tax_query ) > 1 ) {
$tax_query['relation'] = $params['tax_relation'];
}
return $tax_query;
}
/**
* Build meta query.
*
* @param array $params Params.
* @return array
*/
private function build_meta_query( $params ) {
$meta_query = array();
if ( ! empty( $params['meta_key'] ) ) {
$query = array(
'key' => $params['meta_key'],
'compare' => $params['meta_compare'],
'type' => $params['meta_type'],
);
if ( '' !== $params['meta_value'] && 'EXISTS' !== $params['meta_compare'] && 'NOT EXISTS' !== $params['meta_compare'] ) {
$query['value'] = $params['meta_value'];
}
$meta_query[] = $query;
}
/**
* Allows adding complex custom meta query from another file.
*/
$meta_query = apply_filters( 'rx_filter_posts_meta_query', $meta_query, $params );
if ( count( $meta_query ) > 1 && empty( $meta_query['relation'] ) ) {
$meta_query['relation'] = 'AND';
}
return $meta_query;
}
/**
* Build date query.
*
* @param array $params Params.
* @return array
*/
private function build_date_query( $params ) {
$date_query = array();
if ( ! empty( $params['date_after'] ) || ! empty( $params['date_before'] ) ) {
$item = array(
'inclusive' => true,
);
if ( ! empty( $params['date_after'] ) ) {
$item['after'] = $params['date_after'];
}
if ( ! empty( $params['date_before'] ) ) {
$item['before'] = $params['date_before'];
}
$date_query[] = $item;
}
if ( ! empty( $params['year'] ) ) {
$date_query[] = array(
'year' => $params['year'],
);
}
if ( ! empty( $params['month'] ) ) {
$date_query[] = array(
'month' => $params['month'],
);
}
if ( ! empty( $params['day'] ) ) {
$date_query[] = array(
'day' => $params['day'],
);
}
return $date_query;
}
/**
* Apply ordering args.
*
* @param array $args Query args.
* @param array $params Params.
* @return array
*/
private function apply_order_args( $args, $params ) {
$args['order'] = $params['order'];
if ( 'meta_value' === $params['orderby'] || 'meta_value_num' === $params['orderby'] ) {
if ( ! empty( $params['meta_key'] ) ) {
$args['meta_key'] = $params['meta_key'];
$args['orderby'] = $params['orderby'];
} else {
$args['orderby'] = 'date';
}
} else {
$args['orderby'] = $params['orderby'];
}
return $args;
}
/**
* Render one post item.
*
* @param int $post_id Post ID.
* @param array $params Params.
*/
private function render_post_item( $post_id, $params ) {
$classes = array(
'rx-filter-post',
'rx-filter-post--' . $params['layout'],
'post-' . $post_id,
);
if ( has_post_thumbnail( $post_id ) ) {
$classes[] = 'has-post-thumbnail';
} else {
$classes[] = 'no-post-thumbnail';
}
$classes = apply_filters( 'rx_filter_posts_item_classes', $classes, $post_id, $params );
?>
<article <?php post_class( $classes, $post_id ); ?> itemscope itemtype="https://schema.org/Article">
<?php if ( $params['show_image'] ) : ?>
<?php $this->render_post_thumbnail( $post_id, $params ); ?>
<?php endif; ?>
<div class="rx-filter-post__content">
<?php if ( $params['show_category'] ) : ?>
<?php $this->render_post_categories( $post_id ); ?>
<?php endif; ?>
<header class="rx-filter-post__header">
<h2 class="rx-filter-post__title" itemprop="headline">
<a href="<?php echo esc_url( get_permalink( $post_id ) ); ?>" itemprop="url">
<?php echo esc_html( get_the_title( $post_id ) ); ?>
</a>
</h2>
<?php $this->render_post_meta( $post_id, $params ); ?>
</header>
<?php if ( $params['show_excerpt'] ) : ?>
<div class="rx-filter-post__excerpt" itemprop="description">
<?php echo esc_html( $this->get_custom_excerpt( $post_id, $params['excerpt_length'] ) ); ?>
</div>
<?php endif; ?>
<footer class="rx-filter-post__footer">
<a class="rx-filter-post__read-more" href="<?php echo esc_url( get_permalink( $post_id ) ); ?>" aria-label="<?php echo esc_attr( sprintf( __( 'Read more about %s', 'rx-theme' ), get_the_title( $post_id ) ) ); ?>">
<?php esc_html_e( 'Read More', 'rx-theme' ); ?>
</a>
</footer>
</div>
</article>
<?php
}
/**
* Render post thumbnail.
*
* @param int $post_id Post ID.
* @param array $params Params.
*/
private function render_post_thumbnail( $post_id, $params ) {
if ( ! has_post_thumbnail( $post_id ) ) {
return;
}
?>
<figure class="rx-filter-post__thumbnail" itemprop="image" itemscope itemtype="https://schema.org/ImageObject">
<a href="<?php echo esc_url( get_permalink( $post_id ) ); ?>" aria-hidden="true" tabindex="-1">
<?php
echo get_the_post_thumbnail(
$post_id,
$params['image_size'],
array(
'class' => 'rx-filter-post__image',
'loading' => 'lazy',
'decoding' => 'async',
'alt' => esc_attr( get_the_title( $post_id ) ),
)
);
?>
</a>
</figure>
<?php
}
/**
* Render post categories.
*
* @param int $post_id Post ID.
*/
private function render_post_categories( $post_id ) {
$categories = get_the_category( $post_id );
if ( empty( $categories ) || is_wp_error( $categories ) ) {
return;
}
echo '<div class="rx-filter-post__categories">';
foreach ( array_slice( $categories, 0, 2 ) as $category ) {
printf(
'<a class="rx-filter-post__category" href="%1$s">%2$s</a>',
esc_url( get_category_link( $category->term_id ) ),
esc_html( $category->name )
);
}
echo '</div>';
}
/**
* Render post meta.
*
* @param int $post_id Post ID.
* @param array $params Params.
*/
private function render_post_meta( $post_id, $params ) {
if (
! $params['show_author']
&& ! $params['show_date']
&& ! $params['show_comments']
&& ! $params['show_reading_time']
) {
return;
}
echo '<div class="rx-filter-post__meta">';
if ( $params['show_author'] ) {
$author_id = get_post_field( 'post_author', $post_id );
printf(
'<span class="rx-filter-post__author" itemprop="author" itemscope itemtype="https://schema.org/Person">%1$s <a href="%2$s" itemprop="url"><span itemprop="name">%3$s</span></a></span>',
esc_html__( 'By', 'rx-theme' ),
esc_url( get_author_posts_url( $author_id ) ),
esc_html( get_the_author_meta( 'display_name', $author_id ) )
);
}
if ( $params['show_date'] ) {
printf(
'<time class="rx-filter-post__date" datetime="%1$s" itemprop="datePublished">%2$s</time>',
esc_attr( get_the_date( DATE_W3C, $post_id ) ),
esc_html( get_the_date( '', $post_id ) )
);
}
if ( $params['show_reading_time'] ) {
printf(
'<span class="rx-filter-post__reading-time">%s</span>',
esc_html( $this->get_reading_time( $post_id ) )
);
}
if ( $params['show_comments'] && comments_open( $post_id ) ) {
printf(
'<span class="rx-filter-post__comments">%s</span>',
esc_html(
sprintf(
_n(
'%s Comment',
'%s Comments',
get_comments_number( $post_id ),
'rx-theme'
),
number_format_i18n( get_comments_number( $post_id ) )
)
)
);
}
echo '</div>';
}
/**
* Render no posts.
*/
private function render_no_posts() {
?>
<div class="rx-filter-posts-empty">
<h3 class="rx-filter-posts-empty__title">
<?php esc_html_e( 'No posts found', 'rx-theme' ); ?>
</h3>
<p class="rx-filter-posts-empty__text">
<?php esc_html_e( 'Please try another filter, keyword, category, or date range.', 'rx-theme' ); ?>
</p>
</div>
<?php
}
/**
* Get pagination HTML.
*
* @param WP_Query $query Query object.
* @param array $params Params.
* @return string
*/
private function get_pagination_html( $query, $params ) {
if ( $query->max_num_pages <= 1 ) {
return '';
}
if ( $params['load_more'] ) {
if ( $params['page'] >= $query->max_num_pages ) {
return '';
}
return sprintf(
'<button type="button" class="rx-filter-load-more" data-next-page="%1$d">%2$s</button>',
absint( $params['page'] + 1 ),
esc_html__( 'Load More', 'rx-theme' )
);
}
$links = paginate_links(
array(
'base' => '%_%',
'format' => '?paged=%#%',
'current' => max( 1, $params['page'] ),
'total' => $query->max_num_pages,
'type' => 'array',
'prev_text' => esc_html__( 'Previous', 'rx-theme' ),
'next_text' => esc_html__( 'Next', 'rx-theme' ),
)
);
if ( empty( $links ) ) {
return '';
}
return '<nav class="rx-filter-pagination" aria-label="' . esc_attr__( 'Posts pagination', 'rx-theme' ) . '">' . implode( '', $links ) . '</nav>';
}
/**
* Get wrapper classes.
*
* @param array $params Params.
* @return string
*/
private function get_wrapper_classes( $params ) {
$classes = array(
'rx-filter-posts',
'rx-filter-posts--' . $params['layout'],
);
return implode( ' ', array_map( 'sanitize_html_class', $classes ) );
}
/**
* Custom excerpt.
*
* @param int $post_id Post ID.
* @param int $length Word length.
* @return string
*/
private function get_custom_excerpt( $post_id, $length = 24 ) {
$excerpt = get_the_excerpt( $post_id );
if ( empty( $excerpt ) ) {
$content = get_post_field( 'post_content', $post_id );
$content = strip_shortcodes( $content );
$content = wp_strip_all_tags( $content );
$excerpt = $content;
}
return wp_trim_words( $excerpt, $length, '...' );
}
/**
* Reading time.
*
* @param int $post_id Post ID.
* @return string
*/
private function get_reading_time( $post_id ) {
$content = get_post_field( 'post_content', $post_id );
$content = wp_strip_all_tags( strip_shortcodes( $content ) );
$word_count = str_word_count( $content );
$minutes = max( 1, ceil( $word_count / 200 ) );
return sprintf(
_n( '%s min read', '%s min read', $minutes, 'rx-theme' ),
number_format_i18n( $minutes )
);
}
/**
* Cache key.
*
* @param array $params Params.
* @return string
*/
private function get_cache_key( $params ) {
return 'rx_filter_' . md5( wp_json_encode( $params ) );
}
/**
* Sanitize post type.
*
* @param string $post_type Post type.
* @return string
*/
private function sanitize_post_type( $post_type ) {
$post_type = sanitize_key( $post_type );
if ( ! post_type_exists( $post_type ) ) {
return 'post';
}
return $post_type;
}
/**
* Sanitize relation.
*
* @param string $relation Relation.
* @return string
*/
private function sanitize_relation( $relation ) {
$relation = strtoupper( sanitize_key( $relation ) );
return in_array( $relation, array( 'AND', 'OR' ), true ) ? $relation : 'AND';
}
/**
* Sanitize order.
*
* @param string $order Order.
* @return string
*/
private function sanitize_order( $order ) {
$order = strtoupper( sanitize_key( $order ) );
return in_array( $order, array( 'ASC', 'DESC' ), true ) ? $order : 'DESC';
}
/**
* Sanitize orderby.
*
* @param string $orderby Orderby.
* @return string
*/
private function sanitize_orderby( $orderby ) {
$allowed = array(
'none',
'id',
'author',
'title',
'name',
'type',
'date',
'modified',
'parent',
'rand',
'comment_count',
'menu_order',
'meta_value',
'meta_value_num',
'post__in',
);
$orderby = sanitize_key( $orderby );
return in_array( $orderby, $allowed, true ) ? $orderby : 'date';
}
/**
* Sanitize layout.
*
* @param string $layout Layout.
* @return string
*/
private function sanitize_layout( $layout ) {
$allowed = array(
'grid',
'list',
'card',
'minimal',
);
$layout = sanitize_key( $layout );
return in_array( $layout, $allowed, true ) ? $layout : 'grid';
}
/**
* Sanitize meta compare.
*
* @param string $compare Compare.
* @return string
*/
private function sanitize_meta_compare( $compare ) {
$allowed = array(
'=',
'!=',
'>',
'>=',
'<',
'<=',
'LIKE',
'NOT LIKE',
'IN',
'NOT IN',
'BETWEEN',
'NOT BETWEEN',
'EXISTS',
'NOT EXISTS',
'REGEXP',
'NOT REGEXP',
'RLIKE',
);
$compare = strtoupper( trim( $compare ) );
return in_array( $compare, $allowed, true ) ? $compare : '=';
}
/**
* Sanitize meta type.
*
* @param string $type Type.
* @return string
*/
private function sanitize_meta_type( $type ) {
$allowed = array(
'NUMERIC',
'BINARY',
'CHAR',
'DATE',
'DATETIME',
'DECIMAL',
'SIGNED',
'TIME',
'UNSIGNED',
);
$type = strtoupper( sanitize_key( $type ) );
return in_array( $type, $allowed, true ) ? $type : 'CHAR';
}
/**
* Sanitize ID list.
*
* @param mixed $ids IDs.
* @return array
*/
private function sanitize_id_list( $ids ) {
if ( empty( $ids ) ) {
return array();
}
if ( is_string( $ids ) ) {
$ids = explode( ',', $ids );
}
if ( ! is_array( $ids ) ) {
return array();
}
$ids = array_map( 'absint', $ids );
$ids = array_filter( $ids );
return array_values( array_unique( $ids ) );
}
}
endif;
/**
* Initialize RX AJAX filter posts.
*/
function rx_theme_ajax_filter_posts_init() {
return new RX_Ajax_Filter_Posts();
}
rx_theme_ajax_filter_posts_init();
/**
* Helper: frontend filter form shortcode.
*
* Usage:
* [rx_post_filter]
*/
function rx_post_filter_shortcode( $atts ) {
$atts = shortcode_atts(
array(
'post_type' => 'post',
'per_page' => 9,
'layout' => 'grid',
),
$atts,
'rx_post_filter'
);
ob_start();
?>
<div class="rx-post-filter" data-rx-post-filter="true">
<form class="rx-post-filter__form" data-rx-filter-form="true">
<input type="hidden" name="action" value="<?php echo esc_attr( RX_Ajax_Filter_Posts::AJAX_ACTION ); ?>">
<input type="hidden" name="nonce" value="<?php echo esc_attr( wp_create_nonce( RX_Ajax_Filter_Posts::NONCE_ACTION ) ); ?>">
<input type="hidden" name="post_type" value="<?php echo esc_attr( sanitize_key( $atts['post_type'] ) ); ?>">
<input type="hidden" name="per_page" value="<?php echo esc_attr( absint( $atts['per_page'] ) ); ?>">
<input type="hidden" name="layout" value="<?php echo esc_attr( sanitize_key( $atts['layout'] ) ); ?>">
<input type="hidden" name="page" value="1">
<div class="rx-post-filter__row">
<label class="rx-post-filter__label">
<span><?php esc_html_e( 'Search', 'rx-theme' ); ?></span>
<input class="rx-post-filter__input" type="search" name="search" placeholder="<?php esc_attr_e( 'Search posts...', 'rx-theme' ); ?>">
</label>
<label class="rx-post-filter__label">
<span><?php esc_html_e( 'Category', 'rx-theme' ); ?></span>
<select class="rx-post-filter__select" name="category_id">
<option value=""><?php esc_html_e( 'All Categories', 'rx-theme' ); ?></option>
<?php
$categories = get_categories(
array(
'hide_empty' => true,
)
);
foreach ( $categories as $category ) {
printf(
'<option value="%1$d">%2$s</option>',
absint( $category->term_id ),
esc_html( $category->name )
);
}
?>
</select>
</label>
<label class="rx-post-filter__label">
<span><?php esc_html_e( 'Sort By', 'rx-theme' ); ?></span>
<select class="rx-post-filter__select" name="orderby">
<option value="date"><?php esc_html_e( 'Newest', 'rx-theme' ); ?></option>
<option value="title"><?php esc_html_e( 'Title', 'rx-theme' ); ?></option>
<option value="modified"><?php esc_html_e( 'Recently Updated', 'rx-theme' ); ?></option>
<option value="comment_count"><?php esc_html_e( 'Most Commented', 'rx-theme' ); ?></option>
<option value="rand"><?php esc_html_e( 'Random', 'rx-theme' ); ?></option>
</select>
</label>
<label class="rx-post-filter__label">
<span><?php esc_html_e( 'Order', 'rx-theme' ); ?></span>
<select class="rx-post-filter__select" name="order">
<option value="DESC"><?php esc_html_e( 'Descending', 'rx-theme' ); ?></option>
<option value="ASC"><?php esc_html_e( 'Ascending', 'rx-theme' ); ?></option>
</select>
</label>
<button class="rx-post-filter__button" type="submit">
<?php esc_html_e( 'Filter', 'rx-theme' ); ?>
</button>
</div>
</form>
<div class="rx-post-filter__status" data-rx-filter-status="true" aria-live="polite"></div>
<div class="rx-post-filter__results" data-rx-filter-target="true"></div>
<div class="rx-post-filter__pagination" data-rx-filter-pagination="true"></div>
</div>
<?php
return ob_get_clean();
}
add_shortcode( 'rx_post_filter', 'rx_post_filter_shortcode' );
Now include it in your functions.php:
/**
* RX AJAX filter posts.
*/
$rx_filter_posts_file = get_template_directory() . '/inc/ajax/filter-posts.php';
if ( file_exists( $rx_filter_posts_file ) ) {
require_once $rx_filter_posts_file;
}
You also need frontend JavaScript to make the AJAX form work. Add this to your main JS file, for example:
(function () {
'use strict';
document.addEventListener('DOMContentLoaded', function () {
const filters = document.querySelectorAll('[data-rx-post-filter="true"]');
if (!filters.length || typeof rxFilterPosts === 'undefined') {
return;
}
filters.forEach(function (filterBox) {
const form = filterBox.querySelector('[data-rx-filter-form="true"]');
const target = filterBox.querySelector('[data-rx-filter-target="true"]');
const pagination = filterBox.querySelector('[data-rx-filter-pagination="true"]');
const status = filterBox.querySelector('[data-rx-filter-status="true"]');
if (!form || !target) {
return;
}
const setStatus = function (message) {
if (status) {
status.textContent = message || '';
}
};
const getFormData = function () {
const formData = new FormData(form);
if (!formData.get('nonce')) {
formData.set('nonce', rxFilterPosts.nonce);
}
if (!formData.get('action')) {
formData.set('action', rxFilterPosts.action);
}
return formData;
};
const requestPosts = function (append = false) {
setStatus(rxFilterPosts.loadingText);
const formData = getFormData();
fetch(rxFilterPosts.ajaxUrl, {
method: 'POST',
credentials: 'same-origin',
body: formData
})
.then(function (response) {
if (!response.ok) {
throw new Error('Network response failed.');
}
return response.json();
})
.then(function (data) {
if (!data || data.success === false) {
throw new Error(data?.data?.message || rxFilterPosts.errorText);
}
if (append) {
target.insertAdjacentHTML('beforeend', data.html);
} else {
target.innerHTML = data.html;
}
if (pagination) {
pagination.innerHTML = data.pagination || '';
}
setStatus(data.message || '');
form.dataset.maxPages = data.max_pages || 1;
form.dataset.currentPage = data.current_page || 1;
})
.catch(function (error) {
setStatus(error.message || rxFilterPosts.errorText);
});
};
form.addEventListener('submit', function (event) {
event.preventDefault();
const pageInput = form.querySelector('[name="page"]');
if (pageInput) {
pageInput.value = '1';
}
requestPosts(false);
});
form.addEventListener('change', function () {
const pageInput = form.querySelector('[name="page"]');
if (pageInput) {
pageInput.value = '1';
}
requestPosts(false);
});
if (pagination) {
pagination.addEventListener('click', function (event) {
const loadMore = event.target.closest('.rx-filter-load-more');
if (!loadMore) {
return;
}
event.preventDefault();
const pageInput = form.querySelector('[name="page"]');
if (pageInput) {
pageInput.value = loadMore.dataset.nextPage || '1';
}
requestPosts(true);
});
}
requestPosts(false);
});
});
})();
You can use the shortcode anywhere:
[rx_post_filter]
Or with options:
[rx_post_filter post_type="post" per_page="12" layout="grid"]
For your folder structure, I suggest this:
rx-theme/
└── inc/
└── ajax/
└── filter-posts.php
This file is ready for your RX Theme and can be expanded later with WooCommerce products, CPT filters, ACF fields, medical article filters, disease category filters, and live search suggestions.