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