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