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