<?php //phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
* The Jetpack_RelatedPosts class.
* @package automattic/jetpack
use Automattic\Jetpack\Assets;
use Automattic\Jetpack\Blocks;
use Automattic\Jetpack\Post_Media\Images;
use Automattic\Jetpack\Status\Request;
use Automattic\Jetpack\Sync\Settings;
* The Jetpack_RelatedPosts class.
class Jetpack_RelatedPosts {
const VERSION = '20240116';
const SHORTCODE = 'jetpack-related-posts';
* @var Jetpack_RelatedPosts
private static $instance = null;
* Instance of the raw class (?).
* @var Jetpack_RelatedPosts
private static $instance_raw = null;
* Creates and returns a static instance of Jetpack_RelatedPosts.
* @return Jetpack_RelatedPosts
public static function init() {
if ( ! self::$instance ) {
if ( class_exists( 'WPCOM_RelatedPosts' ) && method_exists( 'WPCOM_RelatedPosts', 'init' ) ) {
self::$instance = WPCOM_RelatedPosts::init();
self::$instance = new Jetpack_RelatedPosts();
* Creates and returns a static instance of Jetpack_RelatedPosts_Raw.
* @return Jetpack_RelatedPosts
public static function init_raw() {
if ( ! self::$instance_raw ) {
if ( class_exists( 'WPCOM_RelatedPosts' ) && method_exists( 'WPCOM_RelatedPosts', 'init_raw' ) ) {
self::$instance_raw = WPCOM_RelatedPosts::init_raw();
self::$instance_raw = new Jetpack_RelatedPosts_Raw();
return self::$instance_raw;
* Allow feature toggle variable.
protected $allow_feature_toggle;
protected $convert_charset;
protected $previous_post_id;
protected $found_shortcode = false;
* Constructor for Jetpack_RelatedPosts.
* @uses get_option, add_action, apply_filters
public function __construct() {
$this->blog_charset = get_option( 'blog_charset' );
$this->convert_charset = ( function_exists( 'iconv' ) && ! preg_match( '/^utf\-?8$/i', $this->blog_charset ) );
add_action( 'admin_init', array( $this, 'action_admin_init' ) );
add_action( 'wp', array( $this, 'action_frontend_init' ) );
if ( ! class_exists( 'Jetpack_Media_Summary' ) ) {
require_once JETPACK__PLUGIN_DIR . '_inc/lib/class.media-summary.php';
// Add Related Posts to the REST API Post response.
add_action( 'rest_api_init', array( $this, 'rest_register_related_posts' ) );
* @return mixed current blog id.
protected function get_blog_id() {
return Jetpack_Options::get_option( 'id' );
* Add a checkbox field to Settings > Reading for enabling related posts.
* @uses add_settings_field, __, register_setting, add_action
public function action_admin_init() {
// Add the setting field [jetpack_relatedposts] and place it in Settings > Reading.
add_settings_field( 'jetpack_relatedposts', '<span id="jetpack_relatedposts">' . __( 'Related posts', 'jetpack' ) . '</span>', array( $this, 'print_setting_html' ), 'reading' );
register_setting( 'reading', 'jetpack_relatedposts', array( $this, 'parse_options' ) );
add_action( 'admin_head', array( $this, 'print_setting_head' ) );
if ( 'options-reading.php' === $GLOBALS['pagenow'] ) {
// Enqueue style for live preview on the reading settings page.
$this->enqueue_assets( false, true );
* Load related posts assets if it's an eligible front end page or execute search and return JSON if it's an endpoint request.
* @uses add_shortcode, get_the_ID
public function action_frontend_init() {
// Add a shortcode handler that outputs nothing, this gets overridden later if we can display related content.
add_shortcode( self::SHORTCODE, array( $this, 'get_client_rendered_html_unsupported' ) );
if ( ! $this->enabled_for_request() ) {
if ( isset( $_GET['relatedposts'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading and checking if we need to generate a list of excuded posts, does not update anything on the site.
$excludes = $this->parse_numeric_get_arg( 'relatedposts_exclude' );
$this->action_frontend_init_ajax( $excludes );
if ( isset( $_GET['relatedposts_hit'] ) && isset( $_GET['relatedposts_origin'] ) && isset( $_GET['relatedposts_position'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- checking if fields are set to setup tracking, nothing is changing on the site.
$this->previous_post_id = (int) $_GET['relatedposts_origin']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- fetching a previous post ID for tracking, nothing is changing on the site.
$this->log_click( $this->previous_post_id, get_the_ID(), sanitize_text_field( wp_unslash( $_GET['relatedposts_position'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- logging the click for tracking, nothing is changing on the site.
$this->action_frontend_init_page();
* Render insertion point.
public function get_headline() {
$options = $this->get_options();
if ( ! empty( $options['show_headline'] ) ) {
/** This filter is already documented in modules/sharedaddy/sharing-service.php */
apply_filters( 'jetpack_sharing_headline_html', '<h3 class="jp-relatedposts-headline"><em>%s</em></h3>', esc_html( $options['headline'] ), 'related-posts' ),
esc_html( $options['headline'] )
* Adds a target to the post content to load related posts into if a shortcode for it did not already exist.
* Will skip adding the target if the post content contains a Related Posts block, if the 'get_the_excerpt'
* hook is in the current filter list, or if the site is running an FSE/Site Editor theme.
* @param string $content Post content.
public function filter_add_target_to_dom( $content ) {
// Do not output related posts for ActivityPub requests.
function_exists( '\Activitypub\is_activitypub_request' )
&& \Activitypub\is_activitypub_request()
if ( has_block( 'jetpack/related-posts' ) || Blocks::is_fse_theme() ) {
if ( ! $this->found_shortcode && ! doing_filter( 'get_the_excerpt' ) ) {
if ( class_exists( 'Jetpack_AMP_Support' ) && Jetpack_AMP_Support::is_amp_request() ) {
$content .= "\n" . $this->get_server_rendered_html();
$content .= "\n" . $this->get_client_rendered_html();
* Render static markup based on the Gutenberg block code
* @return string Rendered related posts HTML.
public function get_server_rendered_html() {
$rp_settings = $this->get_options();
$block_rp_settings = array(
'displayThumbnails' => $rp_settings['show_thumbnails'],
'showHeadline' => $rp_settings['show_headline'],
'displayDate' => isset( $rp_settings['show_date'] ) ? (bool) $rp_settings['show_date'] : true,
'displayContext' => isset( $rp_settings['show_context'] ) && $rp_settings['show_context'],
'postLayout' => isset( $rp_settings['layout'] ) ? $rp_settings['layout'] : 'grid',
'postsToShow' => isset( $rp_settings['size'] ) ? $rp_settings['size'] : 3,
/** This filter is already documented in modules/related-posts/jetpack-related-posts.php */
'headline' => apply_filters( 'jetpack_relatedposts_filter_headline', $this->get_headline() ),
'isServerRendered' => true,
return $this->render_block( $block_rp_settings, '' );
* Looks for our shortcode on the unfiltered content, this has to execute early.
* @param string $content - content of the post.
* @return string $content
public function test_for_shortcode( $content ) {
$this->found_shortcode = has_shortcode( $content, self::SHORTCODE );
* Returns the HTML for the related posts section.
* @uses esc_html__, apply_filters
public function get_client_rendered_html() {
if ( Settings::is_syncing() ) {
* Filter the Related Posts headline.
* @param string $headline Related Posts heading.
$headline = apply_filters( 'jetpack_relatedposts_filter_headline', $this->get_headline() );
if ( $this->previous_post_id ) {
$exclude = "data-exclude='{$this->previous_post_id}'";
<div id='jp-relatedposts' class='jp-relatedposts' $exclude>
* Returns the HTML for the related posts section if it's running in the loop or other instances where we don't support related posts.
public function get_client_rendered_html_unsupported() {
if ( Settings::is_syncing() ) {
return "\n\n<!-- Jetpack Related Posts is not supported in this context. -->\n\n";
* Echoes out items for the Gutenberg block
* @param array $related_post The post object.
* @param array $block_attributes The block attributes.
public function render_block_item( $related_post, $block_attributes ) {
$instance_id = 'related-posts-item-' . uniqid();
$label_id = $instance_id . '-label';
$title = $related_post['title'];
$url = $related_post['url'];
$rel = $related_post['rel'];
'<li id="%1$s" class="jp-related-posts-i2__post">',
if ( ! empty( $block_attributes['show_thumbnails'] ) && ! empty( $related_post['img']['src'] ) ) {
'<img loading="lazy" class="jp-related-posts-i2__post-img" src="%1$s" alt="%2$s" %3$s/>',
esc_url( $related_post['img']['src'] ),
esc_attr( $related_post['img']['alt_text'] ),
( ! empty( $related_post['img']['srcset'] ) ? 'srcset="' . esc_attr( $related_post['img']['srcset'] ) . '"' : '' )
'<a id="%1$s" href="%2$s" class="jp-related-posts-i2__post-link" %3$s>%4$s%5$s</a>',
( ! empty( $rel ) ? 'rel="' . esc_attr( $rel ) . '"' : '' ),
if ( $block_attributes['show_date'] ) {
$list .= '<dt>' . __( 'Date', 'jetpack' ) . '</dt>';
$list .= '<dd class="jp-related-posts-i2__post-date">';
$list .= esc_html( $related_post['date'] );
if ( $block_attributes['show_author'] ) {
$list .= '<dt>' . __( 'Author', 'jetpack' ) . '</dt>';
$list .= '<dd class="jp-related-posts-i2__post-author">';
$list .= esc_html( $related_post['author'] );
if ( ( $block_attributes['show_context'] ) && ! empty( $related_post['block_context'] ) ) {
// translators: this is followed by the reason why the item is related to the current post
$list .= '<dt>' . __( 'In relation to', 'jetpack' ) . '</dt>';
$list .= '<dd class="jp-related-posts-i2__post-context">';
// Note: The original 'context' value is not used when rendering the block.
// It is still generated and available for the legacy rendering code path though.
// See './related-posts.js' for that usage.
$block_context = $related_post['block_context'];
if ( ! empty( $block_context['link'] ) ) {
'<a href="%1$s">%2$s</a>',
esc_url( $block_context['link'] ),
esc_html( $block_context['text'] )
$list .= esc_html( $block_context['text'] );
if ( ! empty( $list ) ) {
$item_markup .= '<dl class="jp-related-posts-i2__post-defs">' . $list . '</dl>';
* Render the list of related posts.
* @param array $posts The posts to render into the list.
* @param array $block_attributes Block attributes.
public function render_post_list( $posts, $block_attributes ) {
foreach ( $posts as $post ) {
$markup .= $this->render_block_item( $post, $block_attributes );
// role="list" is required for accessibility as VoiceOver ignores unstyled lists.
'<ul class="jp-related-posts-i2__list" role="list" data-post-count="%1$s">%2$s</ul>',
* Render the related posts markup.
* @param array $attributes Block attributes.
* @param string $content String containing the related Posts block content.
* @param WP_Block $block The block object.
public function render_block( $attributes, $content, $block = null ) {
if ( ! Request::is_frontend() ) {
$wrapper_attributes = array();
$block_attributes = array(
'headline' => isset( $attributes['headline'] ) ? $attributes['headline'] : null,
'show_thumbnails' => isset( $attributes['displayThumbnails'] ) && $attributes['displayThumbnails'],
'show_author' => isset( $attributes['displayAuthor'] ) ? (bool) $attributes['displayAuthor'] : false,
'show_headline' => isset( $attributes['displayHeadline'] ) ? (bool) $attributes['displayHeadline'] : false,
'show_date' => isset( $attributes['displayDate'] ) ? (bool) $attributes['displayDate'] : true,
'show_context' => isset( $attributes['displayContext'] ) && $attributes['displayContext'],
'layout' => isset( $attributes['postLayout'] ) && 'list' === $attributes['postLayout'] ? $attributes['postLayout'] : 'grid',
'size' => ! empty( $attributes['postsToShow'] ) ? absint( $attributes['postsToShow'] ) : 3,
$excludes = $this->parse_numeric_get_arg( 'relatedposts_origin' );
$related_posts = $this->get_for_post_id(
'size' => $block_attributes['size'],
'exclude_post_ids' => $excludes,
if ( empty( $related_posts ) ) {
$list_markup = $this->render_post_list( $related_posts, $block_attributes );
if ( empty( $attributes['isServerRendered'] ) ) {
// The get_server_rendered_html() path won't register a block,
// so only apply block supports when not server rendered.
$wrapper_attributes = \WP_Block_Supports::get_instance()->apply_block_supports();