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