blog_charset = get_option( 'blog_charset' );
$this->convert_charset = ( function_exists( 'iconv' ) && ! preg_match( '/^utf\-?8$/i', $this->blog_charset ) );
add_action( 'admin_init', array( $this, 'action_admin_init' ) );
add_action( 'wp', array( $this, 'action_frontend_init' ) );
if ( ! class_exists( 'Jetpack_Media_Summary' ) ) {
require_once JETPACK__PLUGIN_DIR . '_inc/lib/class.media-summary.php';
}
// Add Related Posts to the REST API Post response.
add_action( 'rest_api_init', array( $this, 'rest_register_related_posts' ) );
}
/**
* Get the blog ID.
*
* @return Object current blog id.
*/
protected function get_blog_id() {
return Jetpack_Options::get_option( 'id' );
}
/**
* =================
* ACTIONS & FILTERS
* =================
*/
/**
* Add a checkbox field to Settings > Reading for enabling related posts.
*
* @action admin_init
* @uses add_settings_field, __, register_setting, add_action
*/
public function action_admin_init() {
// Add the setting field [jetpack_relatedposts] and place it in Settings > Reading.
add_settings_field( 'jetpack_relatedposts', '' . __( 'Related posts', 'jetpack' ) . '', array( $this, 'print_setting_html' ), 'reading' );
register_setting( 'reading', 'jetpack_relatedposts', array( $this, 'parse_options' ) );
add_action( 'admin_head', array( $this, 'print_setting_head' ) );
if ( 'options-reading.php' === $GLOBALS['pagenow'] ) {
// Enqueue style for live preview on the reading settings page.
$this->enqueue_assets( false, true );
}
}
/**
* Load related posts assets if it's an eligible front end page or execute search and return JSON if it's an endpoint request.
*
* @global $_GET
* @action wp
* @uses add_shortcode, get_the_ID
*/
public function action_frontend_init() {
// Add a shortcode handler that outputs nothing, this gets overridden later if we can display related content.
add_shortcode( self::SHORTCODE, array( $this, 'get_client_rendered_html_unsupported' ) );
if ( ! $this->enabled_for_request() ) {
return;
}
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.
$excludes = $this->parse_numeric_get_arg( 'relatedposts_exclude' );
$this->action_frontend_init_ajax( $excludes );
} else {
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.
$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.
$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.
}
$this->action_frontend_init_page();
}
}
/**
* Render insertion point.
*
* @since 4.2.0
*
* @return string
*/
public function get_headline() {
$options = $this->get_options();
if ( $options['show_headline'] ) {
$headline = sprintf(
/** This filter is already documented in modules/sharedaddy/sharing-service.php */
apply_filters( 'jetpack_sharing_headline_html', '
%s
', esc_html( $options['headline'] ), 'related-posts' ),
esc_html( $options['headline'] )
);
} else {
$headline = '';
}
return $headline;
}
/**
* Adds a target to the post content to load related posts into if a shortcode for it did not already exist.
* Will skip adding the target if the post content contains a Related Posts block, if the 'get_the_excerpt'
* hook is in the current filter list, or if the site is running an FSE/Site Editor theme.
*
* @filter the_content
*
* @param string $content Post content.
*
* @return string
*/
public function filter_add_target_to_dom( $content ) {
// Do not output related posts for ActivityPub requests.
if (
function_exists( '\Activitypub\is_activitypub_request' )
&& \Activitypub\is_activitypub_request()
) {
return $content;
}
if ( has_block( 'jetpack/related-posts' ) || Blocks::is_fse_theme() ) {
return $content;
}
if ( ! $this->found_shortcode && ! doing_filter( 'get_the_excerpt' ) ) {
if ( class_exists( 'Jetpack_AMP_Support' ) && Jetpack_AMP_Support::is_amp_request() ) {
$content .= "\n" . $this->get_server_rendered_html();
} else {
$content .= "\n" . $this->get_client_rendered_html();
}
}
return $content;
}
/**
* Render static markup based on the Gutenberg block code
*
* @return string Rendered related posts HTML.
*/
public function get_server_rendered_html() {
$rp_settings = $this->get_options();
$block_rp_settings = array(
'displayThumbnails' => $rp_settings['show_thumbnails'],
'showHeadline' => $rp_settings['show_headline'],
'displayDate' => isset( $rp_settings['show_date'] ) ? (bool) $rp_settings['show_date'] : true,
'displayContext' => isset( $rp_settings['show_context'] ) && $rp_settings['show_context'],
'postLayout' => isset( $rp_settings['layout'] ) ? $rp_settings['layout'] : 'grid',
'postsToShow' => isset( $rp_settings['size'] ) ? $rp_settings['size'] : 3,
/** This filter is already documented in modules/related-posts/jetpack-related-posts.php */
'headline' => apply_filters( 'jetpack_relatedposts_filter_headline', $this->get_headline() ),
'isServerRendered' => true,
);
return $this->render_block( $block_rp_settings, '' );
}
/**
* Looks for our shortcode on the unfiltered content, this has to execute early.
*
* @filter the_content
* @param string $content - content of the post.
* @uses has_shortcode
* @return string $content
*/
public function test_for_shortcode( $content ) {
$this->found_shortcode = has_shortcode( $content, self::SHORTCODE );
return $content;
}
/**
* Returns the HTML for the related posts section.
*
* @uses esc_html__, apply_filters
* @return string
*/
public function get_client_rendered_html() {
if ( Settings::is_syncing() ) {
return '';
}
/**
* Filter the Related Posts headline.
*
* @module related-posts
*
* @since 3.0.0
*
* @param string $headline Related Posts heading.
*/
$headline = apply_filters( 'jetpack_relatedposts_filter_headline', $this->get_headline() );
if ( $this->previous_post_id ) {
$exclude = "data-exclude='{$this->previous_post_id}'";
} else {
$exclude = '';
}
return <<
$headline
EOT;
}
/**
* 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.
*
* @return string
*/
public function get_client_rendered_html_unsupported() {
if ( Settings::is_syncing() ) {
return '';
}
return "\n\n\n\n";
}
/**
* ===============
* GUTENBERG BLOCK
* ===============
*/
/**
* Echoes out items for the Gutenberg block
*
* @param array $related_post The post object.
* @param array $block_attributes The block attributes.
*/
public function render_block_item( $related_post, $block_attributes ) {
$instance_id = 'related-posts-item-' . uniqid();
$label_id = $instance_id . '-label';
$title = $related_post['title'];
$url = $related_post['url'];
$rel = $related_post['rel'];
$img = '';
$list = '';
$item_markup = sprintf(
'
';
}
// Context
if ( ( $block_attributes['show_context'] ) && ! empty( $related_post['block_context'] ) ) {
// translators: this is followed by the reason why the item is related to the current post
$list .= '
' . __( 'In relation to', 'jetpack' ) . '
';
$list .= '
';
// Note: The original 'context' value is not used when rendering the block.
// It is still generated and available for the legacy rendering code path though.
// See './related-posts.js' for that usage.
$block_context = $related_post['block_context'];
if ( ! empty( $block_context['link'] ) ) {
$list .= sprintf(
'%2$s',
esc_url( $block_context['link'] ),
esc_html( $block_context['text'] )
);
} else {
$list .= esc_html( $block_context['text'] );
}
$list .= '
',
esc_html( $headline )
);
}
}
$display_markup = sprintf(
'',
! empty( $wrapper_attributes['class'] ) ? ' ' . esc_attr( $wrapper_attributes['class'] ) : '',
! empty( $wrapper_attributes['style'] ) ? ' style="' . esc_attr( $wrapper_attributes['style'] ) . '"' : '',
esc_attr( $block_attributes['layout'] ),
$headline_markup,
$list_markup,
empty( $headline_markup ) ? esc_attr__( 'Related Posts', 'jetpack' ) : esc_attr( wp_strip_all_tags( $headline_markup ) )
);
/**
* Filter the output HTML of Related Posts.
*
* @module related-posts
*
* @since 10.7
*
* @param string $display_markup HTML output of Related Posts.
* @param int|false get_the_ID() Post ID of the post for which we are retrieving Related Posts.
* @param array $related_posts Array of related posts.
* @param array $block_attributes Array of Block attributes.
*/
return (string) apply_filters( 'jetpack_related_posts_display_markup', $display_markup, $post_id, $related_posts, $block_attributes );
}
/**
* ========================
* PUBLIC UTILITY FUNCTIONS
* ========================
*/
/**
* Parse a numeric GET variable to an array of values.
*
* @since 6.9.0
*
* @uses absint
*
* @param string $arg Name of the GET variable.
* @return array $result Parsed value(s)
*/
public function parse_numeric_get_arg( $arg ) {
$result = array();
if ( isset( $_GET[ $arg ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- requests are used to generate a list of related posts we want to exclude.
if ( is_string( $_GET[ $arg ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$result = explode( ',', sanitize_text_field( wp_unslash( $_GET[ $arg ] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
} elseif ( is_array( $_GET[ $arg ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$args = array_map( 'sanitize_text_field', wp_unslash( $_GET[ $arg ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$result = array_values( $args );
}
$result = array_unique( array_filter( array_map( 'absint', $result ) ) );
}
return $result;
}
/**
* Gets options set for Jetpack_RelatedPosts and merge with defaults.
*
* @uses Jetpack_Options::get_option, apply_filters
* @return array
*/
public function get_options() {
if ( null === $this->options ) {
$this->options = Jetpack_Options::get_option( 'relatedposts', array() );
if ( ! is_array( $this->options ) ) {
$this->options = array();
}
if ( ! isset( $this->options['enabled'] ) ) {
$this->options['enabled'] = true;
}
if ( ! isset( $this->options['show_headline'] ) ) {
$this->options['show_headline'] = true;
}
if ( ! isset( $this->options['show_thumbnails'] ) ) {
$this->options['show_thumbnails'] = false;
}
if ( ! isset( $this->options['show_date'] ) ) {
$this->options['show_date'] = true;
}
if ( ! isset( $this->options['show_context'] ) ) {
$this->options['show_context'] = true;
}
if ( ! isset( $this->options['layout'] ) ) {
$this->options['layout'] = 'grid';
}
if ( ! isset( $this->options['headline'] ) ) {
$this->options['headline'] = esc_html__( 'Related', 'jetpack' );
}
if ( empty( $this->options['size'] ) || (int) $this->options['size'] < 1 ) {
$this->options['size'] = 3;
}
/**
* Filter Related Posts basic options.
*
* @module related-posts
*
* @since 2.8.0
*
* @param array $this->_options Array of basic Related Posts options.
*/
$this->options = apply_filters( 'jetpack_relatedposts_filter_options', $this->options );
}
return $this->options;
}
/**
* Gets options.
*
* @param string $option_name - option we want to get.
*/
public function get_option( $option_name ) {
$options = $this->get_options();
if ( isset( $options[ $option_name ] ) ) {
return $options[ $option_name ];
}
return false;
}
/**
* Parses input and returns normalized options array.
*
* @param array $input - input we're parsing.
* @uses self::get_options
* @return array
*/
public function parse_options( $input ) {
$current = $this->get_options();
if ( ! is_array( $input ) ) {
$input = array();
}
if (
! isset( $input['enabled'] )
|| isset( $input['show_date'] )
|| isset( $input['show_context'] )
|| isset( $input['layout'] )
|| isset( $input['headline'] )
) {
$input['enabled'] = '1';
}
if ( '1' == $input['enabled'] ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- expecting string, but may return bools.
$current['enabled'] = true;
$current['show_headline'] = ( isset( $input['show_headline'] ) && '1' == $input['show_headline'] ); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
$current['show_thumbnails'] = ( isset( $input['show_thumbnails'] ) && '1' == $input['show_thumbnails'] ); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
$current['show_date'] = ( isset( $input['show_date'] ) && '1' == $input['show_date'] ); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
$current['show_context'] = ( isset( $input['show_context'] ) && '1' == $input['show_context'] ); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
$current['layout'] = isset( $input['layout'] ) && in_array( $input['layout'], array( 'grid', 'list' ), true ) ? $input['layout'] : 'grid';
$current['headline'] = isset( $input['headline'] ) ? $input['headline'] : esc_html__( 'Related', 'jetpack' );
} else {
$current['enabled'] = false;
}
if ( isset( $input['size'] ) && (int) $input['size'] > 0 ) {
$current['size'] = (int) $input['size'];
} else {
$current['size'] = null;
}
return $current;
}
/**
* HTML for admin settings page.
*
* @uses self::get_options, checked, esc_html__
*/
public function print_setting_html() {
$options = $this->get_options();
$ui_settings_template = <<%s
%s
EOT;
$ui_settings = sprintf(
$ui_settings_template,
esc_html__( 'The following settings will impact all related posts on your site, except for those you created via the block editor:', 'jetpack' ),
checked( $options['show_headline'], true, false ),
esc_html__( 'Highlight related content with a heading', 'jetpack' ),
checked( $options['show_thumbnails'], true, false ),
esc_html__( 'Show a thumbnail image where available', 'jetpack' ),
checked( $options['show_date'], true, false ),
esc_html__( 'Show entry date', 'jetpack' ),
checked( $options['show_context'], true, false ),
esc_html__( 'Show context (category or tag)', 'jetpack' ),
esc_html__( 'Preview:', 'jetpack' )
);
if ( ! $this->allow_feature_toggle() ) {
$template = <<
%s
EOT;
printf(
$template, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
$ui_settings // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- data is escaped when variable is set.
);
} else {
$template = <<
%s
EOT;
printf(
$template, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
checked( $options['enabled'], false, false ),
esc_html__( 'Hide related content after posts', 'jetpack' ),
checked( $options['enabled'], true, false ),
esc_html__( 'Show related content after posts', 'jetpack' ),
$ui_settings // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- data is escaped when variable is set.
);
}
}
/**
* Head JS/CSS for admin settings page.
*
* @uses esc_html__
* @return null
*/
public function print_setting_head() {
// only dislay the Related Posts JavaScript on the Reading Settings Admin Page.
$current_screen = get_current_screen();
if ( $current_screen === null ) {
return;
}
if ( 'options-reading' !== $current_screen->id ) {
return;
}
$related_headline = sprintf(
'