I’ll build this as a full-featured WordPress widget file for your RX theme, with safe defaults, caching, multiple related-post methods, styling hooks, and Elementor/block-friendly shortcode support inside the same file.
Below is a complete advanced inc/widgets/related-posts-widget.php file for your RX theme. It uses the WordPress WP_Widget class pattern, safe escaping/sanitization, WP_Query, transients cache, thumbnails, categories/tags/author matching, reading-time, views meta support, shortcode support, inline CSS option, and cache clearing hooks. WordPress officially recommends extending WP_Widget for custom widgets, and stylesheet/script loading should use enqueue functions when needed.
Create this file:
<?php
/**
* RX Theme - Advanced Related Posts Widget
*
* File: inc/widgets/related-posts-widget.php
*
* Features:
* - Classic WordPress widget support
* - Shortcode support: [rx_related_posts]
* - Related by category, tag, author, category+tag, smart mixed, popular, recent, random
* - Exclude current post
* - Thumbnail layout
* - Grid/list/compact/card layouts
* - Date, author, category, comment count, reading time, excerpt
* - Post views meta support
* - Sticky post control
* - Post type support
* - Cache with transients
* - Schema-friendly markup
* - Developer filters/actions
* - Safe escaping and sanitization
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'RX_Related_Posts_Widget' ) ) :
class RX_Related_Posts_Widget extends WP_Widget {
/**
* Widget version.
*/
const VERSION = '1.0.0';
/**
* Transient prefix.
*/
const CACHE_PREFIX = 'rx_related_posts_';
/**
* Constructor.
*/
public function __construct() {
parent::__construct(
'rx_related_posts_widget',
esc_html__( 'RX Related Posts', 'rx-theme' ),
array(
'classname' => 'rx_related_posts_widget',
'description' => esc_html__( 'Advanced related posts widget for RX theme.', 'rx-theme' ),
'customize_selective_refresh' => true,
)
);
add_action( 'save_post', array( __CLASS__, 'flush_cache' ) );
add_action( 'deleted_post', array( __CLASS__, 'flush_cache' ) );
add_action( 'switch_theme', array( __CLASS__, 'flush_cache' ) );
}
/**
* Default widget settings.
*/
public static function defaults() {
return apply_filters(
'rx_related_posts_widget_defaults',
array(
'title' => esc_html__( 'Related Posts', 'rx-theme' ),
'post_type' => 'post',
'related_by' => 'smart',
'posts_per_page' => 5,
'offset' => 0,
'order' => 'DESC',
'orderby' => 'date',
'layout' => 'list',
'columns' => 2,
'image_size' => 'thumbnail',
'show_thumbnail' => 1,
'show_title' => 1,
'show_date' => 1,
'show_author' => 0,
'show_category' => 1,
'show_comments' => 0,
'show_excerpt' => 1,
'show_reading_time' => 1,
'show_post_views' => 0,
'excerpt_length' => 16,
'title_length' => 80,
'fallback_recent' => 1,
'ignore_sticky_posts' => 1,
'exclude_current' => 1,
'exclude_ids' => '',
'include_ids' => '',
'category_ids' => '',
'tag_ids' => '',
'author_ids' => '',
'date_range' => 'any',
'enable_cache' => 1,
'cache_time' => 6,
'open_new_tab' => 0,
'nofollow_links' => 0,
'add_inline_css' => 1,
'custom_class' => '',
'empty_message' => esc_html__( 'No related posts found.', 'rx-theme' ),
)
);
}
/**
* Front-end output.
*/
public function widget( $args, $instance ) {
$instance = wp_parse_args( (array) $instance, self::defaults() );
echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
$title = apply_filters( 'widget_title', $instance['title'], $instance, $this->id_base );
if ( ! empty( $title ) ) {
echo $args['before_title'] . esc_html( $title ) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
echo self::render_related_posts( $instance ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Admin form.
*/
public function form( $instance ) {
$instance = wp_parse_args( (array) $instance, self::defaults() );
$related_options = array(
'smart' => esc_html__( 'Smart Mixed', 'rx-theme' ),
'category' => esc_html__( 'Same Category', 'rx-theme' ),
'tag' => esc_html__( 'Same Tag', 'rx-theme' ),
'category_tag' => esc_html__( 'Same Category + Tag', 'rx-theme' ),
'author' => esc_html__( 'Same Author', 'rx-theme' ),
'popular' => esc_html__( 'Popular by Comment Count', 'rx-theme' ),
'views' => esc_html__( 'Popular by Views Meta', 'rx-theme' ),
'recent' => esc_html__( 'Recent Posts', 'rx-theme' ),
'random' => esc_html__( 'Random Posts', 'rx-theme' ),
'manual' => esc_html__( 'Manual Include IDs', 'rx-theme' ),
);
$layout_options = array(
'list' => esc_html__( 'List', 'rx-theme' ),
'grid' => esc_html__( 'Grid', 'rx-theme' ),
'card' => esc_html__( 'Card', 'rx-theme' ),
'compact' => esc_html__( 'Compact', 'rx-theme' ),
'minimal' => esc_html__( 'Minimal', 'rx-theme' ),
);
$order_options = array(
'DESC' => esc_html__( 'Descending', 'rx-theme' ),
'ASC' => esc_html__( 'Ascending', 'rx-theme' ),
);
$orderby_options = array(
'date' => esc_html__( 'Date', 'rx-theme' ),
'title' => esc_html__( 'Title', 'rx-theme' ),
'comment_count' => esc_html__( 'Comment Count', 'rx-theme' ),
'rand' => esc_html__( 'Random', 'rx-theme' ),
'modified' => esc_html__( 'Modified Date', 'rx-theme' ),
'menu_order' => esc_html__( 'Menu Order', 'rx-theme' ),
);
$date_range_options = array(
'any' => esc_html__( 'Any Time', 'rx-theme' ),
'week' => esc_html__( 'Last 7 Days', 'rx-theme' ),
'month' => esc_html__( 'Last 30 Days', 'rx-theme' ),
'quarter' => esc_html__( 'Last 90 Days', 'rx-theme' ),
'year' => esc_html__( 'Last 365 Days', 'rx-theme' ),
);
?>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>">
<?php esc_html_e( 'Title:', 'rx-theme' ); ?>
</label>
<input class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>"
type="text"
value="<?php echo esc_attr( $instance['title'] ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'post_type' ) ); ?>">
<?php esc_html_e( 'Post Type:', 'rx-theme' ); ?>
</label>
<input class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'post_type' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'post_type' ) ); ?>"
type="text"
value="<?php echo esc_attr( $instance['post_type'] ); ?>">
<small><?php esc_html_e( 'Example: post, page, product, or comma separated.', 'rx-theme' ); ?></small>
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'related_by' ) ); ?>">
<?php esc_html_e( 'Related By:', 'rx-theme' ); ?>
</label>
<select class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'related_by' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'related_by' ) ); ?>">
<?php foreach ( $related_options as $key => $label ) : ?>
<option value="<?php echo esc_attr( $key ); ?>" <?php selected( $instance['related_by'], $key ); ?>>
<?php echo esc_html( $label ); ?>
</option>
<?php endforeach; ?>
</select>
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'layout' ) ); ?>">
<?php esc_html_e( 'Layout:', 'rx-theme' ); ?>
</label>
<select class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'layout' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'layout' ) ); ?>">
<?php foreach ( $layout_options as $key => $label ) : ?>
<option value="<?php echo esc_attr( $key ); ?>" <?php selected( $instance['layout'], $key ); ?>>
<?php echo esc_html( $label ); ?>
</option>
<?php endforeach; ?>
</select>
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'posts_per_page' ) ); ?>">
<?php esc_html_e( 'Number of Posts:', 'rx-theme' ); ?>
</label>
<input class="tiny-text"
id="<?php echo esc_attr( $this->get_field_id( 'posts_per_page' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'posts_per_page' ) ); ?>"
type="number"
min="1"
max="30"
value="<?php echo esc_attr( absint( $instance['posts_per_page'] ) ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'columns' ) ); ?>">
<?php esc_html_e( 'Grid Columns:', 'rx-theme' ); ?>
</label>
<input class="tiny-text"
id="<?php echo esc_attr( $this->get_field_id( 'columns' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'columns' ) ); ?>"
type="number"
min="1"
max="4"
value="<?php echo esc_attr( absint( $instance['columns'] ) ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'orderby' ) ); ?>">
<?php esc_html_e( 'Order By:', 'rx-theme' ); ?>
</label>
<select class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'orderby' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'orderby' ) ); ?>">
<?php foreach ( $orderby_options as $key => $label ) : ?>
<option value="<?php echo esc_attr( $key ); ?>" <?php selected( $instance['orderby'], $key ); ?>>
<?php echo esc_html( $label ); ?>
</option>
<?php endforeach; ?>
</select>
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'order' ) ); ?>">
<?php esc_html_e( 'Order:', 'rx-theme' ); ?>
</label>
<select class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'order' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'order' ) ); ?>">
<?php foreach ( $order_options as $key => $label ) : ?>
<option value="<?php echo esc_attr( $key ); ?>" <?php selected( $instance['order'], $key ); ?>>
<?php echo esc_html( $label ); ?>
</option>
<?php endforeach; ?>
</select>
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'date_range' ) ); ?>">
<?php esc_html_e( 'Date Range:', 'rx-theme' ); ?>
</label>
<select class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'date_range' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'date_range' ) ); ?>">
<?php foreach ( $date_range_options as $key => $label ) : ?>
<option value="<?php echo esc_attr( $key ); ?>" <?php selected( $instance['date_range'], $key ); ?>>
<?php echo esc_html( $label ); ?>
</option>
<?php endforeach; ?>
</select>
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'image_size' ) ); ?>">
<?php esc_html_e( 'Image Size:', 'rx-theme' ); ?>
</label>
<input class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'image_size' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'image_size' ) ); ?>"
type="text"
value="<?php echo esc_attr( $instance['image_size'] ); ?>">
<small><?php esc_html_e( 'Example: thumbnail, medium, large, full.', 'rx-theme' ); ?></small>
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'excerpt_length' ) ); ?>">
<?php esc_html_e( 'Excerpt Length:', 'rx-theme' ); ?>
</label>
<input class="tiny-text"
id="<?php echo esc_attr( $this->get_field_id( 'excerpt_length' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'excerpt_length' ) ); ?>"
type="number"
min="0"
max="80"
value="<?php echo esc_attr( absint( $instance['excerpt_length'] ) ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'title_length' ) ); ?>">
<?php esc_html_e( 'Title Character Limit:', 'rx-theme' ); ?>
</label>
<input class="tiny-text"
id="<?php echo esc_attr( $this->get_field_id( 'title_length' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'title_length' ) ); ?>"
type="number"
min="20"
max="200"
value="<?php echo esc_attr( absint( $instance['title_length'] ) ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'include_ids' ) ); ?>">
<?php esc_html_e( 'Include Post IDs:', 'rx-theme' ); ?>
</label>
<input class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'include_ids' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'include_ids' ) ); ?>"
type="text"
value="<?php echo esc_attr( $instance['include_ids'] ); ?>">
<small><?php esc_html_e( 'Comma separated. Used for Manual mode.', 'rx-theme' ); ?></small>
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'exclude_ids' ) ); ?>">
<?php esc_html_e( 'Exclude Post IDs:', 'rx-theme' ); ?>
</label>
<input class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'exclude_ids' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'exclude_ids' ) ); ?>"
type="text"
value="<?php echo esc_attr( $instance['exclude_ids'] ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'category_ids' ) ); ?>">
<?php esc_html_e( 'Limit Category IDs:', 'rx-theme' ); ?>
</label>
<input class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'category_ids' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'category_ids' ) ); ?>"
type="text"
value="<?php echo esc_attr( $instance['category_ids'] ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'tag_ids' ) ); ?>">
<?php esc_html_e( 'Limit Tag IDs:', 'rx-theme' ); ?>
</label>
<input class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'tag_ids' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'tag_ids' ) ); ?>"
type="text"
value="<?php echo esc_attr( $instance['tag_ids'] ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'author_ids' ) ); ?>">
<?php esc_html_e( 'Limit Author IDs:', 'rx-theme' ); ?>
</label>
<input class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'author_ids' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'author_ids' ) ); ?>"
type="text"
value="<?php echo esc_attr( $instance['author_ids'] ); ?>">
</p>
<hr>
<?php
$checkboxes = array(
'show_thumbnail' => esc_html__( 'Show Thumbnail', 'rx-theme' ),
'show_title' => esc_html__( 'Show Title', 'rx-theme' ),
'show_date' => esc_html__( 'Show Date', 'rx-theme' ),
'show_author' => esc_html__( 'Show Author', 'rx-theme' ),
'show_category' => esc_html__( 'Show Category', 'rx-theme' ),
'show_comments' => esc_html__( 'Show Comment Count', 'rx-theme' ),
'show_excerpt' => esc_html__( 'Show Excerpt', 'rx-theme' ),
'show_reading_time' => esc_html__( 'Show Reading Time', 'rx-theme' ),
'show_post_views' => esc_html__( 'Show Post Views', 'rx-theme' ),
'fallback_recent' => esc_html__( 'Fallback to Recent Posts', 'rx-theme' ),
'ignore_sticky_posts' => esc_html__( 'Ignore Sticky Posts', 'rx-theme' ),
'exclude_current' => esc_html__( 'Exclude Current Post', 'rx-theme' ),
'enable_cache' => esc_html__( 'Enable Cache', 'rx-theme' ),
'open_new_tab' => esc_html__( 'Open Links in New Tab', 'rx-theme' ),
'nofollow_links' => esc_html__( 'Add Nofollow to Links', 'rx-theme' ),
'add_inline_css' => esc_html__( 'Add Built-in CSS', 'rx-theme' ),
);
foreach ( $checkboxes as $field => $label ) :
?>
<p>
<input id="<?php echo esc_attr( $this->get_field_id( $field ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( $field ) ); ?>"
type="checkbox"
value="1" <?php checked( ! empty( $instance[ $field ] ) ); ?>>
<label for="<?php echo esc_attr( $this->get_field_id( $field ) ); ?>">
<?php echo esc_html( $label ); ?>
</label>
</p>
<?php
endforeach;
?>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'cache_time' ) ); ?>">
<?php esc_html_e( 'Cache Time in Hours:', 'rx-theme' ); ?>
</label>
<input class="tiny-text"
id="<?php echo esc_attr( $this->get_field_id( 'cache_time' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'cache_time' ) ); ?>"
type="number"
min="1"
max="168"
value="<?php echo esc_attr( absint( $instance['cache_time'] ) ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'custom_class' ) ); ?>">
<?php esc_html_e( 'Custom CSS Class:', 'rx-theme' ); ?>
</label>
<input class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'custom_class' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'custom_class' ) ); ?>"
type="text"
value="<?php echo esc_attr( $instance['custom_class'] ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'empty_message' ) ); ?>">
<?php esc_html_e( 'Empty Message:', 'rx-theme' ); ?>
</label>
<input class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'empty_message' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'empty_message' ) ); ?>"
type="text"
value="<?php echo esc_attr( $instance['empty_message'] ); ?>">
</p>
<?php
}
/**
* Save widget settings.
*/
public function update( $new_instance, $old_instance ) {
$defaults = self::defaults();
$instance = array();
$text_fields = array(
'title',
'post_type',
'related_by',
'order',
'orderby',
'layout',
'image_size',
'exclude_ids',
'include_ids',
'category_ids',
'tag_ids',
'author_ids',
'date_range',
'custom_class',
'empty_message',
);
foreach ( $text_fields as $field ) {
$instance[ $field ] = isset( $new_instance[ $field ] )
? sanitize_text_field( wp_unslash( $new_instance[ $field ] ) )
: $defaults[ $field ];
}
$number_fields = array(
'posts_per_page',
'offset',
'columns',
'excerpt_length',
'title_length',
'cache_time',
);
foreach ( $number_fields as $field ) {
$instance[ $field ] = isset( $new_instance[ $field ] )
? absint( $new_instance[ $field ] )
: absint( $defaults[ $field ] );
}
$checkbox_fields = array(
'show_thumbnail',
'show_title',
'show_date',
'show_author',
'show_category',
'show_comments',
'show_excerpt',
'show_reading_time',
'show_post_views',
'fallback_recent',
'ignore_sticky_posts',
'exclude_current',
'enable_cache',
'open_new_tab',
'nofollow_links',
'add_inline_css',
);
foreach ( $checkbox_fields as $field ) {
$instance[ $field ] = ! empty( $new_instance[ $field ] ) ? 1 : 0;
}
self::flush_cache();
return $instance;
}
/**
* Main renderer.
*/
public static function render_related_posts( $atts = array() ) {
$settings = wp_parse_args( (array) $atts, self::defaults() );
$settings = self::sanitize_render_settings( $settings );
$current_id = get_the_ID();
$cache_key = self::CACHE_PREFIX . md5(
wp_json_encode(
array(
'settings' => $settings,
'current_id' => $current_id,
'lang' => get_locale(),
)
)
);
if ( ! empty( $settings['enable_cache'] ) ) {
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
return $cached;
}
}
$query = self::get_related_query( $settings, $current_id );
if ( ! $query->have_posts() && ! empty( $settings['fallback_recent'] ) && 'recent' !== $settings['related_by'] ) {
$fallback_settings = $settings;
$fallback_settings['related_by'] = 'recent';
$query = self::get_related_query( $fallback_settings, $current_id );
}
ob_start();
if ( ! empty( $settings['add_inline_css'] ) ) {
self::print_inline_css();
}
$classes = array(
'rx-related-posts',
'rx-related-layout-' . sanitize_html_class( $settings['layout'] ),
'rx-related-columns-' . absint( $settings['columns'] ),
);
if ( ! empty( $settings['custom_class'] ) ) {
$classes[] = sanitize_html_class( $settings['custom_class'] );
}
do_action( 'rx_related_posts_before', $settings, $query );
if ( $query->have_posts() ) :
?>
<div class="<?php echo esc_attr( implode( ' ', $classes ) ); ?>" data-rx-related="true">
<div class="rx-related-posts-inner">
<?php
while ( $query->have_posts() ) :
$query->the_post();
self::render_post_item( get_the_ID(), $settings );
endwhile;
?>
</div>
</div>
<?php
else :
if ( ! empty( $settings['empty_message'] ) ) :
?>
<p class="rx-related-empty">
<?php echo esc_html( $settings['empty_message'] ); ?>
</p>
<?php
endif;
endif;
wp_reset_postdata();
do_action( 'rx_related_posts_after', $settings, $query );
$output = ob_get_clean();
if ( ! empty( $settings['enable_cache'] ) ) {
set_transient( $cache_key, $output, absint( $settings['cache_time'] ) * HOUR_IN_SECONDS );
}
return $output;
}
/**
* Query builder.
*/
public static function get_related_query( $settings, $current_id = 0 ) {
$post_types = self::csv_to_clean_array( $settings['post_type'], 'sanitize_key' );
if ( empty( $post_types ) ) {
$post_types = array( 'post' );
}
$post__not_in = self::csv_to_int_array( $settings['exclude_ids'] );
if ( ! empty( $settings['exclude_current'] ) && $current_id ) {
$post__not_in[] = absint( $current_id );
}
$args = array(
'post_type' => $post_types,
'post_status' => 'publish',
'posts_per_page' => max( 1, min( 30, absint( $settings['posts_per_page'] ) ) ),
'offset' => absint( $settings['offset'] ),
'order' => in_array( strtoupper( $settings['order'] ), array( 'ASC', 'DESC' ), true ) ? strtoupper( $settings['order'] ) : 'DESC',
'orderby' => sanitize_key( $settings['orderby'] ),
'post__not_in' => array_unique( array_filter( $post__not_in ) ),
'ignore_sticky_posts' => ! empty( $settings['ignore_sticky_posts'] ),
'no_found_rows' => true,
'update_post_meta_cache' => true,
'update_post_term_cache' => true,
);
$args = self::apply_date_range( $args, $settings['date_range'] );
$include_ids = self::csv_to_int_array( $settings['include_ids'] );
$category_ids = self::csv_to_int_array( $settings['category_ids'] );
$tag_ids = self::csv_to_int_array( $settings['tag_ids'] );
$author_ids = self::csv_to_int_array( $settings['author_ids'] );
if ( ! empty( $category_ids ) ) {
$args['category__in'] = $category_ids;
}
if ( ! empty( $tag_ids ) ) {
$args['tag__in'] = $tag_ids;
}
if ( ! empty( $author_ids ) ) {
$args['author__in'] = $author_ids;
}
switch ( $settings['related_by'] ) {
case 'manual':
if ( ! empty( $include_ids ) ) {
$args['post__in'] = $include_ids;
$args['orderby'] = 'post__in';
}
break;
case 'category':
$current_categories = self::get_current_term_ids( $current_id, 'category' );
if ( ! empty( $current_categories ) ) {
$args['category__in'] = $current_categories;
}
break;
case 'tag':
$current_tags = self::get_current_term_ids( $current_id, 'post_tag' );
if ( ! empty( $current_tags ) ) {
$args['tag__in'] = $current_tags;
}
break;
case 'category_tag':
$tax_query = array( 'relation' => 'OR' );
$current_categories = self::get_current_term_ids( $current_id, 'category' );
$current_tags = self::get_current_term_ids( $current_id, 'post_tag' );
if ( ! empty( $current_categories ) ) {
$tax_query[] = array(
'taxonomy' => 'category',
'field' => 'term_id',
'terms' => $current_categories,
);
}
if ( ! empty( $current_tags ) ) {
$tax_query[] = array(
'taxonomy' => 'post_tag',
'field' => 'term_id',
'terms' => $current_tags,
);
}
if ( count( $tax_query ) > 1 ) {
$args['tax_query'] = $tax_query;
}
break;
case 'author':
$author_id = $current_id ? absint( get_post_field( 'post_author', $current_id ) ) : 0;
if ( $author_id ) {
$args['author__in'] = array( $author_id );
}
break;
case 'popular':
$args['orderby'] = 'comment_count';
$args['order'] = 'DESC';
break;
case 'views':
$args['meta_key'] = apply_filters( 'rx_related_posts_views_meta_key', 'rx_post_views_count' );
$args['orderby'] = 'meta_value_num';
$args['order'] = 'DESC';
break;
case 'random':
$args['orderby'] = 'rand';
break;
case 'recent':
$args['orderby'] = 'date';
$args['order'] = 'DESC';
break;
case 'smart':
default:
$args = self::apply_smart_related_args( $args, $current_id );
break;
}
$args = apply_filters( 'rx_related_posts_query_args', $args, $settings, $current_id );
return new WP_Query( $args );
}
/**
* Smart related logic.
*/
public static function apply_smart_related_args( $args, $current_id ) {
if ( ! $current_id ) {
$args['orderby'] = 'date';
return $args;
}
$category_ids = self::get_current_term_ids( $current_id, 'category' );
$tag_ids = self::get_current_term_ids( $current_id, 'post_tag' );
$author_id = absint( get_post_field( 'post_author', $current_id ) );
$tax_query = array( 'relation' => 'OR' );
if ( ! empty( $category_ids ) ) {
$tax_query[] = array(
'taxonomy' => 'category',
'field' => 'term_id',
'terms' => $category_ids,
);
}
if ( ! empty( $tag_ids ) ) {
$tax_query[] = array(
'taxonomy' => 'post_tag',
'field' => 'term_id',
'terms' => $tag_ids,
);
}
if ( count( $tax_query ) > 1 ) {
$args['tax_query'] = $tax_query;
}
if ( $author_id ) {
$args['author__in'] = array( $author_id );
}
return $args;
}
/**
* Render single related post item.
*/
public static function render_post_item( $post_id, $settings ) {
$target = ! empty( $settings['open_new_tab'] ) ? ' target="_blank"' : '';
$rel_parts = array();
if ( ! empty( $settings['open_new_tab'] ) ) {
$rel_parts[] = 'noopener';
}
if ( ! empty( $settings['nofollow_links'] ) ) {
$rel_parts[] = 'nofollow';
}
$rel = ! empty( $rel_parts ) ? ' rel="' . esc_attr( implode( ' ', $rel_parts ) ) . '"' : '';
$title = get_the_title( $post_id );
$title = self::trim_text( $title, absint( $settings['title_length'] ), '' );
$permalink = get_permalink( $post_id );
?>
<article <?php post_class( 'rx-related-item', $post_id ); ?> itemscope itemtype="https://schema.org/BlogPosting">
<?php if ( ! empty( $settings['show_thumbnail'] ) && has_post_thumbnail( $post_id ) ) : ?>
<a class="rx-related-thumb"
href="<?php echo esc_url( $permalink ); ?>"
<?php echo $target . $rel; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
aria-label="<?php echo esc_attr( $title ); ?>">
<?php
echo get_the_post_thumbnail(
$post_id,
sanitize_key( $settings['image_size'] ),
array(
'loading' => 'lazy',
'decoding' => 'async',
'itemprop' => 'image',
)
);
?>
</a>
<?php endif; ?>
<div class="rx-related-content">
<?php if ( ! empty( $settings['show_category'] ) ) : ?>
<?php self::render_primary_category( $post_id ); ?>
<?php endif; ?>
<?php if ( ! empty( $settings['show_title'] ) ) : ?>
<h3 class="rx-related-title" itemprop="headline">
<a href="<?php echo esc_url( $permalink ); ?>" <?php echo $target . $rel; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
<?php echo esc_html( $title ); ?>
</a>
</h3>
<?php endif; ?>
<div class="rx-related-meta">
<?php if ( ! empty( $settings['show_date'] ) ) : ?>
<span class="rx-related-date">
<time datetime="<?php echo esc_attr( get_the_date( DATE_W3C, $post_id ) ); ?>" itemprop="datePublished">
<?php echo esc_html( get_the_date( '', $post_id ) ); ?>
</time>
</span>
<?php endif; ?>
<?php if ( ! empty( $settings['show_author'] ) ) : ?>
<span class="rx-related-author" itemprop="author" itemscope itemtype="https://schema.org/Person">
<?php esc_html_e( 'By', 'rx-theme' ); ?>
<span itemprop="name"><?php echo esc_html( get_the_author_meta( 'display_name', get_post_field( 'post_author', $post_id ) ) ); ?></span>
</span>
<?php endif; ?>
<?php if ( ! empty( $settings['show_comments'] ) ) : ?>
<span class="rx-related-comments">
<?php
printf(
esc_html(
_n(
'%s comment',
'%s comments',
get_comments_number( $post_id ),
'rx-theme'
)
),
esc_html( number_format_i18n( get_comments_number( $post_id ) ) )
);
?>
</span>
<?php endif; ?>
<?php if ( ! empty( $settings['show_reading_time'] ) ) : ?>
<span class="rx-related-reading-time">
<?php echo esc_html( self::get_reading_time( $post_id ) ); ?>
</span>
<?php endif; ?>
<?php if ( ! empty( $settings['show_post_views'] ) ) : ?>
<span class="rx-related-views">
<?php echo esc_html( self::get_post_views_label( $post_id ) ); ?>
</span>
<?php endif; ?>
</div>
<?php if ( ! empty( $settings['show_excerpt'] ) && absint( $settings['excerpt_length'] ) > 0 ) : ?>
<div class="rx-related-excerpt" itemprop="description">
<?php echo esc_html( self::get_excerpt( $post_id, absint( $settings['excerpt_length'] ) ) ); ?>
</div>
<?php endif; ?>
</div>
</article>
<?php
}
/**
* Render first category.
*/
public static function render_primary_category( $post_id ) {
$categories = get_the_category( $post_id );
if ( empty( $categories ) || is_wp_error( $categories ) ) {
return;
}
$category = $categories[0];
$link = get_category_link( $category->term_id );
if ( is_wp_error( $link ) ) {
return;
}
?>
<a class="rx-related-category" href="<?php echo esc_url( $link ); ?>">
<?php echo esc_html( $category->name ); ?>
</a>
<?php
}
/**
* Excerpt helper.
*/
public static function get_excerpt( $post_id, $length = 16 ) {
$excerpt = get_the_excerpt( $post_id );
if ( empty( $excerpt ) ) {
$post = get_post( $post_id );
$excerpt = $post ? wp_strip_all_tags( strip_shortcodes( $post->post_content ) ) : '';
}
return wp_trim_words( $excerpt, $length, '...' );
}
/**
* Reading time helper.
*/
public static function get_reading_time( $post_id ) {
$post = get_post( $post_id );
if ( ! $post ) {
return '';
}
$content = wp_strip_all_tags( strip_shortcodes( $post->post_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 )
);
}
/**
* Views label helper.
*/
public static function get_post_views_label( $post_id ) {
$meta_key = apply_filters( 'rx_related_posts_views_meta_key', 'rx_post_views_count' );
$views = absint( get_post_meta( $post_id, $meta_key, true ) );
return sprintf(
_n( '%s view', '%s views', $views, 'rx-theme' ),
number_format_i18n( $views )
);
}
/**
* Current post term IDs.
*/
public static function get_current_term_ids( $post_id, $taxonomy ) {
if ( ! $post_id ) {
return array();
}
$terms = wp_get_post_terms(
$post_id,
$taxonomy,
array(
'fields' => 'ids',
)
);
if ( is_wp_error( $terms ) || empty( $terms ) ) {
return array();
}
return array_map( 'absint', $terms );
}
/**
* Apply date range to query.
*/
public static function apply_date_range( $args, $range ) {
$range = sanitize_key( $range );
$days = 0;
switch ( $range ) {
case 'week':
$days = 7;
break;
case 'month':
$days = 30;
break;
case 'quarter':
$days = 90;
break;
case 'year':
$days = 365;
break;
}
if ( $days > 0 ) {
$args['date_query'] = array(
array(
'after' => $days . ' days ago',
'inclusive' => true,
),
);
}
return $args;
}
/**
* Sanitize render settings.
*/
public static function sanitize_render_settings( $settings ) {
$defaults = self::defaults();
$settings = wp_parse_args( $settings, $defaults );
$settings['posts_per_page'] = max( 1, min( 30, absint( $settings['posts_per_page'] ) ) );
$settings['offset'] = absint( $settings['offset'] );
$settings['columns'] = max( 1, min( 4, absint( $settings['columns'] ) ) );
$settings['excerpt_length'] = max( 0, min( 80, absint( $settings['excerpt_length'] ) ) );
$settings['title_length'] = max( 20, min( 200, absint( $settings['title_length'] ) ) );
$settings['cache_time'] = max( 1, min( 168, absint( $settings['cache_time'] ) ) );
$settings['related_by'] = sanitize_key( $settings['related_by'] );
$settings['layout'] = sanitize_key( $settings['layout'] );
$settings['orderby'] = sanitize_key( $settings['orderby'] );
$settings['order'] = strtoupper( sanitize_key( $settings['order'] ) );
$allowed_related = array(
'smart',
'category',
'tag',
'category_tag',
'author',
'popular',
'views',
'recent',
'random',
'manual',
);
if ( ! in_array( $settings['related_by'], $allowed_related, true ) ) {
$settings['related_by'] = 'smart';
}
$allowed_layouts = array( 'list', 'grid', 'card', 'compact', 'minimal' );
if ( ! in_array( $settings['layout'], $allowed_layouts, true ) ) {
$settings['layout'] = 'list';
}
return $settings;
}
/**
* CSV to integer array.
*/
public static function csv_to_int_array( $csv ) {
if ( empty( $csv ) ) {
return array();
}
$items = explode( ',', $csv );
$items = array_map( 'trim', $items );
$items = array_map( 'absint', $items );
$items = array_filter( $items );
return array_values( array_unique( $items ) );
}
/**
* CSV to clean array.
*/
public static function csv_to_clean_array( $csv, $callback = 'sanitize_text_field' ) {
if ( empty( $csv ) ) {
return array();
}
$items = explode( ',', $csv );
$items = array_map( 'trim', $items );
if ( is_callable( $callback ) ) {
$items = array_map( $callback, $items );
}
$items = array_filter( $items );
return array_values( array_unique( $items ) );
}
/**
* Trim text by character count.
*/
public static function trim_text( $text, $limit = 80, $more = '...' ) {
$text = wp_strip_all_tags( $text );
if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) {
if ( mb_strlen( $text ) <= $limit ) {
return $text;
}
return mb_substr( $text, 0, $limit ) . $more;
}
if ( strlen( $text ) <= $limit ) {
return $text;
}
return substr( $text, 0, $limit ) . $more;
}
/**
* Built-in CSS.
*/
public static function print_inline_css() {
static $printed = false;
if ( $printed ) {
return;
}
$printed = true;
?>
<style id="rx-related-posts-widget-css">
.rx-related-posts {
width: 100%;
margin: 0;
padding: 0;
}
.rx-related-posts-inner {
display: flex;
flex-direction: column;
gap: 16px;
}
.rx-related-item {
display: flex;
gap: 14px;
margin: 0;
padding: 0 0 16px;
border-bottom: 1px solid rgba(0,0,0,.08);
}
.rx-related-item:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.rx-related-thumb {
display: block;
flex: 0 0 96px;
max-width: 96px;
border-radius: 12px;
overflow: hidden;
background: #f3f4f6;
}
.rx-related-thumb img {
display: block;
width: 100%;
height: 96px;
object-fit: cover;
transition: transform .25s ease;
}
.rx-related-thumb:hover img {
transform: scale(1.04);
}
.rx-related-content {
flex: 1;
min-width: 0;
}
.rx-related-category {
display: inline-flex;
margin-bottom: 6px;
font-size: 11px;
font-weight: 700;
line-height: 1;
text-transform: uppercase;
letter-spacing: .04em;
text-decoration: none;
}
.rx-related-title {
margin: 0 0 6px;
font-size: 16px;
line-height: 1.35;
font-weight: 700;
}
.rx-related-title a {
text-decoration: none;
color: inherit;
}
.rx-related-title a:hover {
text-decoration: underline;
}
.rx-related-meta {
display: flex;
flex-wrap: wrap;
gap: 6px 10px;
margin-bottom: 6px;
font-size: 12px;
line-height: 1.4;
color: #667085;
}
.rx-related-excerpt {
font-size: 13px;
line-height: 1.55;
color: #475467;
}
.rx-related-layout-grid .rx-related-posts-inner,
.rx-related-layout-card .rx-related-posts-inner {
display: grid;
gap: 18px;
}
.rx-related-columns-1 .rx-related-posts-inner {
grid-template-columns: 1fr;
}
.rx-related-columns-2 .rx-related-posts-inner {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rx-related-columns-3 .rx-related-posts-inner {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.rx-related-columns-4 .rx-related-posts-inner {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.rx-related-layout-grid .rx-related-item,
.rx-related-layout-card .rx-related-item {
display: block;
padding: 0;
border: 0;
}
.rx-related-layout-grid .rx-related-thumb,
.rx-related-layout-card .rx-related-thumb {
max-width: 100%;
width: 100%;
margin-bottom: 10px;
}
.rx-related-layout-grid .rx-related-thumb img,
.rx-related-layout-card .rx-related-thumb img {
width: 100%;
height: 160px;
}
.rx-related-layout-card .rx-related-item {
padding: 12px;
border: 1px solid rgba(0,0,0,.08);
border-radius: 16px;
background: #fff;
box-shadow: 0 4px 18px rgba(16,24,40,.06);
}
.rx-related-layout-compact .rx-related-item {
gap: 10px;
padding-bottom: 10px;
}
.rx-related-layout-compact .rx-related-thumb {
flex-basis: 64px;
max-width: 64px;
border-radius: 8px;
}
.rx-related-layout-compact .rx-related-thumb img {
height: 64px;
}
.rx-related-layout-compact .rx-related-title {
font-size: 14px;
}
.rx-related-layout-minimal .rx-related-item {
display: block;
padding: 0 0 10px;
}
.rx-related-layout-minimal .rx-related-thumb,
.rx-related-layout-minimal .rx-related-category,
.rx-related-layout-minimal .rx-related-excerpt {
display: none;
}
.rx-related-empty {
margin: 0;
color: #667085;
font-size: 14px;
}
@media (max-width: 768px) {
.rx-related-columns-2 .rx-related-posts-inner,
.rx-related-columns-3 .rx-related-posts-inner,
.rx-related-columns-4 .rx-related-posts-inner {
grid-template-columns: 1fr;
}
}
</style>
<?php
}
/**
* Flush related post transients.
*/
public static function flush_cache() {
global $wpdb;
if ( ! $wpdb ) {
return;
}
$prefix = '_transient_' . self::CACHE_PREFIX;
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
$wpdb->esc_like( $prefix ) . '%',
$wpdb->esc_like( '_transient_timeout_' . self::CACHE_PREFIX ) . '%'
)
);
}
}
endif;
/**
* Register widget.
*/
if ( ! function_exists( 'rx_register_related_posts_widget' ) ) {
function rx_register_related_posts_widget() {
register_widget( 'RX_Related_Posts_Widget' );
}
add_action( 'widgets_init', 'rx_register_related_posts_widget' );
}
/**
* Shortcode:
* [rx_related_posts posts_per_page="6" layout="grid" columns="3" related_by="smart"]
*/
if ( ! function_exists( 'rx_related_posts_shortcode' ) ) {
function rx_related_posts_shortcode( $atts ) {
$atts = shortcode_atts(
RX_Related_Posts_Widget::defaults(),
$atts,
'rx_related_posts'
);
return RX_Related_Posts_Widget::render_related_posts( $atts );
}
add_shortcode( 'rx_related_posts', 'rx_related_posts_shortcode' );
}
/**
* Optional post views counter.
*
* Use this only if you want the widget's "views" mode to work with rx_post_views_count.
*/
if ( ! function_exists( 'rx_related_posts_track_views' ) ) {
function rx_related_posts_track_views() {
if ( ! is_singular() || is_admin() ) {
return;
}
$post_id = get_queried_object_id();
if ( ! $post_id ) {
return;
}
$meta_key = apply_filters( 'rx_related_posts_views_meta_key', 'rx_post_views_count' );
$count = absint( get_post_meta( $post_id, $meta_key, true ) );
update_post_meta( $post_id, $meta_key, $count + 1 );
}
add_action( 'wp', 'rx_related_posts_track_views' );
}
Now include it from your theme functions.php:
/**
* RX Theme widgets.
*/
require_once get_template_directory() . '/inc/widgets/related-posts-widget.php';
You can also use it inside posts/pages with shortcode:
[rx_related_posts posts_per_page="6" layout="grid" columns="3" related_by="smart"]
Best modes for your medical website:
[rx_related_posts related_by="category_tag" posts_per_page="8" layout="card" columns="2" show_excerpt="1"]
For popular medical articles:
[rx_related_posts related_by="views" posts_per_page="5" layout="list" show_post_views="1"]