related-posts-widget.php

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"]

Leave a Reply

Your email address will not be published. Required fields are marked *