Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
47.67% covered (danger)
47.67%
409 / 858
21.57% covered (danger)
21.57%
11 / 51
CRAP
0.00% covered (danger)
0.00%
0 / 2
Jetpack_RelatedPosts
48.57% covered (danger)
48.57%
409 / 842
23.91% covered (danger)
23.91%
11 / 46
9310.46
0.00% covered (danger)
0.00%
0 / 1
 init
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 init_raw
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 __construct
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 get_blog_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 action_admin_init
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 action_frontend_init
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
8.30
 get_headline
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 filter_add_target_to_dom
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
11.19
 get_server_rendered_html
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 test_for_shortcode
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_client_rendered_html
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 get_client_rendered_html_unsupported
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 render_block_item
50.00% covered (danger)
50.00%
26 / 52
0.00% covered (danger)
0.00%
0 / 1
26.12
 render_post_list
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 render_block
76.47% covered (warning)
76.47%
39 / 51
0.00% covered (danger)
0.00%
0 / 1
28.30
 parse_numeric_get_arg
33.33% covered (danger)
33.33%
3 / 9
0.00% covered (danger)
0.00%
0 / 1
8.74
 get_options
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 get_option
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 parse_options
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
16.19
 print_setting_html
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
6
 print_setting_head
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
20
 get_for_post_id
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
5
 get_es_filters_from_args
34.62% covered (danger)
34.62%
18 / 52
0.00% covered (danger)
0.00%
0 / 1
185.01
 get_coalesced_range
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 action_frontend_init_ajax
0.00% covered (danger)
0.00%
0 / 128
0.00% covered (danger)
0.00%
0 / 1
72
 get_related_post_data_for_post
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
1
 get_title
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 get_excerpt
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 generate_related_post_image_params
90.62% covered (success)
90.62%
58 / 64
0.00% covered (danger)
0.00%
0 / 1
13.14
 to_utf8
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 get_related_posts
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 get_related_post_ids
48.28% covered (danger)
48.28%
28 / 58
0.00% covered (danger)
0.00%
0 / 1
82.03
 filter_non_public_posts
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 generate_related_post_display_author
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 generate_related_post_context_block
38.24% covered (danger)
38.24%
13 / 34
0.00% covered (danger)
0.00%
0 / 1
39.51
 generate_related_post_context
36.00% covered (danger)
36.00%
9 / 25
0.00% covered (danger)
0.00%
0 / 1
42.72
 log_click
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 enabled_for_request
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
7.07
 action_frontend_init_page
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 requires_scripts
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 enqueue_assets
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 render_amp_reader_mode_css
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setup_shortcode
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 allow_feature_toggle
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 rest_register_related_posts
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 rest_get_related_posts
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
Jetpack_RelatedPosts_Raw
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 5
30
0.00% covered (danger)
0.00%
0 / 1
 set_query_name
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 action_admin_init
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 action_frontend_init
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_options
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 get_related_posts
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
1<?php //phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2/**
3 * The Jetpack_RelatedPosts class.
4 *
5 * @package automattic/jetpack
6 */
7
8use Automattic\Jetpack\Assets;
9use Automattic\Jetpack\Blocks;
10use Automattic\Jetpack\Post_Media\Images;
11use Automattic\Jetpack\SEO\Content_Gate;
12use Automattic\Jetpack\Status\Request;
13use Automattic\Jetpack\Sync\Settings;
14
15/**
16 * The Jetpack_RelatedPosts class.
17 */
18class Jetpack_RelatedPosts {
19    const VERSION   = '20240116';
20    const SHORTCODE = 'jetpack-related-posts';
21
22    /**
23     * Instance of the class.
24     *
25     * @var Jetpack_RelatedPosts
26     */
27    private static $instance = null;
28
29    /**
30     * Instance of the raw class (?).
31     *
32     * @var Jetpack_RelatedPosts
33     */
34    private static $instance_raw = null;
35
36    /**
37     * Creates and returns a static instance of Jetpack_RelatedPosts.
38     *
39     * @return Jetpack_RelatedPosts
40     */
41    public static function init() {
42        if ( ! self::$instance ) {
43            if ( class_exists( 'WPCOM_RelatedPosts' ) && method_exists( 'WPCOM_RelatedPosts', 'init' ) ) {
44                self::$instance = WPCOM_RelatedPosts::init();
45            } else {
46                self::$instance = new Jetpack_RelatedPosts();
47            }
48        }
49
50        return self::$instance;
51    }
52
53    /**
54     * Creates and returns a static instance of Jetpack_RelatedPosts_Raw.
55     *
56     * @return Jetpack_RelatedPosts
57     */
58    public static function init_raw() {
59        if ( ! self::$instance_raw ) {
60            if ( class_exists( 'WPCOM_RelatedPosts' ) && method_exists( 'WPCOM_RelatedPosts', 'init_raw' ) ) {
61                self::$instance_raw = WPCOM_RelatedPosts::init_raw();
62            } else {
63                self::$instance_raw = new Jetpack_RelatedPosts_Raw();
64            }
65        }
66
67        return self::$instance_raw;
68    }
69
70    /**
71     * Options.
72     *
73     * @var array $options
74     */
75    protected $options;
76
77    /**
78     * Allow feature toggle variable.
79     *
80     * @var bool
81     */
82    protected $allow_feature_toggle;
83
84    /**
85     * Blog character set.
86     *
87     * @var mixed
88     */
89    protected $blog_charset;
90
91    /**
92     * Convert character set.
93     *
94     * @var bool
95     */
96    protected $convert_charset;
97
98    /**
99     * Previous Post ID
100     *
101     * @var int
102     */
103    protected $previous_post_id;
104
105    /**
106     * Shortcode usage.
107     *
108     * @var bool
109     */
110    protected $found_shortcode = false;
111
112    /**
113     * Constructor for Jetpack_RelatedPosts.
114     *
115     * @uses get_option, add_action, apply_filters
116     */
117    public function __construct() {
118        $this->blog_charset    = get_option( 'blog_charset' );
119        $this->convert_charset = ( function_exists( 'iconv' ) && ! preg_match( '/^utf\-?8$/i', $this->blog_charset ) );
120        add_action( 'admin_init', array( $this, 'action_admin_init' ) );
121        add_action( 'wp', array( $this, 'action_frontend_init' ) );
122
123        if ( ! class_exists( 'Jetpack_Media_Summary' ) ) {
124            require_once JETPACK__PLUGIN_DIR . '_inc/lib/class.media-summary.php';
125        }
126
127        // Add Related Posts to the REST API Post response.
128        add_action( 'rest_api_init', array( $this, 'rest_register_related_posts' ) );
129    }
130
131    /**
132     * Get the blog ID.
133     *
134     * @return mixed current blog id.
135     */
136    protected function get_blog_id() {
137        return Jetpack_Options::get_option( 'id' );
138    }
139
140    /**
141     * =================
142     * ACTIONS & FILTERS
143     * =================
144     */
145
146    /**
147     * Add a checkbox field to Settings > Reading for enabling related posts.
148     *
149     * @action admin_init
150     * @uses add_settings_field, __, register_setting, add_action
151     */
152    public function action_admin_init() {
153
154        // Add the setting field [jetpack_relatedposts] and place it in Settings > Reading.
155        add_settings_field( 'jetpack_relatedposts', '<span id="jetpack_relatedposts">' . __( 'Related posts', 'jetpack' ) . '</span>', array( $this, 'print_setting_html' ), 'reading' );
156        register_setting( 'reading', 'jetpack_relatedposts', array( $this, 'parse_options' ) );
157        add_action( 'admin_head', array( $this, 'print_setting_head' ) );
158
159        if ( 'options-reading.php' === $GLOBALS['pagenow'] ) {
160            // Enqueue style for live preview on the reading settings page.
161            $this->enqueue_assets( false, true );
162        }
163    }
164
165    /**
166     * Load related posts assets if it's an eligible front end page or execute search and return JSON if it's an endpoint request.
167     *
168     * @global $_GET
169     * @action wp
170     * @uses add_shortcode, get_the_ID
171     */
172    public function action_frontend_init() {
173        // Add a shortcode handler that outputs nothing, this gets overridden later if we can display related content.
174        add_shortcode( self::SHORTCODE, array( $this, 'get_client_rendered_html_unsupported' ) );
175
176        if ( ! $this->enabled_for_request() ) {
177            return;
178        }
179
180        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.
181            $excludes = $this->parse_numeric_get_arg( 'relatedposts_exclude' );
182            $this->action_frontend_init_ajax( $excludes );
183        } else {
184            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.
185                $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.
186                $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.
187            }
188
189            $this->action_frontend_init_page();
190        }
191    }
192
193    /**
194     * Render insertion point.
195     *
196     * @since 4.2.0
197     *
198     * @return string
199     */
200    public function get_headline() {
201        $options = $this->get_options();
202
203        if ( ! empty( $options['show_headline'] ) ) {
204            $headline = sprintf(
205                /** This filter is already documented in modules/sharedaddy/sharing-service.php */
206                apply_filters( 'jetpack_sharing_headline_html', '<h3 class="jp-relatedposts-headline"><em>%s</em></h3>', esc_html( $options['headline'] ), 'related-posts' ),
207                esc_html( $options['headline'] )
208            );
209        } else {
210            $headline = '';
211        }
212        return $headline;
213    }
214
215    /**
216     * Adds a target to the post content to load related posts into if a shortcode for it did not already exist.
217     * Will skip adding the target if the post content contains a Related Posts block, if the 'get_the_excerpt'
218     * hook is in the current filter list, or if the site is running an FSE/Site Editor theme.
219     *
220     * @filter the_content
221     *
222     * @param string $content Post content.
223     *
224     * @return string
225     */
226    public function filter_add_target_to_dom( $content ) {
227        // Do not output related posts for ActivityPub requests.
228        if (
229            function_exists( '\Activitypub\is_activitypub_request' )
230            && \Activitypub\is_activitypub_request()
231        ) {
232            return $content;
233        }
234
235        if ( has_block( 'jetpack/related-posts' ) || Blocks::is_fse_theme() ) {
236            return $content;
237        }
238
239        if ( ! $this->found_shortcode && ! doing_filter( 'get_the_excerpt' ) ) {
240            if ( class_exists( 'Jetpack_AMP_Support' ) && Jetpack_AMP_Support::is_amp_request() ) {
241                $content .= "\n" . $this->get_server_rendered_html();
242            } else {
243                $content .= "\n" . $this->get_client_rendered_html();
244            }
245        }
246
247        return $content;
248    }
249
250    /**
251     * Render static markup based on the Gutenberg block code
252     *
253     * @return string Rendered related posts HTML.
254     */
255    public function get_server_rendered_html() {
256        $rp_settings       = $this->get_options();
257        $block_rp_settings = array(
258            'displayThumbnails' => $rp_settings['show_thumbnails'],
259            'showHeadline'      => $rp_settings['show_headline'],
260            'displayDate'       => isset( $rp_settings['show_date'] ) ? (bool) $rp_settings['show_date'] : true,
261            'displayContext'    => isset( $rp_settings['show_context'] ) && $rp_settings['show_context'],
262            'postLayout'        => $rp_settings['layout'] ?? 'grid',
263            'postsToShow'       => $rp_settings['size'] ?? 3,
264            /** This filter is already documented in modules/related-posts/jetpack-related-posts.php */
265            'headline'          => apply_filters( 'jetpack_relatedposts_filter_headline', $this->get_headline() ),
266            'isServerRendered'  => true,
267        );
268
269        return $this->render_block( $block_rp_settings, '' );
270    }
271
272    /**
273     * Looks for our shortcode on the unfiltered content, this has to execute early.
274     *
275     * @filter the_content
276     * @param string $content - content of the post.
277     * @uses has_shortcode
278     * @return string $content
279     */
280    public function test_for_shortcode( $content ) {
281        $this->found_shortcode = has_shortcode( $content, self::SHORTCODE );
282
283        return $content;
284    }
285
286    /**
287     * Returns the HTML for the related posts section.
288     *
289     * @uses esc_html__, apply_filters
290     * @return string
291     */
292    public function get_client_rendered_html() {
293        if ( Settings::is_syncing() ) {
294            return '';
295        }
296
297        /**
298         * Filter the Related Posts headline.
299         *
300         * @module related-posts
301         *
302         * @since 3.0.0
303         *
304         * @param string $headline Related Posts heading.
305         */
306        $headline = apply_filters( 'jetpack_relatedposts_filter_headline', $this->get_headline() );
307
308        if ( $this->previous_post_id ) {
309            $exclude = "data-exclude='{$this->previous_post_id}'";
310        } else {
311            $exclude = '';
312        }
313
314        return <<<EOT
315<div id='jp-relatedposts' class='jp-relatedposts' $exclude>
316    $headline
317</div>
318EOT;
319    }
320
321    /**
322     * 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.
323     *
324     * @return string
325     */
326    public function get_client_rendered_html_unsupported() {
327        if ( Settings::is_syncing() ) {
328            return '';
329        }
330        return "\n\n<!-- Jetpack Related Posts is not supported in this context. -->\n\n";
331    }
332
333    /**
334     * ===============
335     * GUTENBERG BLOCK
336     * ===============
337     */
338
339    /**
340     * Echoes out items for the Gutenberg block
341     *
342     * @param array $related_post The post object.
343     * @param array $block_attributes The block attributes.
344     */
345    public function render_block_item( $related_post, $block_attributes ) {
346        $instance_id = 'related-posts-item-' . uniqid();
347        $label_id    = $instance_id . '-label';
348        $title       = $related_post['title'];
349        $url         = $related_post['url'];
350        $rel         = $related_post['rel'];
351        $img         = '';
352        $list        = '';
353
354        $item_markup = sprintf(
355            '<li id="%1$s" class="jp-related-posts-i2__post">',
356            esc_attr( $instance_id )
357        );
358
359        // Thumbnail
360        if ( ! empty( $block_attributes['show_thumbnails'] ) && ! empty( $related_post['img']['src'] ) ) {
361            $img = sprintf(
362                '<img loading="lazy" class="jp-related-posts-i2__post-img" src="%1$s" alt="%2$s" %3$s/>',
363                esc_url( $related_post['img']['src'] ),
364                esc_attr( $related_post['img']['alt_text'] ),
365                ( ! empty( $related_post['img']['srcset'] ) ? 'srcset="' . esc_attr( $related_post['img']['srcset'] ) . '"' : '' )
366            );
367        }
368
369        // Link
370        $item_markup .= sprintf(
371            '<a id="%1$s" href="%2$s" class="jp-related-posts-i2__post-link" %3$s>%4$s%5$s</a>',
372            esc_attr( $label_id ),
373            esc_url( $url ),
374            ( ! empty( $rel ) ? 'rel="' . esc_attr( $rel ) . '"' : '' ),
375            esc_html( $title ),
376            $img
377        );
378
379        // Date
380        if ( $block_attributes['show_date'] ) {
381            $list .= '<dt>' . __( 'Date', 'jetpack' ) . '</dt>';
382            $list .= '<dd class="jp-related-posts-i2__post-date">';
383            $list .= esc_html( $related_post['date'] );
384            $list .= '</dd>';
385        }
386
387        // Author
388        if ( $block_attributes['show_author'] ) {
389            $list .= '<dt>' . __( 'Author', 'jetpack' ) . '</dt>';
390            $list .= '<dd class="jp-related-posts-i2__post-author">';
391            $list .= esc_html( $related_post['author'] );
392            $list .= '</dd>';
393        }
394
395        // Context
396        if ( ( $block_attributes['show_context'] ) && ! empty( $related_post['block_context'] ) ) {
397            // translators: this is followed by the reason why the item is related to the current post
398            $list .= '<dt>' . __( 'In relation to', 'jetpack' ) . '</dt>';
399            $list .= '<dd class="jp-related-posts-i2__post-context">';
400
401            // Note: The original 'context' value is not used when rendering the block.
402            // It is still generated and available for the legacy rendering code path though.
403            // See './related-posts.js' for that usage.
404            $block_context = $related_post['block_context'];
405
406            if ( ! empty( $block_context['link'] ) ) {
407                $list .= sprintf(
408                    '<a href="%1$s">%2$s</a>',
409                    esc_url( $block_context['link'] ),
410                    esc_html( $block_context['text'] )
411                );
412            } else {
413                $list .= esc_html( $block_context['text'] );
414            }
415
416            $list .= '</dd>';
417        }
418
419        // Metadata
420        if ( ! empty( $list ) ) {
421            $item_markup .= '<dl class="jp-related-posts-i2__post-defs">' . $list . '</dl>';
422        }
423
424        $item_markup .= '</li>';
425
426        return $item_markup;
427    }
428
429    /**
430     * Render the list of related posts.
431     *
432     * @param array $posts The posts to render into the list.
433     * @param array $block_attributes Block attributes.
434     * @return string
435     */
436    public function render_post_list( $posts, $block_attributes ) {
437        $markup = '';
438
439        foreach ( $posts as $post ) {
440            $markup .= $this->render_block_item( $post, $block_attributes );
441        }
442
443        return sprintf(
444            // role="list" is required for accessibility as VoiceOver ignores unstyled lists.
445            '<ul class="jp-related-posts-i2__list" role="list" data-post-count="%1$s">%2$s</ul>',
446            count( $posts ),
447            $markup
448        );
449    }
450
451    /**
452     * Render the related posts markup.
453     *
454     * @param array    $attributes Block attributes.
455     * @param string   $content    String containing the related Posts block content.
456     * @param WP_Block $block    The block object.
457     * @return string
458     */
459    public function render_block( $attributes, $content, $block = null ) {
460        if ( ! Request::is_frontend() ) {
461            return $content;
462        }
463
464        $wrapper_attributes = array();
465        $post_id            = get_the_ID();
466        $block_attributes   = array(
467            'headline'        => $attributes['headline'] ?? null,
468            'show_thumbnails' => isset( $attributes['displayThumbnails'] ) && $attributes['displayThumbnails'],
469            'show_author'     => isset( $attributes['displayAuthor'] ) ? (bool) $attributes['displayAuthor'] : false,
470            'show_headline'   => isset( $attributes['displayHeadline'] ) ? (bool) $attributes['displayHeadline'] : false,
471            'show_date'       => isset( $attributes['displayDate'] ) ? (bool) $attributes['displayDate'] : true,
472            'show_context'    => isset( $attributes['displayContext'] ) && $attributes['displayContext'],
473            'layout'          => isset( $attributes['postLayout'] ) && 'list' === $attributes['postLayout'] ? $attributes['postLayout'] : 'grid',
474            'size'            => ! empty( $attributes['postsToShow'] ) ? absint( $attributes['postsToShow'] ) : 3,
475        );
476
477        $excludes = $this->parse_numeric_get_arg( 'relatedposts_origin' );
478
479        $related_posts = $this->get_for_post_id(
480            $post_id,
481            array(
482                'size'             => $block_attributes['size'],
483                'exclude_post_ids' => $excludes,
484            )
485        );
486
487        if ( empty( $related_posts ) ) {
488            return '';
489        }
490
491        /*
492         * The block renders through its own block callback, independently of the
493         * module's front-end asset gate (enabled_for_request()). That gate only
494         * enqueues our assets on single posts in classic themes, so a block placed
495         * on a page (or any view the gate skips) would render as unstyled HTML.
496         * Enqueue the stylesheet here, whenever the block actually outputs markup,
497         * to keep it styled everywhere it can be used. We intentionally do not widen
498         * enabled_for_request() itself: that governs the automatic the_content
499         * insertion and was deliberately scoped in #39784 to avoid showing related
500         * posts on classic-theme pages.
501         */
502        $this->enqueue_assets( false, true );
503
504        $list_markup = $this->render_post_list( $related_posts, $block_attributes );
505
506        if ( empty( $attributes['isServerRendered'] ) ) {
507            // The get_server_rendered_html() path won't register a block,
508            // so only apply block supports when not server rendered.
509            $wrapper_attributes = \WP_Block_Supports::get_instance()->apply_block_supports();
510        }
511
512        $headline_markup = '';
513
514        if ( isset( $block ) ) {
515            foreach ( $block->inner_blocks as $inner_block ) {
516                if ( 'core/heading' === $inner_block->name && ! empty( wp_strip_all_tags( $inner_block->inner_html ) ) ) {
517                    $headline_markup = trim( $inner_block->inner_html );
518                    break;
519                }
520            }
521        }
522
523        if ( empty( $headline_markup ) && $block_attributes['show_headline'] ) {
524            $headline = $block_attributes['headline'];
525            if ( strlen( trim( $headline ) ) !== 0 ) {
526                $headline_markup = sprintf(
527                    '<h3 class="jp-relatedposts-headline">%1$s</h3>',
528                    esc_html( $headline )
529                );
530            }
531        }
532
533        $display_markup = sprintf(
534            '<nav class="jp-relatedposts-i2%1$s"%2$s data-layout="%3$s" aria-label="%6$s">%4$s%5$s</nav>',
535            ! empty( $wrapper_attributes['class'] ) ? ' ' . esc_attr( $wrapper_attributes['class'] ) : '',
536            ! empty( $wrapper_attributes['style'] ) ? ' style="' . esc_attr( $wrapper_attributes['style'] ) . '"' : '',
537            esc_attr( $block_attributes['layout'] ),
538            $headline_markup,
539            $list_markup,
540            empty( $headline_markup ) ? esc_attr__( 'Related Posts', 'jetpack' ) : esc_attr( wp_strip_all_tags( $headline_markup ) )
541        );
542
543        /**
544         * Filter the output HTML of Related Posts.
545         *
546         * @module related-posts
547         *
548         * @since 10.7
549         *
550         * @param string $display_markup HTML output of Related Posts.
551         * @param int|false get_the_ID() Post ID of the post for which we are retrieving Related Posts.
552         * @param array $related_posts Array of related posts.
553         * @param array $block_attributes Array of Block attributes.
554         */
555        return (string) apply_filters( 'jetpack_related_posts_display_markup', $display_markup, $post_id, $related_posts, $block_attributes );
556    }
557
558    /**
559     * ========================
560     * PUBLIC UTILITY FUNCTIONS
561     * ========================
562     */
563
564    /**
565     * Parse a numeric GET variable to an array of values.
566     *
567     * @since 6.9.0
568     *
569     * @uses absint
570     *
571     * @param string $arg Name of the GET variable.
572     * @return array $result Parsed value(s)
573     */
574    public function parse_numeric_get_arg( $arg ) {
575        $result = array();
576
577        if ( isset( $_GET[ $arg ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- requests are used to generate a list of related posts we want to exclude.
578            if ( is_string( $_GET[ $arg ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
579                $result = explode( ',', sanitize_text_field( wp_unslash( $_GET[ $arg ] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
580            } elseif ( is_array( $_GET[ $arg ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
581                $args   = array_map( 'sanitize_text_field', wp_unslash( $_GET[ $arg ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
582                $result = array_values( $args );
583            }
584
585            $result = array_unique( array_filter( array_map( 'absint', $result ) ) );
586        }
587
588        return $result;
589    }
590
591    /**
592     * Gets options set for Jetpack_RelatedPosts and merge with defaults.
593     *
594     * @uses Jetpack_Options::get_option, apply_filters
595     * @return array
596     */
597    public function get_options() {
598        if ( null === $this->options ) {
599            $this->options = Jetpack_Options::get_option( 'relatedposts', array() );
600            if ( ! is_array( $this->options ) ) {
601                $this->options = array();
602            }
603            if ( ! isset( $this->options['enabled'] ) ) {
604                $this->options['enabled'] = true;
605            }
606            if ( ! isset( $this->options['show_headline'] ) ) {
607                $this->options['show_headline'] = true;
608            }
609            if ( ! isset( $this->options['show_thumbnails'] ) ) {
610                $this->options['show_thumbnails'] = false;
611            }
612            if ( ! isset( $this->options['show_date'] ) ) {
613                $this->options['show_date'] = true;
614            }
615            if ( ! isset( $this->options['show_context'] ) ) {
616                $this->options['show_context'] = true;
617            }
618            if ( ! isset( $this->options['layout'] ) ) {
619                $this->options['layout'] = 'grid';
620            }
621            if ( ! isset( $this->options['headline'] ) ) {
622                $this->options['headline'] = esc_html__( 'Related', 'jetpack' );
623            }
624            if ( empty( $this->options['size'] ) || (int) $this->options['size'] < 1 ) {
625                $this->options['size'] = 3;
626            }
627
628            /**
629             * Filter Related Posts basic options.
630             *
631             * @module related-posts
632             *
633             * @since 2.8.0
634             *
635             * @param array $this->_options Array of basic Related Posts options.
636             */
637            $this->options = apply_filters( 'jetpack_relatedposts_filter_options', $this->options );
638        }
639
640        return $this->options;
641    }
642
643    /**
644     * Gets options.
645     *
646     * @param string $option_name - option we want to get.
647     */
648    public function get_option( $option_name ) {
649        $options = $this->get_options();
650
651        if ( isset( $options[ $option_name ] ) ) {
652            return $options[ $option_name ];
653        }
654
655        return false;
656    }
657
658    /**
659     * Parses input and returns normalized options array.
660     *
661     * @param array $input - input we're parsing.
662     * @uses self::get_options
663     * @return array
664     */
665    public function parse_options( $input ) {
666        $current = $this->get_options();
667
668        if ( ! is_array( $input ) ) {
669            $input = array();
670        }
671
672        if (
673            ! isset( $input['enabled'] )
674            || isset( $input['show_date'] )
675            || isset( $input['show_context'] )
676            || isset( $input['layout'] )
677            || isset( $input['headline'] )
678            ) {
679            $input['enabled'] = '1';
680        }
681
682        if ( '1' == $input['enabled'] ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- expecting string, but may return bools.
683            $current['enabled']         = true;
684            $current['show_headline']   = ( isset( $input['show_headline'] ) && '1' == $input['show_headline'] ); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
685            $current['show_thumbnails'] = ( isset( $input['show_thumbnails'] ) && '1' == $input['show_thumbnails'] ); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
686            $current['show_date']       = ( isset( $input['show_date'] ) && '1' == $input['show_date'] ); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
687            $current['show_context']    = ( isset( $input['show_context'] ) && '1' == $input['show_context'] ); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
688            $current['layout']          = isset( $input['layout'] ) && in_array( $input['layout'], array( 'grid', 'list' ), true ) ? $input['layout'] : 'grid';
689            $current['headline']        = $input['headline'] ?? esc_html__( 'Related', 'jetpack' );
690        } else {
691            $current['enabled'] = false;
692        }
693
694        if ( isset( $input['size'] ) && (int) $input['size'] > 0 ) {
695            $current['size'] = (int) $input['size'];
696        } else {
697            $current['size'] = null;
698        }
699        return $current;
700    }
701
702    /**
703     * HTML for admin settings page.
704     *
705     * @uses self::get_options, checked, esc_html__
706     */
707    public function print_setting_html() {
708        $options = $this->get_options();
709
710        $ui_settings_template = <<<'EOT'
711<p class="description">%s</p>
712<ul id="settings-reading-relatedposts-customize">
713    <li>
714        <label><input name="jetpack_relatedposts[show_headline]" type="checkbox" value="1" %s /> %s</label>
715    </li>
716    <li>
717        <label><input name="jetpack_relatedposts[show_thumbnails]" type="checkbox" value="1" %s /> %s</label>
718    </li>
719    <li>
720        <label><input name="jetpack_relatedposts[show_date]" type="checkbox" value="1" %s /> %s</label>
721    </li>
722    <li>
723        <label><input name="jetpack_relatedposts[show_context]" type="checkbox" value="1" %s /> %s</label>
724    </li>
725</ul>
726<div id='settings-reading-relatedposts-preview'>
727    %s
728    <div id="jp-relatedposts" class="jp-relatedposts"></div>
729</div>
730EOT;
731        $ui_settings          = sprintf(
732            $ui_settings_template,
733            esc_html__( 'The following settings will impact all related posts on your site, except for those you created via the block editor:', 'jetpack' ),
734            checked( $options['show_headline'], true, false ),
735            esc_html__( 'Highlight related content with a heading', 'jetpack' ),
736            checked( $options['show_thumbnails'], true, false ),
737            esc_html__( 'Show a thumbnail image where available', 'jetpack' ),
738            checked( $options['show_date'], true, false ),
739            esc_html__( 'Show entry date', 'jetpack' ),
740            checked( $options['show_context'], true, false ),
741            esc_html__( 'Show context (category or tag)', 'jetpack' ),
742            esc_html__( 'Preview:', 'jetpack' )
743        );
744
745        if ( ! $this->allow_feature_toggle() ) {
746            $template = <<<'EOT'
747<input type="hidden" name="jetpack_relatedposts[enabled]" value="1" />
748%s
749EOT;
750            printf(
751                $template, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
752                $ui_settings // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- data is escaped when variable is set.
753            );
754        } else {
755            $template = <<<'EOT'
756<ul id="settings-reading-relatedposts">
757    <li>
758        <label><input type="radio" name="jetpack_relatedposts[enabled]" value="0" class="tog" %s /> %s</label>
759    </li>
760    <li>
761        <label><input type="radio" name="jetpack_relatedposts[enabled]" value="1" class="tog" %s /> %s</label>
762        %s
763    </li>
764</ul>
765EOT;
766            printf(
767                $template, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
768                checked( $options['enabled'], false, false ),
769                esc_html__( 'Hide related content after posts', 'jetpack' ),
770                checked( $options['enabled'], true, false ),
771                esc_html__( 'Show related content after posts', 'jetpack' ),
772                $ui_settings // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- data is escaped when variable is set.
773            );
774        }
775    }
776
777    /**
778     * Head JS/CSS for admin settings page.
779     *
780     * @uses esc_html__
781     * @return null
782     */
783    public function print_setting_head() {
784
785        // only dislay the Related Posts JavaScript on the Reading Settings Admin Page.
786        $current_screen = get_current_screen();
787
788        if ( $current_screen === null ) {
789            return;
790        }
791
792        if ( 'options-reading' !== $current_screen->id ) {
793            return;
794        }
795
796        $related_headline = sprintf(
797            '<h3 class="jp-relatedposts-headline"><em>%s</em></h3>',
798            esc_html__( 'Related', 'jetpack' )
799        );
800
801        $href_params            = 'class="jp-relatedposts-post-a" href="#jetpack_relatedposts" rel="nofollow" data-origin="0" data-position="0"';
802        $related_with_images    = <<<EOT
803<div class="jp-relatedposts-items jp-relatedposts-items-visual">
804    <div class="jp-relatedposts-post jp-relatedposts-post0 jp-relatedposts-post-thumbs" data-post-id="0" data-post-format="image">
805        <a $href_params>
806            <img class="jp-relatedposts-post-img" src="https://jetpackme.files.wordpress.com/2019/03/cat-blog.png" width="350" alt="Big iPhone/iPad Update Now Available" scale="0">
807        </a>
808        <h4 class="jp-relatedposts-post-title">
809            <a $href_params>Big iPhone/iPad Update Now Available</a>
810        </h4>
811        <p class="jp-relatedposts-post-excerpt">Big iPhone/iPad Update Now Available</p>
812        <p class="jp-relatedposts-post-context">In "Mobile"</p>
813    </div>
814    <div class="jp-relatedposts-post jp-relatedposts-post1 jp-relatedposts-post-thumbs" data-post-id="0" data-post-format="image">
815        <a $href_params>
816            <img class="jp-relatedposts-post-img" src="https://jetpackme.files.wordpress.com/2019/03/devices.jpg" width="350" alt="The WordPress for Android App Gets a Big Facelift" scale="0">
817        </a>
818        <h4 class="jp-relatedposts-post-title">
819            <a $href_params>The WordPress for Android App Gets a Big Facelift</a>
820        </h4>
821        <p class="jp-relatedposts-post-excerpt">The WordPress for Android App Gets a Big Facelift</p>
822        <p class="jp-relatedposts-post-context">In "Mobile"</p>
823    </div>
824    <div class="jp-relatedposts-post jp-relatedposts-post2 jp-relatedposts-post-thumbs" data-post-id="0" data-post-format="image">
825        <a $href_params>
826            <img class="jp-relatedposts-post-img" src="https://jetpackme.files.wordpress.com/2019/03/mobile-wedding.jpg" width="350" alt="Upgrade Focus: VideoPress For Weddings" scale="0">
827        </a>
828        <h4 class="jp-relatedposts-post-title">
829            <a $href_params>Upgrade Focus: VideoPress For Weddings</a>
830        </h4>
831        <p class="jp-relatedposts-post-excerpt">Upgrade Focus: VideoPress For Weddings</p>
832        <p class="jp-relatedposts-post-context">In "Upgrade"</p>
833    </div>
834</div>
835EOT;
836        $related_with_images    = str_replace( "\n", '', $related_with_images );
837        $related_without_images = <<<EOT
838<div class="jp-relatedposts-items jp-relatedposts-items-minimal">
839    <p class="jp-relatedposts-post jp-relatedposts-post0" data-post-id="0" data-post-format="image">
840        <span class="jp-relatedposts-post-title"><a $href_params>Big iPhone/iPad Update Now Available</a></span>
841        <span class="jp-relatedposts-post-context">In "Mobile"</span>
842    </p>
843    <p class="jp-relatedposts-post jp-relatedposts-post1" data-post-id="0" data-post-format="image">
844        <span class="jp-relatedposts-post-title"><a $href_params>The WordPress for Android App Gets a Big Facelift</a></span>
845        <span class="jp-relatedposts-post-context">In "Mobile"</span>
846    </p>
847    <p class="jp-relatedposts-post jp-relatedposts-post2" data-post-id="0" data-post-format="image">
848        <span class="jp-relatedposts-post-title"><a $href_params>Upgrade Focus: VideoPress For Weddings</a></span>
849        <span class="jp-relatedposts-post-context">In "Upgrade"</span>
850    </p>
851</div>
852EOT;
853        $related_without_images = str_replace( "\n", '', $related_without_images );
854
855        if ( $this->allow_feature_toggle() ) {
856            $extra_css = '#settings-reading-relatedposts-customize { padding-left:2em; margin-top:.5em; }';
857        } else {
858            $extra_css = '';
859        }
860        // phpcs:disable WordPress.Security.EscapeOutput.HeredocOutputNotEscaped -- Escaped above where needed.
861        echo <<<EOT
862<style type="text/css">
863    #settings-reading-relatedposts .disabled { opacity:.5; filter:Alpha(opacity=50); }
864    #settings-reading-relatedposts-preview .jp-relatedposts { background:#fff; padding:.5em; width:75%; }
865    $extra_css
866</style>
867<script type="text/javascript">
868    jQuery( document ).ready( function($) {
869        var update_ui = function() {
870            var is_enabled = true;
871            if ( 'radio' == $( 'input[name="jetpack_relatedposts[enabled]"]' ).attr('type') ) {
872                if ( '0' == $( 'input[name="jetpack_relatedposts[enabled]"]:checked' ).val() ) {
873                    is_enabled = false;
874                }
875            }
876            if ( is_enabled ) {
877                $( '#settings-reading-relatedposts-customize' )
878                    .removeClass( 'disabled' )
879                    .find( 'input' )
880                    .attr( 'disabled', false );
881                $( '#settings-reading-relatedposts-preview' )
882                    .removeClass( 'disabled' );
883            } else {
884                $( '#settings-reading-relatedposts-customize' )
885                    .addClass( 'disabled' )
886                    .find( 'input' )
887                    .attr( 'disabled', true );
888                $( '#settings-reading-relatedposts-preview' )
889                    .addClass( 'disabled' );
890            }
891        };
892
893        var update_preview = function() {
894            var html = '';
895            if ( $( 'input[name="jetpack_relatedposts[show_headline]"]:checked' ).length ) {
896                html += '$related_headline';
897            }
898            if ( $( 'input[name="jetpack_relatedposts[show_thumbnails]"]:checked' ).length ) {
899                html += '$related_with_images';
900            } else {
901                html += '$related_without_images';
902            }
903            $( '#settings-reading-relatedposts-preview .jp-relatedposts' ).html( html );
904            if ( $( 'input[name="jetpack_relatedposts[show_date]"]:checked' ).length ) {
905                $( '.jp-relatedposts-post-title' ).each( function() {
906                    $( this ).after( $( '<span>August 8, 2005</span>' ) );
907                } );
908            }
909            if ( $( 'input[name="jetpack_relatedposts[show_context]"]:checked' ).length ) {
910                $( '.jp-relatedposts-post-context' ).show();
911            } else {
912                $( '.jp-relatedposts-post-context' ).hide();
913            }
914            $( '#settings-reading-relatedposts-preview .jp-relatedposts' ).show();
915        };
916
917        // Update on load
918        update_preview();
919        update_ui();
920
921        // Update on change
922        $( '#settings-reading-relatedposts-customize input' )
923            .change( update_preview );
924        $( '#settings-reading-relatedposts' )
925            .find( 'input.tog' )
926            .change( update_ui );
927    });
928</script>
929EOT;
930        // phpcs:enable WordPress.Security.EscapeOutput.HeredocOutputNotEscaped
931    }
932
933    /**
934     * Gets an array of related posts that match the given post_id.
935     *
936     * @param int   $post_id Post which we want to find related posts for.
937     * @param array $args - params to use when building Elasticsearch filters to narrow down the search domain.
938     * @uses self::get_options, get_post_type, wp_parse_args, apply_filters
939     * @return array
940     */
941    public function get_for_post_id( $post_id, array $args ) {
942        $options = $this->get_options();
943
944        if ( ! empty( $args['size'] ) ) {
945            $options['size'] = $args['size'];
946        }
947
948        if (
949            empty( $options['enabled'] )
950            || 0 === (int) $post_id
951            || empty( $options['size'] )
952        ) {
953            return array();
954        }
955
956        $defaults = array(
957            'size'             => (int) $options['size'],
958            'post_type'        => get_post_type( $post_id ),
959            'post_formats'     => array(),
960            'has_terms'        => array(),
961            'date_range'       => array(),
962            'exclude_post_ids' => array(),
963        );
964        $args     = wp_parse_args( $args, $defaults );
965        /**
966         * Filter the arguments used to retrieve a list of Related Posts.
967         *
968         * @module related-posts
969         *
970         * @since 2.8.0
971         *
972         * @param array $args Array of options to retrieve Related Posts.
973         * @param string $post_id Post ID of the post for which we are retrieving Related Posts.
974         */
975        $args = apply_filters( 'jetpack_relatedposts_filter_args', $args, $post_id );
976
977        $filters = $this->get_es_filters_from_args( $post_id, $args );
978        /**
979         * Filter Elasticsearch options used to calculate Related Posts.
980         *
981         * @module related-posts
982         *
983         * @since 2.8.0
984         *
985         * @param array $filters Array of Elasticsearch filters based on the post_id and args.
986         * @param string $post_id Post ID of the post for which we are retrieving Related Posts.
987         */
988        $filters = apply_filters( 'jetpack_relatedposts_filter_filters', $filters, $post_id );
989
990        $results = $this->get_related_posts( $post_id, $args['size'], $filters );
991        /**
992         * Filter the array of related posts matched by Elasticsearch.
993         *
994         * @module related-posts
995         *
996         * @since 2.8.0
997         *
998         * @param array $results Array of related posts matched by Elasticsearch.
999         * @param int $post_id Post ID of the post for which we are retrieving Related Posts.
1000         */
1001        return apply_filters( 'jetpack_relatedposts_returned_results', $results, $post_id );
1002    }
1003
1004    /**
1005     * =========================
1006     * PRIVATE UTILITY FUNCTIONS
1007     * =========================
1008     */
1009
1010    /**
1011     * Creates an array of Elasticsearch filters based on the post_id and args.
1012     *
1013     * @param int   $post_id - the post ID.
1014     * @param array $args - the arguments.
1015     * @uses apply_filters, get_post_types, get_post_format_strings
1016     * @return array
1017     */
1018    protected function get_es_filters_from_args( $post_id, array $args ) {
1019        $filters = array();
1020
1021        /**
1022         * Filter the terms used to search for Related Posts.
1023         *
1024         * @module related-posts
1025         *
1026         * @since 2.8.0
1027         *
1028         * @param array $args['has_terms'] Array of terms associated to the Related Posts.
1029         * @param string $post_id Post ID of the post for which we are retrieving Related Posts.
1030         */
1031        $args['has_terms'] = apply_filters( 'jetpack_relatedposts_filter_has_terms', $args['has_terms'], $post_id );
1032        if ( ! empty( $args['has_terms'] ) ) {
1033            foreach ( (array) $args['has_terms'] as $term ) {
1034                if ( mb_strlen( $term->taxonomy ) ) {
1035                    switch ( $term->taxonomy ) {
1036                        case 'post_tag':
1037                            $tax_fld = 'tag.slug';
1038                            break;
1039                        case 'category':
1040                            $tax_fld = 'category.slug';
1041                            break;
1042                        default:
1043                            $tax_fld = 'taxonomy.' . $term->taxonomy . '.slug';
1044                            break;
1045                    }
1046                    $filters[] = array( 'term' => array( $tax_fld => $term->slug ) );
1047                }
1048            }
1049        }
1050
1051        /**
1052         * Filter the Post Types where we search Related Posts.
1053         *
1054         * @module related-posts
1055         *
1056         * @since 2.8.0
1057         *
1058         * @param array $args['post_type'] Array of Post Types.
1059         * @param string $post_id Post ID of the post for which we are retrieving Related Posts.
1060         */
1061        $args['post_type'] = apply_filters( 'jetpack_relatedposts_filter_post_type', $args['post_type'], $post_id );
1062        $valid_post_types  = get_post_types();
1063        if ( is_array( $args['post_type'] ) ) {
1064            $sanitized_post_types = array();
1065            foreach ( $args['post_type'] as $pt ) {
1066                if ( in_array( $pt, $valid_post_types, true ) ) {
1067                    $sanitized_post_types[] = $pt;
1068                }
1069            }
1070            if ( ! empty( $sanitized_post_types ) ) {
1071                $filters[] = array( 'terms' => array( 'post_type' => $sanitized_post_types ) );
1072            }
1073        } elseif ( in_array( $args['post_type'], $valid_post_types, true ) && 'all' !== $args['post_type'] ) {
1074                $filters[] = array( 'term' => array( 'post_type' => $args['post_type'] ) );
1075        }
1076
1077        /**
1078         * Filter the Post Formats where we search Related Posts.
1079         *
1080         * @module related-posts
1081         *
1082         * @since 3.3.0
1083         *
1084         * @param array $args['post_formats'] Array of Post Formats.
1085         * @param string $post_id Post ID of the post for which we are retrieving Related Posts.
1086         */
1087        $args['post_formats']   = apply_filters( 'jetpack_relatedposts_filter_post_formats', $args['post_formats'], $post_id );
1088        $valid_post_formats     = get_post_format_strings();
1089        $sanitized_post_formats = array();
1090        foreach ( $args['post_formats'] as $pf ) {
1091            if ( array_key_exists( $pf, $valid_post_formats ) ) {
1092                $sanitized_post_formats[] = $pf;
1093            }
1094        }
1095        if ( ! empty( $sanitized_post_formats ) ) {
1096            $filters[] = array( 'terms' => array( 'post_format' => $sanitized_post_formats ) );
1097        }
1098
1099        /**
1100         * Filter the date range used to search Related Posts.
1101         *
1102         * @module related-posts
1103         *
1104         * @since 2.8.0
1105         *
1106         * @param array $args['date_range'] Array of a month interval where we search Related Posts.
1107         * @param string $post_id Post ID of the post for which we are retrieving Related Posts.
1108         */
1109        $args['date_range'] = apply_filters( 'jetpack_relatedposts_filter_date_range', $args['date_range'], $post_id );
1110        if ( is_array( $args['date_range'] ) && ! empty( $args['date_range'] ) ) {
1111            $args['date_range'] = array_map( 'intval', $args['date_range'] );
1112            if ( ! empty( $args['date_range']['from'] ) && ! empty( $args['date_range']['to'] ) ) {
1113                $filters[] = array(
1114                    'range' => array(
1115                        'date_gmt' => $this->get_coalesced_range( $args['date_range'] ),
1116                    ),
1117                );
1118            }
1119        }
1120
1121        /**
1122         * Filter the Post IDs excluded from appearing in Related Posts.
1123         *
1124         * @module related-posts
1125         *
1126         * @since 2.9.0
1127         *
1128         * @param array $args['exclude_post_ids'] Array of Post IDs.
1129         * @param string $post_id Post ID of the post for which we are retrieving Related Posts.
1130         */
1131        $args['exclude_post_ids'] = apply_filters( 'jetpack_relatedposts_filter_exclude_post_ids', $args['exclude_post_ids'], $post_id );
1132        if ( ! empty( $args['exclude_post_ids'] ) && is_array( $args['exclude_post_ids'] ) ) {
1133            $excluded_post_ids = array();
1134            foreach ( $args['exclude_post_ids'] as $exclude_post_id ) {
1135                $exclude_post_id = (int) $exclude_post_id;
1136                if ( $exclude_post_id > 0 ) {
1137                    $excluded_post_ids[] = $exclude_post_id;
1138                }
1139            }
1140            $filters[] = array( 'not' => array( 'terms' => array( 'post_id' => $excluded_post_ids ) ) );
1141        }
1142
1143        return $filters;
1144    }
1145
1146    /**
1147     * Takes a range and coalesces it into a month interval bracketed by a time as determined by the blog_id to enhance caching.
1148     *
1149     * @todo Rewrite this function with proper date handling rather than `strtotime()` and `date()`.
1150     *
1151     * @param array $date_range - the date range.
1152     * @return array
1153     */
1154    protected function get_coalesced_range( array $date_range ) {
1155        $now           = time();
1156        $coalesce_time = $this->get_blog_id() % 86400;
1157        $current_time  = $now - strtotime( 'today', $now );
1158
1159        if ( $current_time < $coalesce_time && '01' === date( 'd', $now ) ) { // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
1160            // Move back 1 period.
1161            return array(
1162                'from' => date( 'Y-m-01', strtotime( '-1 month', $date_range['from'] ) ) . ' ' . date( 'H:i:s', $coalesce_time ), //phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
1163                'to'   => date( 'Y-m-01', $date_range['to'] ) . ' ' . date( 'H:i:s', $coalesce_time ), //phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
1164            );
1165        } else {
1166            // Use current period.
1167            return array(
1168                'from' => date( 'Y-m-01', $date_range['from'] ) . ' ' . date( 'H:i:s', $coalesce_time ), //phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
1169                'to'   => date( 'Y-m-01', strtotime( '+1 month', $date_range['to'] ) ) . ' ' . date( 'H:i:s', $coalesce_time ), //phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
1170            );
1171        }
1172    }
1173
1174    /**
1175     * Generate and output ajax response for related posts API call.
1176     * NOTE: Calls exit() to end all further processing after payload has been outputed.
1177     *
1178     * @param array $excludes array of post_ids to exclude.
1179     * @uses send_nosniff_header, self::get_for_post_id, get_the_ID
1180     * @return never
1181     */
1182    protected function action_frontend_init_ajax( array $excludes ) {
1183        define( 'DOING_AJAX', true );
1184
1185        header( 'Content-type: application/json; charset=utf-8' ); // JSON can only be UTF-8.
1186        send_nosniff_header();
1187
1188        $options = $this->get_options();
1189
1190        if ( isset( $_GET['jetpackrpcustomize'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- adds dummy content if we're in the customizer.
1191
1192            // If we're in the customizer, add dummy content.
1193            $date_now      = current_time( get_option( 'date_format' ) );
1194            $related_posts = array(
1195                array(
1196                    'id'       => - 1,
1197                    'url'      => 'https://jetpackme.files.wordpress.com/2019/03/cat-blog.png',
1198                    'url_meta' => array(
1199                        'origin'   => 0,
1200                        'position' => 0,
1201                    ),
1202                    'title'    => esc_html__( 'Big iPhone/iPad Update Now Available', 'jetpack' ),
1203                    'date'     => $date_now,
1204                    'format'   => false,
1205                    'excerpt'  => esc_html__( 'It is that time of the year when devices are shiny again.', 'jetpack' ),
1206                    'rel'      => 'nofollow',
1207                    'context'  => esc_html__( 'In "Mobile"', 'jetpack' ),
1208                    'img'      => array(
1209                        'src'    => 'https://jetpackme.files.wordpress.com/2019/03/cat-blog.png',
1210                        'width'  => 350,
1211                        'height' => 200,
1212                    ),
1213                    'classes'  => array(),
1214                ),
1215                array(
1216                    'id'       => - 1,
1217                    'url'      => 'https://jetpackme.files.wordpress.com/2019/03/devices.jpg',
1218                    'url_meta' => array(
1219                        'origin'   => 0,
1220                        'position' => 0,
1221                    ),
1222                    'title'    => esc_html__( 'The WordPress for Android App Gets a Big Facelift', 'jetpack' ),
1223                    'date'     => $date_now,
1224                    'format'   => false,
1225                    'excerpt'  => esc_html__( 'Writing is new again in Android with the new WordPress app.', 'jetpack' ),
1226                    'rel'      => 'nofollow',
1227                    'context'  => esc_html__( 'In "Mobile"', 'jetpack' ),
1228                    'img'      => array(
1229                        'src'    => 'https://jetpackme.files.wordpress.com/2019/03/devices.jpg',
1230                        'width'  => 350,
1231                        'height' => 200,
1232                    ),
1233                    'classes'  => array(),
1234                ),
1235                array(
1236                    'id'       => - 1,
1237                    'url'      => 'https://jetpackme.files.wordpress.com/2019/03/mobile-wedding.jpg',
1238                    'url_meta' => array(
1239                        'origin'   => 0,
1240                        'position' => 0,
1241                    ),
1242                    'title'    => esc_html__( 'Upgrade Focus, VideoPress for weddings', 'jetpack' ),
1243                    'date'     => $date_now,
1244                    'format'   => false,
1245                    'excerpt'  => esc_html__( 'Weddings are in the spotlight now with VideoPress for weddings.', 'jetpack' ),
1246                    'rel'      => 'nofollow',
1247                    'context'  => esc_html__( 'In "Mobile"', 'jetpack' ),
1248                    'img'      => array(
1249                        'src'    => 'https://jetpackme.files.wordpress.com/2019/03/mobile-wedding.jpg',
1250                        'width'  => 350,
1251                        'height' => 200,
1252                    ),
1253                    'classes'  => array(),
1254                ),
1255            );
1256
1257            for ( $total = 0; $total < $options['size'] - 3; $total++ ) {
1258                $related_posts[] = $related_posts[ $total ];
1259            }
1260
1261            $current_post = get_post();
1262
1263            // Exclude current post after filtering to make sure it's excluded and not lost during filtering.
1264            $excluded_posts = array_merge(
1265                /** This filter is already documented in modules/related-posts/jetpack-related-posts.php */
1266                apply_filters( 'jetpack_relatedposts_filter_exclude_post_ids', array() ),
1267                array( $current_post->ID )
1268            );
1269
1270            // Fetch posts with featured image.
1271            $with_post_thumbnails = get_posts(
1272                array(
1273                    'posts_per_page'   => $options['size'],
1274                    'post__not_in'     => $excluded_posts,
1275                    'post_type'        => $current_post->post_type,
1276                    'meta_key'         => '_thumbnail_id',
1277                    'suppress_filters' => false,
1278                )
1279            );
1280
1281            // If we don't have enough, fetch posts without featured image.
1282            $count_post_with_thumbnails = is_countable( $with_post_thumbnails ) ? count( $with_post_thumbnails ) : 0;
1283            $more                       = $options['size'] - $count_post_with_thumbnails;
1284            if ( 0 < $more ) {
1285                $no_post_thumbnails = get_posts(
1286                    array(
1287                        'posts_per_page'   => $more,
1288                        'post__not_in'     => $excluded_posts,
1289                        'post_type'        => $current_post->post_type,
1290                        'meta_query'       => array(
1291                            array(
1292                                'key'     => '_thumbnail_id',
1293                                'compare' => 'NOT EXISTS',
1294                            ),
1295                        ),
1296                        'suppress_filters' => false,
1297                    )
1298                );
1299            } else {
1300                $no_post_thumbnails = array();
1301            }
1302
1303            foreach ( array_merge( $with_post_thumbnails, $no_post_thumbnails ) as $index => $real_post ) {
1304                $related_posts[ $index ]['id']      = $real_post->ID;
1305                $related_posts[ $index ]['url']     = esc_url( get_permalink( $real_post ) );
1306                $related_posts[ $index ]['title']   = $this->to_utf8( $this->get_title( $real_post->post_title, $real_post->post_content, $real_post->ID ) );
1307                $related_posts[ $index ]['date']    = get_the_date( '', $real_post );
1308                $related_posts[ $index ]['excerpt'] = html_entity_decode( $this->to_utf8( $this->get_excerpt( $real_post->post_excerpt, $real_post->post_content, $real_post->ID ) ), ENT_QUOTES, 'UTF-8' );
1309                $related_posts[ $index ]['img']     = $this->generate_related_post_image_params( $real_post->ID );
1310                $related_posts[ $index ]['context'] = $this->generate_related_post_context( $real_post->ID );
1311            }
1312        } else {
1313            $related_posts = $this->get_for_post_id(
1314                get_the_ID(),
1315                array(
1316                    'exclude_post_ids' => $excludes,
1317                )
1318            );
1319        }
1320
1321        $response = array(
1322            'version'         => self::VERSION,
1323            'show_thumbnails' => (bool) ( $options['show_thumbnails'] ?? false ),
1324            'show_date'       => (bool) ( $options['show_date'] ?? true ),
1325            'show_context'    => (bool) ( $options['show_context'] ?? true ),
1326            'layout'          => (string) ( $options['layout'] ?? 'grid' ),
1327            'headline'        => (string) ( $options['headline'] ?? '' ),
1328            'items'           => array(),
1329        );
1330
1331        if ( ! empty( $options['size'] ) && count( $related_posts ) === $options['size'] ) {
1332            $response['items'] = $related_posts;
1333        }
1334
1335        // @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
1336        wp_send_json( $response, null, JSON_UNESCAPED_SLASHES );
1337    }
1338
1339    /**
1340     * Returns a UTF-8 encoded array of post information for the given post_id
1341     *
1342     * @param int $post_id - the post ID.
1343     * @param int $position - position of the post.
1344     * @param int $origin - The post id that this is related to.
1345     * @uses get_post, get_permalink, remove_query_arg, get_post_format, apply_filters
1346     * @return array
1347     */
1348    public function get_related_post_data_for_post( $post_id, $position, $origin ) {
1349        $post = get_post( $post_id );
1350        return array(
1351            'id'            => $post->ID,
1352            'url'           => get_permalink( $post->ID ),
1353            'url_meta'      => array(
1354                'origin'   => $origin,
1355                'position' => $position,
1356            ),
1357            'title'         => $this->to_utf8( $this->get_title( $post->post_title, $post->post_content, $post->ID ) ),
1358            'author'        => $this->generate_related_post_display_author( $post->ID ),
1359            'date'          => get_the_date( '', $post->ID ),
1360            'format'        => get_post_format( $post->ID ),
1361            'excerpt'       => html_entity_decode( $this->to_utf8( $this->get_excerpt( $post->post_excerpt, $post->post_content, $post->ID ) ), ENT_QUOTES, 'UTF-8' ),
1362            /**
1363             * Filters the rel attribute for the Related Posts' links.
1364             *
1365             * @module related-posts
1366             *
1367             * @since 3.7.0
1368             * @since 7.9.0 - Change Default value to empty.
1369             *
1370             * @param string $link_rel Link rel attribute for Related Posts' link. Default is empty.
1371             * @param int    $post->ID Post ID.
1372             */
1373            'rel'           => apply_filters( 'jetpack_relatedposts_filter_post_link_rel', '', $post->ID ),
1374            /**
1375             * Filter the context displayed below each Related Post.
1376             *
1377             * This context is used when rendering the legacy 'widget' version of Related Posts.
1378             * It is not used when rendering the block-based version. See 'block_context' below for that.
1379             *
1380             * @module related-posts
1381             *
1382             * @since 3.0.0
1383             *
1384             * @param string $this->to_utf8( $this->generate_related_post_context( $post->ID ) ) Context displayed below each related post.
1385             * @param int $post_id Post ID of the post for which we are retrieving Related Posts.
1386             */
1387            'context'       => apply_filters(
1388                'jetpack_relatedposts_filter_post_context',
1389                $this->to_utf8( $this->generate_related_post_context( $post->ID ) ),
1390                $post->ID
1391            ),
1392            // The context used when rendering as a Block. No filtering applied.
1393            'block_context' => $this->generate_related_post_context_block( $post->ID ),
1394            'img'           => $this->generate_related_post_image_params( $post->ID ),
1395            /**
1396             * Filter the post css classes added on HTML markup.
1397             *
1398             * @module related-posts
1399             *
1400             * @since 3.8.0
1401             *
1402             * @param array array() CSS classes added on post HTML markup.
1403             * @param string $post_id Post ID.
1404             */
1405            'classes'       => apply_filters(
1406                'jetpack_relatedposts_filter_post_css_classes',
1407                array(),
1408                $post->ID
1409            ),
1410        );
1411    }
1412
1413    /**
1414     * Returns either the title or a small excerpt to use as title for post.
1415     *
1416     * @uses strip_shortcodes, wp_trim_words, __, apply_filters
1417     *
1418     * @param string $post_title   Post title.
1419     * @param string $post_content Post content.
1420     * @param int    $post_id Post ID.
1421     *
1422     * @return string
1423     */
1424    protected function get_title( $post_title, $post_content, $post_id ) {
1425        if ( ! empty( $post_title ) ) {
1426            return wp_strip_all_tags(
1427                /** This filter is documented in core/src/wp-includes/post-template.php */
1428                apply_filters( 'the_title', $post_title, $post_id )
1429            );
1430        }
1431
1432        // Same gate as get_excerpt(): this fallback is five words of the raw body.
1433        if ( ! Content_Gate::is_gated( $post_id ) ) {
1434            $post_title = wp_trim_words( wp_strip_all_tags( strip_shortcodes( $post_content ) ), 5, '…' );
1435            if ( ! empty( $post_title ) ) {
1436                return $post_title;
1437            }
1438        }
1439
1440        return __( 'Untitled Post', 'jetpack' );
1441    }
1442
1443    /**
1444     * Returns a plain text post excerpt for title attribute of links.
1445     *
1446     * @param string $post_excerpt - the post excerpt.
1447     * @param string $post_content - the post content.
1448     * @param int    $post_id - the post ID.
1449     * @uses strip_shortcodes, wp_strip_all_tags, wp_trim_words
1450     * @return string
1451     */
1452    protected function get_excerpt( $post_excerpt, $post_content, $post_id ) {
1453        if ( ! empty( $post_excerpt ) ) {
1454            $excerpt = $post_excerpt;
1455        } elseif ( Content_Gate::is_gated( $post_id ) ) {
1456            // The body fall-through never passes through the `the_content` paywall, so ask the gate directly.
1457            return '';
1458        } else {
1459            $excerpt = $post_content;
1460        }
1461
1462        return wp_trim_words( wp_strip_all_tags( strip_shortcodes( $excerpt ) ), 50, '…' );
1463    }
1464
1465    /**
1466     * Generates the thumbnail image to be used for the post. Uses the
1467     * image as returned by Images::get_image()
1468     *
1469     * @param int $post_id - the post ID.
1470     * @uses self::get_options, apply_filters, Images::get_image, Images::fit_image_url
1471     * @return string
1472     */
1473    protected function generate_related_post_image_params( $post_id ) {
1474        $image_params = array(
1475            'alt_text' => '',
1476            'src'      => '',
1477            'width'    => 0,
1478            'height'   => 0,
1479        );
1480
1481        /**
1482         * Filter the size of the Related Posts images.
1483         *
1484         * @module related-posts
1485         *
1486         * @since 2.8.0
1487         *
1488         * @param array array( 'width' => 350, 'height' => 200 ) Size of the images displayed below each Related Post.
1489         */
1490        $thumbnail_size = apply_filters(
1491            'jetpack_relatedposts_filter_thumbnail_size',
1492            array(
1493                'width'  => 350,
1494                'height' => 200,
1495            )
1496        );
1497        if ( ! is_array( $thumbnail_size ) ) {
1498            $thumbnail_size = array(
1499                'width'  => (int) $thumbnail_size,
1500                'height' => (int) $thumbnail_size,
1501            );
1502        }
1503
1504        // Try to get post image.
1505        $img_url    = '';
1506        $is_gated   = Content_Gate::is_gated( $post_id );
1507        $image_args = $thumbnail_size;
1508        if ( $is_gated ) {
1509            // Restrict to the featured image; every other source parses the body.
1510            $image_args = array_merge(
1511                $image_args,
1512                array(
1513                    'from_slideshow'  => false,
1514                    'from_gallery'    => false,
1515                    'from_attachment' => false,
1516                    'from_blocks'     => false,
1517                    'from_html'       => false,
1518                )
1519            );
1520        }
1521        $post_image = Images::get_image( $post_id, $image_args );
1522
1523        if ( is_array( $post_image ) ) {
1524            $img_url = $post_image['src'];
1525        } elseif ( ! $is_gated && class_exists( 'Jetpack_Media_Summary' ) ) {
1526            $media = Jetpack_Media_Summary::get( $post_id );
1527
1528            if ( is_array( $media ) && ! empty( $media['image'] ) ) {
1529                $img_url = $media['image'];
1530            }
1531        }
1532
1533        if ( ! empty( $img_url ) ) {
1534            if ( ! empty( $post_image['alt_text'] ) ) {
1535                $image_params['alt_text'] = $post_image['alt_text'];
1536            } else {
1537                $image_params['alt_text'] = '';
1538            }
1539
1540            $thumbnail_width  = 0;
1541            $thumbnail_height = 0;
1542
1543            if ( ! empty( $thumbnail_size['width'] ) ) {
1544                $thumbnail_width       = $thumbnail_size['width'];
1545                $image_params['width'] = $thumbnail_width;
1546            }
1547
1548            if ( ! empty( $thumbnail_size['height'] ) ) {
1549                $thumbnail_height       = $thumbnail_size['height'];
1550                $image_params['height'] = $thumbnail_height;
1551            }
1552
1553            $image_params['src'] = Images::fit_image_url(
1554                $img_url,
1555                $thumbnail_width,
1556                $thumbnail_height
1557            );
1558
1559            // Add a srcset to handle zoomed views and high-density screens.
1560            $srcset = Images::generate_cropped_srcset(
1561                $post_image,
1562                $thumbnail_width,
1563                $thumbnail_height
1564            );
1565            if ( ! empty( $srcset ) ) {
1566                $image_params['srcset'] = $srcset;
1567            }
1568        }
1569
1570        return $image_params;
1571    }
1572
1573    /**
1574     * Returns the string UTF-8 encoded
1575     *
1576     * @param string $text - the text we want to convert.
1577     * @return string
1578     */
1579    protected function to_utf8( $text ) {
1580        if ( $this->convert_charset ) {
1581            return iconv( $this->blog_charset, 'UTF-8', $text );
1582        } else {
1583            return $text;
1584        }
1585    }
1586
1587    /**
1588     * =============================================
1589     * PROTECTED UTILITY FUNCTIONS EXTENDED BY WPCOM
1590     * =============================================
1591     */
1592
1593    /**
1594     * Workhorse method to return array of related posts matched by Elasticsearch.
1595     *
1596     * @param int   $post_id - the ID of the post.
1597     * @param int   $size - the size of the post.
1598     * @param array $filters - filters.
1599     * @uses wp_remote_post, is_wp_error, get_option, wp_remote_retrieve_body, get_post, add_query_arg, remove_query_arg, get_permalink, get_post_format, apply_filters
1600     * @return array
1601     */
1602    protected function get_related_posts( $post_id, $size, array $filters ) {
1603        $hits = $this->filter_non_public_posts(
1604            $this->get_related_post_ids(
1605                $post_id,
1606                $size,
1607                $filters
1608            )
1609        );
1610
1611        /**
1612         * Filter the Related Posts matched by Elasticsearch.
1613         *
1614         * @module related-posts
1615         *
1616         * @since 2.9.0
1617         *
1618         * @param array $hits Array of Post IDs matched by Elasticsearch.
1619         * @param string $post_id Post ID of the post for which we are retrieving Related Posts.
1620         */
1621        $hits = apply_filters( 'jetpack_relatedposts_filter_hits', $hits, $post_id );
1622
1623        $related_posts = array();
1624        foreach ( $hits as $i => $hit ) {
1625            $related_posts[] = $this->get_related_post_data_for_post( $hit['id'], $i, $post_id );
1626        }
1627        return $related_posts;
1628    }
1629
1630    /**
1631     * Get array of related posts matched by Elasticsearch.
1632     *
1633     * @param int   $post_id - the post ID.
1634     * @param int   $size - the size.
1635     * @param array $filters - some filters.
1636     * @uses wp_remote_post, is_wp_error, wp_remote_retrieve_body, get_post_meta, update_post_meta
1637     * @return array
1638     */
1639    protected function get_related_post_ids( $post_id, $size, array $filters ) {
1640        $transient_name = null;
1641        $now_ts         = time();
1642        $cache_meta_key = '_jetpack_related_posts_cache';
1643
1644        $body = array(
1645            'size' => (int) $size,
1646        );
1647
1648        if ( ! empty( $filters ) ) {
1649            $body['filter'] = array( 'and' => $filters );
1650        }
1651
1652        // Build cache key.
1653        $cache_key = md5( serialize( $body ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- this is used for caching.
1654
1655        // Load all cached values.
1656        if ( wp_using_ext_object_cache() ) {
1657            $transient_name = "{$cache_meta_key}_{$cache_key}_{$post_id}";
1658            $cache          = get_transient( $transient_name );
1659            if ( false !== $cache ) {
1660                return $cache;
1661            }
1662        } else {
1663            $cache = get_post_meta( $post_id, $cache_meta_key, true );
1664
1665            if ( empty( $cache ) ) {
1666                $cache = array();
1667            }
1668
1669            // Cache is valid! Return cached value.
1670            if ( isset( $cache[ $cache_key ] ) && is_array( $cache[ $cache_key ] ) && $cache[ $cache_key ]['expires'] > $now_ts ) {
1671                return $cache[ $cache_key ]['payload'];
1672            }
1673        }
1674
1675        $user_agent = '';
1676        if ( isset( $_SERVER['HTTP_USER_AGENT'] ) ) {
1677            $user_agent = strtolower( filter_var( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) );
1678        }
1679
1680        $response = wp_remote_post(
1681            "https://public-api.wordpress.com/rest/v1/sites/{$this->get_blog_id()}/posts/$post_id/related/",
1682            array(
1683                'timeout'    => 10,
1684                'user-agent' => "jetpack_related_posts, $user_agent",
1685                'sslverify'  => true,
1686                'body'       => $body,
1687            )
1688        );
1689
1690        // Oh no... return nothing don't cache errors. Also, don't cache HTTP 409 conflict responses.
1691        if ( is_wp_error( $response ) || WP_Http::CONFLICT === wp_remote_retrieve_response_code( $response ) ) {
1692            if ( isset( $cache[ $cache_key ] ) && is_array( $cache[ $cache_key ] ) ) {
1693                return $cache[ $cache_key ]['payload']; // return stale.
1694            } else {
1695                return array();
1696            }
1697        }
1698
1699        $results       = json_decode( wp_remote_retrieve_body( $response ), true );
1700        $related_posts = array();
1701        if ( is_array( $results ) && ! empty( $results['hits'] ) ) {
1702            foreach ( $results['hits'] as $hit ) {
1703                $related_posts[] = array(
1704                    'id' => $hit['fields']['post_id'],
1705                );
1706            }
1707        }
1708
1709        // An empty array might indicate no related posts or that posts
1710        // are not yet synced to WordPress.com, so we cache for only 1
1711        // minute in this case.
1712        if ( empty( $related_posts ) ) {
1713            $cache_ttl = 60;
1714        } else {
1715            $cache_ttl = 12 * HOUR_IN_SECONDS;
1716        }
1717
1718        // Update cache.
1719        if ( wp_using_ext_object_cache() ) {
1720            set_transient( $transient_name, $related_posts, $cache_ttl );
1721        } else {
1722            // Copy all valid cache values.
1723            $new_cache = array();
1724            foreach ( $cache as $k => $v ) {
1725                if ( is_array( $v ) && $v['expires'] > $now_ts ) {
1726                    $new_cache[ $k ] = $v;
1727                }
1728            }
1729
1730            // Set new cache value.
1731            $cache_expires           = $cache_ttl + $now_ts;
1732            $new_cache[ $cache_key ] = array(
1733                'expires' => $cache_expires,
1734                'payload' => $related_posts,
1735            );
1736            update_post_meta( $post_id, $cache_meta_key, $new_cache );
1737        }
1738
1739        return $related_posts;
1740    }
1741
1742    /**
1743     * Filter out any hits that are not public anymore.
1744     *
1745     * @param array $related_posts - the related posts.
1746     * @uses get_post_stati, get_post_status
1747     * @return array
1748     */
1749    protected function filter_non_public_posts( array $related_posts ) {
1750        $public_stati = get_post_stati( array( 'public' => true ) );
1751
1752        $filtered = array();
1753        foreach ( $related_posts as $hit ) {
1754            if ( in_array( get_post_status( $hit['id'] ), $public_stati, true ) ) {
1755                $filtered[] = $hit;
1756            }
1757        }
1758        return $filtered;
1759    }
1760
1761    /**
1762     * Generates the author byline for the related post.
1763     *
1764     * @param int $post_id - the post ID.
1765     * @uses get_post_field, get_the_author_meta
1766     * @return string
1767     */
1768    protected function generate_related_post_display_author( $post_id ) {
1769        $post_author         = get_post_field( 'post_author', $post_id );
1770        $author_display_name = get_the_author_meta( 'display_name', $post_author );
1771        if ( ! empty( $author_display_name ) ) {
1772            return $author_display_name;
1773        }
1774        return '';
1775    }
1776
1777    /**
1778     * Generates a context for the related content (second line in related post output).
1779     * Order of importance:
1780     *   - First category (Not 'Uncategorized')
1781     *   - First post tag
1782     *   - Number of comments
1783     *
1784     * @param int $post_id - the post ID.
1785     * @uses get_the_category, get_the_terms, get_comments_number, number_format_i18n, __, _n
1786     * @return string
1787     */
1788    protected function generate_related_post_context_block( $post_id ) {
1789        $categories = get_the_category( $post_id );
1790        if ( is_array( $categories ) ) {
1791            foreach ( $categories as $category ) {
1792                if ( $category instanceof WP_Term && 'uncategorized' !== $category->slug && '' !== trim( $category->name ) ) {
1793                    $cat_link = get_category_link( $category );
1794                    return array(
1795                        'text' => trim( $category->name ),
1796                        'link' => $cat_link,
1797                    );
1798                }
1799            }
1800        }
1801        $tags = get_the_terms( $post_id, 'post_tag' );
1802        if ( is_array( $tags ) ) {
1803            foreach ( $tags as $tag ) {
1804                if ( $tag instanceof WP_Term && '' !== trim( $tag->name ) ) {
1805                    $tag_link = get_tag_link( $tag );
1806                    return array(
1807                        'text' => trim( $tag->name ),
1808                        'link' => $tag_link,
1809                    );
1810                }
1811            }
1812        }
1813        $comment_count = get_comments_number( $post_id );
1814        if ( $comment_count > 0 ) {
1815            $comments_string = sprintf(
1816                // Translators: amount of comments.
1817                _n( 'With %s comment', 'With %s comments', $comment_count, 'jetpack' ),
1818                number_format_i18n( $comment_count )
1819            );
1820            $comments_link = get_comments_link( $post_id );
1821            return array(
1822                'text' => $comments_string,
1823                'link' => $comments_link,
1824            );
1825        }
1826        $fallback_string = __( 'Similar post', 'jetpack' );
1827        return array(
1828            'text' => $fallback_string,
1829            'link' => '',
1830        );
1831    }
1832
1833    /**
1834     * Generates a context for the related content (second line in related post output).
1835     * Order of importance:
1836     *   - First category (Not 'Uncategorized')
1837     *   - First post tag
1838     *   - Number of comments
1839     *
1840     * @param int $post_id - the post ID.
1841     * @uses get_the_category, get_the_terms, get_comments_number, number_format_i18n, __, _n
1842     * @return string
1843     */
1844    protected function generate_related_post_context( $post_id ) {
1845        $categories = get_the_category( $post_id );
1846        if ( is_array( $categories ) ) {
1847            foreach ( $categories as $category ) {
1848                if ( $category instanceof WP_Term && 'uncategorized' !== $category->slug && '' !== trim( $category->name ) ) {
1849                    $post_cat_context = sprintf(
1850                        // Translators: The category or tag name.
1851                        esc_html_x( 'In "%s"', 'in {category/tag name}', 'jetpack' ),
1852                        $category->name
1853                    );
1854                    /**
1855                     * Filter the "In Category" line displayed in the post context below each Related Post.
1856                     *
1857                     * @module related-posts
1858                     *
1859                     * @since 3.2.0
1860                     *
1861                     * @param string $post_cat_context "In Category" line displayed in the post context below each Related Post.
1862                     * @param array $category Array containing information about the category.
1863                     */
1864                    return apply_filters( 'jetpack_relatedposts_post_category_context', $post_cat_context, $category );
1865                }
1866            }
1867        }
1868
1869        $tags = get_the_terms( $post_id, 'post_tag' );
1870        if ( is_array( $tags ) ) {
1871            foreach ( $tags as $tag ) {
1872                if ( $tag instanceof WP_Term && '' !== trim( $tag->name ) ) {
1873                    $post_tag_context = sprintf(
1874                        // Translators: the category or tag name.
1875                        _x( 'In "%s"', 'in {category/tag name}', 'jetpack' ),
1876                        $tag->name
1877                    );
1878                    /**
1879                     * Filter the "In Tag" line displayed in the post context below each Related Post.
1880                     *
1881                     * @module related-posts
1882                     *
1883                     * @since 3.2.0
1884                     *
1885                     * @param string $post_tag_context "In Tag" line displayed in the post context below each Related Post.
1886                     * @param array $tag Array containing information about the tag.
1887                     */
1888                    return apply_filters( 'jetpack_relatedposts_post_tag_context', $post_tag_context, $tag );
1889                }
1890            }
1891        }
1892
1893        $comment_count = get_comments_number( $post_id );
1894        if ( $comment_count > 0 ) {
1895            return sprintf(
1896                // Translators: amount of comments.
1897                _n( 'With %s comment', 'With %s comments', $comment_count, 'jetpack' ),
1898                number_format_i18n( $comment_count )
1899            );
1900        }
1901
1902        return __( 'Similar post', 'jetpack' );
1903    }
1904
1905    /**
1906     * Logs clicks for clickthrough analysis and related result tuning.
1907     *
1908     * @param int $post_id - the post ID.
1909     * @param int $to_post_id - the to post ID.
1910     * @param int $link_position - the link position.
1911     */
1912    protected function log_click( $post_id, $to_post_id, $link_position ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1913    }
1914
1915    /**
1916     * Determines if the current post is able to use related posts.
1917     *
1918     * @since 14.0 Checks for singular instead of single to allow usage on non-posts CPTs in block themes.
1919     * @uses self::get_options, is_admin, is_singular, apply_filters
1920     * @return bool
1921     */
1922    protected function enabled_for_request() {
1923        /*
1924         * On block themes, allow usage on any singular view (post, page, CPT).
1925         * On classic themes, only allow usage on single posts by default.
1926         */
1927        $enabled_on_singular_views = wp_is_block_theme()
1928            ? is_singular()
1929            : is_single();
1930
1931        $enabled = $enabled_on_singular_views
1932            && ! is_attachment()
1933            && ! is_admin()
1934            && ! is_embed()
1935            && ( ! $this->allow_feature_toggle() || $this->get_option( 'enabled' ) );
1936
1937        /**
1938         * Filter the Enabled value to allow related posts to be selectively enabled/disabled.
1939         *
1940         * @module related-posts
1941         *
1942         * @since 3.3.0
1943         *
1944         * @param bool $enabled Should Related Posts be enabled on the current page.
1945         */
1946        return apply_filters( 'jetpack_relatedposts_filter_enabled_for_request', $enabled );
1947    }
1948
1949    /**
1950     * Adds filters.
1951     *
1952     * @uses self::enqueue_assets, self::setup_shortcode, add_filter
1953     */
1954    protected function action_frontend_init_page() {
1955        $this->enqueue_assets( true, true );
1956        $this->setup_shortcode();
1957
1958        add_filter( 'the_content', array( $this, 'filter_add_target_to_dom' ), 40 );
1959    }
1960
1961    /**
1962     * Determines if the scripts need be enqueued.
1963     *
1964     * @return bool
1965     */
1966    protected function requires_scripts() {
1967        return (
1968            ! ( class_exists( 'Jetpack_AMP_Support' ) && Jetpack_AMP_Support::is_amp_request() ) &&
1969            ! has_block( 'jetpack/related-posts' ) &&
1970            ! Blocks::is_fse_theme()
1971        );
1972    }
1973
1974    /**
1975     * Enqueues assets needed to do async loading of related posts.
1976     *
1977     * @param string $script - the script we're enqueing.
1978     * @param string $style - the style we're enqueing.
1979     *
1980     * @uses wp_enqueue_script, wp_enqueue_style, plugins_url
1981     */
1982    protected function enqueue_assets( $script, $style ) {
1983        $dependencies = is_customize_preview() ? array( 'customize-base' ) : array();
1984        // Do not enqueue scripts unless they are required.
1985        if ( $script && $this->requires_scripts() ) {
1986            wp_enqueue_script(
1987                'jetpack_related-posts',
1988                Assets::get_file_url_for_environment(
1989                    '_inc/build/related-posts/related-posts.min.js',
1990                    'modules/related-posts/related-posts.js'
1991                ),
1992                $dependencies,
1993                self::VERSION,
1994                false
1995            );
1996            $related_posts_js_options = array(
1997                /**
1998                 * Filter each Related Post Heading structure.
1999                 *
2000                 * @since 4.0.0
2001                 *
2002                 * @param string $str Related Post Heading structure. Default to h4.
2003                 */
2004                'post_heading' => apply_filters( 'jetpack_relatedposts_filter_post_heading', esc_attr( 'h4' ) ),
2005            );
2006            wp_localize_script( 'jetpack_related-posts', 'related_posts_js_options', $related_posts_js_options );
2007        }
2008        if ( $style ) {
2009            wp_enqueue_style( 'jetpack_related-posts', plugins_url( 'related-posts.css', __FILE__ ), array(), self::VERSION );
2010            wp_style_add_data( 'jetpack_related-posts', 'rtl', 'replace' );
2011            add_action( 'amp_post_template_css', array( $this, 'render_amp_reader_mode_css' ) );
2012        }
2013    }
2014
2015    /**
2016     * Render AMP's reader mode CSS.
2017     */
2018    public function render_amp_reader_mode_css() {
2019        echo file_get_contents( __DIR__ . '/related-posts.css' );  // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- this is loading a CSS file.
2020    }
2021
2022    /**
2023     * Sets up the shortcode processing.
2024     *
2025     * @uses add_filter, add_shortcode
2026     */
2027    protected function setup_shortcode() {
2028        add_filter( 'the_content', array( $this, 'test_for_shortcode' ), 0 );
2029
2030        add_shortcode( self::SHORTCODE, array( $this, 'get_client_rendered_html' ) );
2031    }
2032
2033    /**
2034     * Return status of related posts toggle.
2035     */
2036    protected function allow_feature_toggle() {
2037        if ( null === $this->allow_feature_toggle ) {
2038            /**
2039             * Filter the display of the Related Posts toggle in Settings > Reading.
2040             *
2041             * @module related-posts
2042             *
2043             * @since 2.8.0
2044             *
2045             * @param bool $allow_feature_toggle Display a feature toggle. Default to false.
2046             */
2047            $this->allow_feature_toggle = apply_filters( 'jetpack_relatedposts_filter_allow_feature_toggle', false );
2048        }
2049        return $this->allow_feature_toggle;
2050    }
2051
2052    /**
2053     * ===================================================
2054     * FUNCTIONS EXPOSING RELATED POSTS IN THE WP REST API
2055     * ===================================================
2056     */
2057
2058    /**
2059     * Add Related Posts to the REST API Post response.
2060     *
2061     * @since 4.4.0
2062     *
2063     * @action rest_api_init
2064     * @uses register_rest_field, self::rest_get_related_posts
2065     */
2066    public function rest_register_related_posts() {
2067        /** This filter is already documented in class.json-api-endpoints.php */
2068        $post_types = apply_filters( 'rest_api_allowed_post_types', array( 'post', 'page', 'revision' ) );
2069
2070        /**
2071         * Filter the post types that are allowed to have related posts.
2072         *
2073         * @since 15.3
2074         *
2075         * @param array $post_types The post types that are allowed to have related posts.
2076         */
2077        $post_types = apply_filters( 'jetpack_related_posts_rest_api_allowed_post_types', $post_types );
2078
2079        foreach ( $post_types as $post_type ) {
2080            register_rest_field(
2081                $post_type,
2082                'jetpack-related-posts',
2083                array(
2084                    'get_callback'    => array( $this, 'rest_get_related_posts' ),
2085                    'update_callback' => null,
2086                    'schema'          => null,
2087                )
2088            );
2089        }
2090    }
2091
2092    /**
2093     * Build an array of Related Posts.
2094     * By default returns cached results that are stored for up to 12 hours.
2095     *
2096     * @since 4.4.0
2097     *
2098     * @param array $object Details of current post.
2099     *
2100     * @uses self::get_for_post_id
2101     *
2102     * @return array
2103     */
2104    public function rest_get_related_posts( $object ) {
2105        if ( ! isset( $object['id'] ) ) {
2106            return array();
2107        }
2108
2109        // If the Related Posts option is turned off, don't get the related posts.
2110        $options = \Jetpack_Options::get_option( 'relatedposts', array() );
2111        if ( empty( $options['enabled'] ) || ! $options['enabled'] ) {
2112            return array();
2113        }
2114
2115        // If the current post doesn't contain a Related Posts block, and we're also on an admin page, don't get the related posts.
2116        // This will ensure that if the feature is enabled, we can still retrieve Related Posts via the REST API.
2117        if ( ! has_block( 'jetpack/related-posts' ) && is_admin() ) {
2118            return array();
2119        }
2120
2121        return $this->get_for_post_id( $object['id'], array( 'size' => 6 ) );
2122    }
2123}
2124
2125/**
2126 * The raw related posts class can be used by other plugins or themes
2127 * to get related content. This class wraps the existing RelatedPosts
2128 * logic thus we never want to add anything to the DOM or do anything
2129 * for event hooks. We will also not present any settings for this
2130 * class and keep it enabled as calls to this class are done
2131 * programmatically.
2132 */
2133class Jetpack_RelatedPosts_Raw extends Jetpack_RelatedPosts { //phpcs:ignore Generic.Classes.OpeningBraceSameLine.ContentAfterBrace, Generic.Files.OneObjectStructurePerFile.MultipleFound
2134
2135    /**
2136     * The query name we want to look up.
2137     *
2138     * @var string
2139     */
2140    protected $query_name;
2141
2142    /**
2143     * Allows callers of this class to tag each query with a unique name for tracking purposes.
2144     *
2145     * @param string $name - the name of the query.
2146     * @return Jetpack_RelatedPosts_Raw
2147     */
2148    public function set_query_name( $name ) {
2149        $this->query_name = (string) $name;
2150        return $this;
2151    }
2152
2153    /**
2154     * Initialize admin.
2155     */
2156    public function action_admin_init() {}
2157
2158    /**
2159     * Initialize front end.
2160     */
2161    public function action_frontend_init() {}
2162
2163    /**
2164     * Get options.
2165     */
2166    public function get_options() {
2167        return array(
2168            'enabled' => true,
2169        );
2170    }
2171
2172    /**
2173     * Workhorse method to return array of related posts ids matched by Elasticsearch.
2174     *
2175     * @param int   $post_id - the post ID.
2176     * @param int   $size - size of the post.
2177     * @param array $filters - filters we're using.
2178     * @uses wp_remote_post, is_wp_error, wp_remote_retrieve_body
2179     * @return array
2180     */
2181    protected function get_related_posts( $post_id, $size, array $filters ) {
2182        $hits = $this->filter_non_public_posts(
2183            $this->get_related_post_ids(
2184                $post_id,
2185                $size,
2186                $filters
2187            )
2188        );
2189
2190        /** This filter is already documented in modules/related-posts/related-posts.php */
2191        $hits = apply_filters( 'jetpack_relatedposts_filter_hits', $hits, $post_id );
2192
2193        return $hits;
2194    }
2195}