// Add our default styles.
wp_register_style( 'the-neverending-homepage', plugins_url( 'infinity.css', __FILE__ ), array(), '20140422' );
// Make sure there are enough posts for IS
if ( self::is_last_batch() ) {
wp_enqueue_script( 'the-neverending-homepage' );
// Add our default styles.
wp_enqueue_style( 'the-neverending-homepage' );
add_action( 'wp_footer', array( $this, 'action_wp_footer_settings' ), 2 );
add_action( 'wp_footer', array( $this, 'action_wp_footer' ), 21 ); // Core prints footer scripts at priority 20, so we just need to be one later than that
add_filter( 'infinite_scroll_results', array( $this, 'filter_infinite_scroll_results' ), 10, 3 );
* Initialize the Customizer logic separately from the main JS.
public function init_customizer_assets() {
'the-neverending-homepage-customizer',
Assets::get_file_url_for_environment(
'_inc/build/infinite-scroll/infinity-customizer.min.js',
'modules/infinite-scroll/infinity-customizer.js'
array( 'jquery', 'customize-base' ),
JETPACK__VERSION . '-is5.0.0', // Added for ability to cachebust on WP.com.
wp_enqueue_script( 'the-neverending-homepage-customizer' );
* Returns classes to be added to <body>. If it's enabled, 'infinite-scroll'. If set to continuous scroll, adds 'neverending' too.
* @since 4.7.0 No longer added as a 'body_class' filter but passed to JS environment and added using JS.
public function body_class() {
$settings = self::get_settings();
// Do not add infinity-scroll class if disabled through the Reading page
$disabled = '' === get_option( self::$option_name_enabled ) ? true : false;
if ( ! $disabled || 'click' === $settings->type ) {
$classes = 'infinite-scroll';
if ( 'scroll' === $settings->type ) {
$classes .= ' neverending';
* In case IS is activated on search page, we have to exclude initially loaded posts which match the keyword by title, not the content as they are displayed before content-matching ones
* @uses self::get_last_post_date
* @uses self::has_only_title_matching_posts
public function get_excluded_posts() {
$excluded_posts = array();
// loop through posts returned by wp_query call
foreach ( self::wp_query()->get_posts() as $post ) {
if ( ! $post instanceof \WP_Post ) {
$orderby = isset( self::wp_query()->query_vars['orderby'] ) ? self::wp_query()->query_vars['orderby'] : '';
$post_date = ( ! empty( $post->post_date ) ? $post->post_date : false );
if ( 'modified' === $orderby || false === $post_date ) {
$post_date = $post->post_modified;
// in case all posts initially displayed match the keyword by title we add em all to excluded posts array
// else, we add only posts which are older than last_post_date param as newer are natually excluded by last_post_date condition in the SQL query
if ( self::has_only_title_matching_posts() || $post_date <= self::get_last_post_date() ) {
array_push( $excluded_posts, $post->ID );
* In case IS is active on search, we have to exclude posts matched by title rather than by post_content in order to prevent dupes on next pages
* @uses self::get_excluded_posts
public function get_query_vars() {
$query_vars = self::wp_query()->query_vars;
// applies to search page only
if ( true === self::wp_query()->is_search() ) {
// set post__not_in array in query_vars in case it does not exists
if ( false === isset( $query_vars['post__not_in'] ) ) {
$query_vars['post__not_in'] = array();
$excluded = self::get_excluded_posts();
// merge them with other post__not_in posts (eg.: sticky posts)
$query_vars['post__not_in'] = array_merge( $query_vars['post__not_in'], $excluded );
* This function checks whether all posts returned by initial wp_query match the keyword by title
* The code used in this function is borrowed from WP_Query class where it is used to construct like conditions for keywords
public function has_only_title_matching_posts() {
// apply following logic for search page results only
if ( false === self::wp_query()->is_search() ) {
// grab the last posts in the stack as if the last one is title-matching the rest is title-matching as well
$post = end( self::wp_query()->posts );
// code inspired by WP_Query class
if ( preg_match_all( '/".*?("|$)|((?<=[\t ",+])|^)[^\t ",+]+/', self::wp_query()->get( 's' ), $matches ) ) {
$search_terms = self::wp_query()->query_vars['search_terms'] ?? null;
// if the search string has only short terms or stopwords, or is 10+ terms long, match it as sentence
if ( empty( $search_terms ) || ! is_countable( $search_terms ) || count( $search_terms ) > 9 ) {
$search_terms = array( self::wp_query()->get( 's' ) );
$search_terms = array( self::wp_query()->get( 's' ) );
// actual testing. As search query combines multiple keywords with AND, it's enough to check if any of the keywords is present in the title
$term = current( $search_terms );
if ( ! empty( $term ) && str_contains( $post->post_title, $term ) ) {
* Grab the timestamp for the initial query's last post.
* This takes into account the query's 'orderby' parameter and returns
* false if the posts are not ordered by date.
* @uses self::got_infinity
* @uses self::has_only_title_matching_posts
* @return string 'Y-m-d H:i:s' or false
public function get_last_post_date() {
if ( self::got_infinity() ) {
if ( ! self::wp_query()->have_posts() ) {
// In case there are only title-matching posts in the initial WP_Query result, we don't want to use the last_post_date param yet
if ( true === self::has_only_title_matching_posts() ) {
$post = end( self::wp_query()->posts );
$orderby = isset( self::wp_query()->query_vars['orderby'] ) ?
self::wp_query()->query_vars['orderby'] : '';
$post_date = ( ! empty( $post->post_date ) ? $post->post_date : false );
return $post->post_modified;
* Returns the appropriate `wp_posts` table field for a given query's
* 'orderby' parameter, if applicable.
* @param object $query - an optional query object.
* @return string or false
public function get_query_sort_field( $query = null ) {
$query = self::wp_query();
$orderby = isset( $query->query_vars['orderby'] ) ? $query->query_vars['orderby'] : '';
* Create a where clause that will make sure post queries return posts
* in the correct order, without duplicates, if a new post is added
* and we're sorting by post date.
* @param string $where - the where clause.
* @param object $query - the query.
public function query_time_filter( $where, $query ) {
if ( self::got_infinity() ) {
$sort_field = self::get_query_sort_field( $query );
if ( 'post_date' !== $sort_field ||
! isset( $_REQUEST['query_args']['order'] ) || 'DESC' !== $_REQUEST['query_args']['order'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- no changes made to the site.
$query_before = isset( $_REQUEST['query_before'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['query_before'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- no changes made to the site.
if ( empty( $query_before ) ) {
// Construct the date query using our timestamp
$clause = $wpdb->prepare( " AND {$wpdb->posts}.post_date <= %s", $query_before );
* Filter Infinite Scroll's SQL date query making sure post queries
* will always return results prior to (descending sort)
* or before (ascending sort) the last post date.
* @module infinite-scroll
* @param string $clause SQL Date query.
* @param object $query Query.
* @param string $operator @deprecated Query operator.
* @param string $last_post_date @deprecated Last Post Date timestamp.
$last_post_date = isset( $_REQUEST['last_post_date'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['last_post_date'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- no changes to the site
$where .= apply_filters_deprecated( 'infinite_scroll_posts_where', array( $clause, $query, $operator, $last_post_date ), '14.0', '' );
* Let's overwrite the default post_per_page setting to always display a fixed amount.
* @param object $query - the query.
* @uses is_admin, self::archive_supports_infinity, self::get_settings
public function posts_per_page_query( $query ) {
if ( ! is_admin() && self::archive_supports_infinity() && $query->is_main_query() ) {
$query->set( 'posts_per_page', self::posts_per_page() );
* Check if the IS output should be wrapped in a div.
* Setting value can be a boolean or a string specifying the class applied to the div.
* @uses self::get_settings
public function has_wrapper() {
return (bool) self::get_settings()->wrapper;
* @uses home_url, add_query_arg, apply_filters
public function ajax_url() {
$base_url = set_url_scheme( home_url( '/' ) );
$ajaxurl = add_query_arg( array( 'infinity' => 'scrolling' ), $base_url );
* Filter the Infinite Scroll Ajax URL.
* @module infinite-scroll
* @param string $ajaxurl Infinite Scroll Ajax URL.
return apply_filters( 'infinite_scroll_ajax_url', $ajaxurl );
* Our own Ajax response, avoiding calling admin-ajax
public function ajax_response() {
// Only proceed if the url query has a key of "Infinity"
if ( ! self::got_infinity() ) {
// This should already be defined below, but make sure.
if ( ! defined( 'DOING_AJAX' ) ) {
define( 'DOING_AJAX', true );
@header( 'Content-Type: text/html; charset=' . get_option( 'blog_charset' ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
* Fires at the end of the Infinite Scroll Ajax response.
* @module infinite-scroll
do_action( 'custom_ajax_infinite_scroll' );
* Alias for renamed class method.
* Previously, JS settings object was unnecessarily output in the document head.
* When the hook was changed, the method name no longer made sense.
public function action_wp_head() {
$this->action_wp_footer_settings();
* Prints the relevant infinite scroll settings in JS.
* @uses self::get_settings, esc_js, esc_url_raw, self::has_wrapper, __, apply_filters, do_action, self::get_query_vars
public function action_wp_footer_settings() {
$settings = self::get_settings();
// Default click handle text
$click_handle_text = __( 'Older posts', 'jetpack' );
// If a single CPT is displayed, use its plural name instead of "posts"
// Could be empty (posts) or an array of multiple post types.
// In the latter two cases cases, the default text is used, leaving the `infinite_scroll_js_settings` filter for further customization.
$post_type = self::wp_query()->get( 'post_type' );
// If it's a taxonomy, try to change the button text.
// Get current taxonomy slug.
$taxonomy_slug = self::wp_query()->get( 'taxonomy' );
// Get taxonomy settings.
$taxonomy = get_taxonomy( $taxonomy_slug );
// Check if the taxonomy is attached to one post type only and use its plural name.
// If not, use "Posts" without confusing the users.
is_a( $taxonomy, 'WP_Taxonomy' )
&& is_countable( $taxonomy->object_type )
&& ! empty( $taxonomy->object_type )
&& count( $taxonomy->object_type ) < 2
$post_type = $taxonomy->object_type[0];
if ( is_string( $post_type ) && ! empty( $post_type ) ) {
$post_type = get_post_type_object( $post_type );
if ( is_object( $post_type ) && ! is_wp_error( $post_type ) ) {
if ( isset( $post_type->labels->name ) ) {
$cpt_text = $post_type->labels->name;
} elseif ( isset( $post_type->label ) ) {
$cpt_text = $post_type->label;
if ( isset( $cpt_text ) ) {
/* translators: %s is the name of a custom post type */
$click_handle_text = sprintf( __( 'More %s', 'jetpack' ), $cpt_text );
'id' => $settings->container,
'ajaxurl' => esc_url_raw( self::ajax_url() ),
'type' => esc_js( $settings->type ),
'wrapper' => self::has_wrapper(),
'wrapper_class' => is_string( $settings->wrapper ) ? esc_js( $settings->wrapper ) : 'infinite-wrap',
'footer' => is_string( $settings->footer ) ? esc_js( $settings->footer ) : $settings->footer,
'click_handle' => esc_js( $settings->click_handle ),
'text' => esc_js( $click_handle_text ),
'totop' => __( 'Scroll back to top', 'jetpack' ),
'currentday' => $currentday,
'google_analytics' => false,
'offset' => max( 1, self::wp_query()->get( 'paged' ) ), // Pass through the current page so we can use that to offset the first load.
'host' => preg_replace( '#^http(s)?://#i', '', untrailingslashit( esc_url( get_home_url() ) ) ),
'path' => self::get_request_path(),
'use_trailing_slashes' => $wp_rewrite->use_trailing_slashes,
'parameters' => self::get_request_parameters(),
'query_args' => self::get_query_vars(),
'query_before' => current_time( 'mysql' ),
'last_post_date' => self::get_last_post_date(),
'body_class' => self::body_class(),
'loading_text' => __( 'Loading new page', 'jetpack' ),
if ( isset( $_REQUEST['order'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- no changes made to the site.
$order = strtoupper( sanitize_text_field( wp_unslash( $_REQUEST['order'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- no changes made to the site.
if ( in_array( $order, array( 'ASC', 'DESC' ), true ) ) {
$js_settings['order'] = $order;
* Filter the Infinite Scroll JS settings outputted in the head.
* @module infinite-scroll
* @param array $js_settings Infinite Scroll JS settings.
$js_settings = apply_filters( 'infinite_scroll_js_settings', $js_settings );
* Fires before Infinite Scroll outputs inline JavaScript in the head.
* @module infinite-scroll
do_action( 'infinite_scroll_wp_head' );
<script type="text/javascript">
var infiniteScroll = <?php echo wp_json_encode( array( 'settings' => $js_settings ), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ); ?>;
// phpcs:disable WordPress.WP.GlobalVariablesOverride.Prohibited
* Build path data for current request.
* Used for Google Analytics and pushState history tracking.