<?php
/**
* LazyLoad
*
* Most of the code borrowed from https://github.com/Angrycreative/bj-lazy-load
*
* @package PoweredCache
*/
namespace PoweredCache;
use const PoweredCache\Constants\POST_META_DISABLE_LAZYLOAD_KEY;
// phpcs:disable WordPressVIPMinimum.Security.ProperEscapingFunction.hrefSrcEscUrl
/**
* Class LazyLoad
*/
class LazyLoad {
/**
* Hold plugin settings
*
* @var array $settings
*/
private static $settings = null;
/**
* Return an instance of the current class
*
* @return LazyLoad
*/
public static function factory() {
static $instance = false;
if ( ! $instance ) {
$instance = new self();
$instance->setup();
}
return $instance;
}
/**
* Setup routine
*/
public function setup() {
if ( ! self::$settings ) {
self::$settings = \PoweredCache\Utils\get_settings();
}
if ( self::$settings['enable_lazy_load'] ) {
add_action( 'wp', [ $this, 'init' ], 9999 ); // run this as late as possible
add_action( 'powered_cache_lazy_load_compat', [ $this, 'compat' ] );
add_action( 'powered_cache_lazy_load_run_filter', [ $this, 'maybe_disable_through_meta' ] );
add_filter( 'powered_cache_delayed_js_skip', [ $this, 'delayed_js_skip' ], 10, 2 );
add_filter( 'powered_cache_fo_excluded_js_files', [ $this, 'add_file_optimizer_exclusion' ] );
}
/**
* Filter to disable native lazyload support.
*
* @hook powered_cache_disable_native_lazyload
*
* @param {boolean} $status true to disable native lazy load.
*
* @return {boolean} New value.
* @since 2.0
*/
if ( apply_filters( 'powered_cache_disable_native_lazyload', self::$settings['disable_wp_lazy_load'] ) ) {
add_filter( 'wp_lazy_loading_enabled', '__return_false' );
}
}
/**
* Initialize the setup
*/
public function init() {
/* We do not touch the feeds */
if ( is_admin() || is_feed() || is_preview() ) {
return;
}
/**
* Fires before filtering lazy-load hooks.
*
* @hook powered_cache_lazy_load_compat
*
* @since 1.0
*/
do_action( 'powered_cache_lazy_load_compat' );
/**
* Filters lazy-load status.
*
* @hook powered_cache_lazy_load_enabled
*
* @param {boolean} $enable true to enable
*
* @return {boolean} New value
* @since 1.0
*/
$enabled = apply_filters( 'powered_cache_lazy_load_enabled', true );
if ( $enabled ) {
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
$this->setup_filtering();
}
}
/**
* Enqueue scripts
*/
public function enqueue_scripts() {
wp_enqueue_script( 'PCLL', POWERED_CACHE_URL . 'dist/js/lazyload.js', null, POWERED_CACHE_VERSION, true );
/**
* Filters screen threshold value.
*
* @hook powered_cache_lazy_load_threshold
*
* @param {int} $threshold Screen display threshold.
*
* @return {int} New value
* @since 1.0
*/
$threshold = apply_filters( 'powered_cache_lazy_load_threshold', 200 );
/**
* Filters the count of images that skipped from lazyload.
*
* @hook powered_cache_lazy_load_skip_first_nth_img
*
* @param {int} $immediate_load_count Default image count
*
* @return {int} New value
* @since 3.1
*/
$immediate_load_count = apply_filters( 'powered_cache_lazy_load_skip_first_nth_img', self::$settings['lazy_load_skip_first_nth_img'] );
if ( 200 !== (int) $threshold || 3 !== (int) $immediate_load_count ) {
wp_localize_script(
'PCLL',
'PCLL_options',
[
'threshold' => $threshold,
'immediate_load_count' => $immediate_load_count,
]
);
}
if ( self::$settings['lazy_load_youtube'] ) {
wp_register_style( 'pcll-youtube-lazyload', false ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion
wp_enqueue_style( 'pcll-youtube-lazyload' );
wp_add_inline_style( 'pcll-youtube-lazyload', $this->add_inline_youtube_css() );
}
}
/**
* Set up filtering for certain content
*/
protected function setup_filtering() {
/**
* Filters whether apply or not apply lazyload for images.
*
* @hook powered_cache_lazy_load_images
*
* @param {boolean} true to lazyload for images
*
* @return {boolean} New value.
* @since 1.0
*/
if ( true === apply_filters( 'powered_cache_lazy_load_images', self::$settings['lazy_load_images'] ) ) {
add_filter( 'powered_cache_lazy_load_filter', array( __CLASS__, 'filter_images' ) );
}
/**
* Filters whether apply or not apply lazyload for iframes.
*
* @hook powered_cache_lazy_load_iframes
*
* @param {boolean} true to apply lazyload for iframes
*
* @return {boolean} New value.
* @since 1.0
*/
if ( true === apply_filters( 'powered_cache_lazy_load_iframes', self::$settings['lazy_load_iframes'] ) ) {
add_filter( 'powered_cache_lazy_load_filter', array( __CLASS__, 'filter_iframes' ) );
}
/**
* Filters whether apply or not apply lazyload for post_content.
*
* @hook powered_cache_lazy_load_post_content
*
* @param {boolean} true to apply lazyload for post_content
*
* @return {boolean} New value.
* @since 1.0
*/
if ( true === apply_filters( 'powered_cache_lazy_load_post_content', self::$settings['lazy_load_post_content'] ) ) {
add_filter( 'the_content', array( __CLASS__, 'filter' ), 200 );
}
/**
* Filters whether apply or not apply lazyload for widgets.
*
* @hook powered_cache_lazy_load_widget_text
*
* @param {boolean} true to apply lazyload for widgets
*
* @return {boolean} New value.
* @since 1.0
*/
if ( true === apply_filters( 'powered_cache_lazy_load_widget_text', self::$settings['lazy_load_widgets'] ) ) {
add_filter( 'widget_text', array( __CLASS__, 'filter' ), 200 );
}
/**
* Filters whether apply or not apply lazyload for thumbnails.
*
* @hook powered_cache_lazy_load_post_thumbnail
*
* @param {boolean} true to apply lazyload for thumbnails
*
* @return {boolean} New value.
* @since 1.0
*/
if ( true === apply_filters( 'powered_cache_lazy_load_post_thumbnail', self::$settings['lazy_load_post_thumbnail'] ) ) {
add_filter( 'post_thumbnail_html', array( __CLASS__, 'filter' ), 200 );
}
/**
* Filters whether replace youtube iframe with thumbnail.
*
* @hook powered_cache_lazy_load_youtube
*
* @param {boolean} true to replace youtube iframe with thumbnail
*
* @return {boolean} New value.
* @since 3.4
*/
if ( true === apply_filters( 'powered_cache_lazy_load_youtube', self::$settings['lazy_load_youtube'] ) ) {
add_filter( 'powered_cache_lazy_load_filter', array( __CLASS__, 'replace_youtube_iframe_with_thumbnail' ), 9 );
}
/**
* Filters whether apply or not apply lazyload for avatars.
*
* @hook powered_cache_lazy_load_avatar
*
* @param {boolean} true to apply lazyload for avatars
*
* @return {boolean} New value.
* @since 1.0
*/
if ( true === apply_filters( 'powered_cache_lazy_load_avatar', self::$settings['lazy_load_avatars'] ) ) {
add_filter( 'get_avatar', array( __CLASS__, 'filter' ), 200 );
}
/**
* * Filters whether apply or not apply lazyload for html.
*
* @hook powered_cache_lazy_load_html
*
* @param {boolean} true to apply lazyload for html
*
* @return {boolean} New value.
* @since 1.0
*/
add_filter( 'powered_cache_lazy_load_html', array( __CLASS__, 'filter' ) );
}
/**
* Filter HTML content. Replace supported content with placeholders.
*
* @param string $content The HTML string to filter
*
* @return string The filtered HTML string
*/
public static function filter( $content ) {
// Last chance to bail out before running the filter
/**
* Filters lazy-load run filter.
*
* @hook powered_cache_lazy_load_run_filter
*
* @param {boolean} $run_filter true to enable
*
* @return {boolean} New value
* @since 1.0
*/
$run_filter = apply_filters( 'powered_cache_lazy_load_run_filter', true );
if ( ! $run_filter ) {
return $content;
}
/**
* Filters the content
*
* @hook powered_cache_lazy_load_filter
*
* @param string $content The HTML string to filter
*
* @since 1.0
*/
$content = apply_filters( 'powered_cache_lazy_load_filter', $content );
return $content;
}
/**
* Replace images with placeholders in the content
*
* @param string $content The HTML to do the filtering on
*
* @return string The HTML with the images replaced
*/
public static function filter_images( $content ) {
/**
* Filters the content
*
* @hook powered_cache_lazy_load_filter
*
* @param string $content The HTML string to filter
*
* @since 1.0
*/
$placeholder_url = apply_filters( 'powered_cache_lazy_load_placeholder_url', 'data:image/gif;base64,R0lGODdhAQABAPAAAP///wAAACwAAAAAAQABAEACAkQBADs=' );
$match_content = self::get_content_haystack( $content );
$matches = array();
preg_match_all( '/<img[\s\r\n]+.*?>/is', $match_content, $matches );
$search = array();
$replace = array();
foreach ( $matches[0] as $img_html ) {
if ( self::is_excluded( $img_html ) ) {
continue;
}
// don't to the replacement if the image is a data-uri
if ( ! preg_match( "/src=['\"]data:image/is", $img_html ) ) {
$placeholder_url_used = $placeholder_url;
// replace the src and add the data-src attribute
$replace_html = preg_replace( '/<img(.*?)src=/is', '<img$1src="' . esc_attr( $placeholder_url_used ) . '" data-lazy-type="image" data-lazy-src=', $img_html );
// also replace the srcset (responsive images)
$replace_html = str_replace( 'srcset', 'data-lazy-srcset', $replace_html );
// add the lazy class to the img element
if ( preg_match( '/class=["\']/i', $replace_html ) ) {
$replace_html = preg_replace( '/class=(["\'])(.*?)["\']/is', 'class=$1lazy lazy-hidden $2$1', $replace_html );
} else {
$replace_html = preg_replace( '/<img/is', '<img class="lazy lazy-hidden"', $replace_html );
}
$replace_html .= '<noscript>' . $img_html . '</noscript>';
array_push( $search, $img_html );
array_push( $replace, $replace_html );
}
}
$content = str_replace( $search, $replace, $content );
return $content;
}
/**
* Replace iframes with placeholders in the content
*
* @param string $content The HTML to do the filtering on
*
* @return string The HTML with the iframes replaced
*/
public static function filter_iframes( $content ) {
/**
* Filters the lazyload placeholder URL.
*
* @hook powered_cache_lazy_load_placeholder_url
*
* @param string $image default placeholder url. Base64 string as default.
*
* @since 1.0
*/
$placeholder_url = apply_filters( 'powered_cache_lazy_load_placeholder_url', 'data:image/gif;base64,R0lGODdhAQABAPAAAP///wAAACwAAAAAAQABAEACAkQBADs=' );
$match_content = self::get_content_haystack( $content );
$matches = array();
preg_match_all( '|<iframe\s+.*?</iframe>|si', $match_content, $matches );
$search = array();
$replace = array();
foreach ( $matches[0] as $iframe_html ) {
if ( self::is_excluded( $iframe_html ) ) {
continue;
}
// Don't mess with the Gravity Forms ajax iframe
if ( strpos( $iframe_html, 'gform_ajax_frame' ) ) {
continue;
}
$replace_html = '<img src="' . esc_attr( $placeholder_url ) . '" class="lazy lazy-hidden" data-lazy-type="iframe" data-lazy-src="' . esc_attr( $iframe_html ) . '" alt="">';
$replace_html .= '<noscript>' . $iframe_html . '</noscript>';
array_push( $search, $iframe_html );
array_push( $replace, $replace_html );
}
$content = str_replace( $search, $replace, $content );
return $content;
}
/**
* Remove elements we don’t want to filter from the HTML string
* We’re reducing the haystack by removing the hay we know we don’t want to look for needles in
*
* @param string $content The HTML string
*
* @return string The HTML string without the unwanted elements
*/
protected static function get_content_haystack( $content ) {
$content = self::remove_noscript( $content );
$content = self::remove_skip_classes_elements( $content );
return $content;
}
/**
* Remove <noscript> elements from HTML string
*
* @param string $content The HTML string
*
* @return string The HTML string without <noscript> elements
* @author sigginet
*/
public static function remove_noscript( $content ) {
return preg_replace( '/<noscript.*?(\/noscript>)/i', '', $content );
}
/**
* Remove HTML elements with certain classnames (or IDs) from HTML string
*
* @param string $content The HTML string
*
* @return string The HTML string without the unwanted elements
*/
public static function remove_skip_classes_elements( $content ) {
$skip_classes = self::get_skip_classes( 'html' );
/**
* http://stackoverflow.com/questions/1732348/regex-match-open-tags-except-xhtml-self-contained-tags/1732454#1732454
* We can’t do this, but we still do it.
*/
$skip_classes_quoted = array_map( 'preg_quote', $skip_classes );
$skip_classes_ored = implode( '|', $skip_classes_quoted );
$regex = '/<\s*\w*\s*class\s*=\s*[\'"]?(|.*\s)?' . $skip_classes_ored . '(|\s.*)?[\'"]?.*?>/isU';
return preg_replace( $regex, '', $content );
}
/**
* Get the skip classes
*
* @param string $content_type The content type (image/iframe etc)
*
* @return array An array of strings with the class names
*/
protected static function get_skip_classes( $content_type ) {
/**
* Filters the class names to skip
*
* @hook powered_cache_lazy_load_skip_classes
*
* @param {array} $skip_classes The current classes to skip
* @param {string} $content_type The current content type
*
* @return {array} New value.
* @since 1.0
*/
$skip_classes = apply_filters( 'powered_cache_lazy_load_skip_classes', array( 'lazy' ), $content_type );
return $skip_classes;
}
/**
* Manage 3rd party compat cases
*/
public function compat() {
if ( function_exists( 'is_amp_endpoint' ) && is_amp_endpoint() ) {
add_filter( 'powered_cache_lazy_load_enabled', '__return_false' );
}
if ( function_exists( 'bp_is_my_profile' ) && bp_is_my_profile() ) {
add_filter( 'powered_cache_lazy_load_enabled', '__return_false' );
}
if ( function_exists( 'mopr_get_option' ) && WP_CONTENT_DIR . mopr_get_option( 'mobile_theme_root', 1 ) === get_theme_root() ) {
add_filter( 'powered_cache_lazy_load_enabled', '__return_false' );
}
if ( isset( $_SERVER['HTTP_USER_AGENT'] ) && false !== strpos( $_SERVER['HTTP_USER_AGENT'], 'Opera Mini' ) ) { // phpcs:ignore
add_filter( 'powered_cache_lazy_load_enabled', '__return_false' );
}
if ( 1 === intval( get_query_var( 'print' ) ) || 1 === intval( get_query_var( 'printpage' ) ) ) {
add_filter( 'powered_cache_lazy_load_enabled', '__return_false' );
}
if ( function_exists( 'bnc_wptouch_is_mobile' ) || defined( 'WPTOUCH_VERSION' ) ) {
add_filter( 'powered_cache_lazy_load_enabled', '__return_false' );
}
}
/**
* Maybe disable lazyloading for a particular post
*
* @param bool $status Lazyload filter status
*
* @return bool
* @since 2.0
*/
public function maybe_disable_through_meta( $status ) {
if ( in_the_loop() && get_post_meta( get_the_ID(), POST_META_DISABLE_LAZYLOAD_KEY, true ) ) {
$status = false;
}
return $status;
}
/**
* Skip lazyload for delayed script
*
* @param boolean $is_delay_skipped Whether skip or not skip delayed JS
* @param string $script script
*
* @return boolean
*/
public function delayed_js_skip( $is_delay_skipped, $script ) {
if ( false !== stripos( $script, 'powered-cache/dist/js/lazyload.js' ) || false !== stripos( $script, 'PCLL_' ) ) {
return true;
}
return $is_delay_skipped;
}
/**
* Exclude link preloader from file optimizer
*
* @param array $excluded_files the list of excluded files for optimization
*
* @return mixed
*/
public function add_file_optimizer_exclusion( $excluded_files ) {
$excluded_files[] = POWERED_CACHE_URL . 'dist/js/lazyload.js';
return $excluded_files;
}
/**
* Replace youtube iframe with thumbnail
*
* @param string $content HTML content, or the content of the current post if called in the loop.
*
* @return array|string|string[]|null
* @since 3.4
*/
public static function replace_youtube_iframe_with_thumbnail( $content ) {
$match_content = self::get_content_haystack( $content );
// Regular expression to match YouTube iframes
$pattern = '/<iframe[^>]+src="https?:\/\/www\.youtube\.com\/embed\/([a-zA-Z0-9_-]+)([^"]*)"[^>]*><\/iframe>/i';
// Find all YouTube iframes
preg_match_all( $pattern, $match_content, $matches );
$search = array();
$replace = array();
foreach ( $matches[0] as $iframe_html ) {
if ( self::is_excluded( $iframe_html ) ) {
continue;
}
// Extract video ID and additional parameters
preg_match( $pattern, $iframe_html, $iframe_parts );
$video_id = $iframe_parts[1];
$params = $iframe_parts[2];
// Construct the replacement HTML using the video ID
$replacement = '<div class="pcll-youtube-player" data-src="https://www.youtube.com/embed/' . $video_id . $params . '">
<img src="https://img.youtube.com/vi/' . $video_id . '/0.jpg" style="width:100%;height:auto;">
<div style="width: 100%; height: 100%;cursor:pointer;">
<svg class="pcll-youtube-play-button" viewBox="0 0 68 48" style="position: absolute; left: 50%; top: 50%; width: 68px; height: 48px; margin-left: -34px; margin-top: -24px; transition: opacity .25s cubic-bezier(0,0,.2,1); z-index: 63; cursor: pointer;">
<path d="M66.52,7.74c-0.78-2.93-3.07-5.22-6-6C53.08,0.74,34,0.74,34,0.74s-19.08,0-26.52,1C4.57,2.52,2.28,4.81,1.5,7.74C0.68,11,0.68,24,0.68,24s0,13,0.82,16.26c0.78,2.93,3.07,5.22,6,6c7.44,1,26.52,1,26.52,1s19.08,0,26.52-1c2.93-0.78,5.22-3.07,6-6C67.32,37,67.32,24,67.32,24S67.32,11,66.52,7.74z" fill-opacity="0.8" fill="#FF0000"></path>
<path d="M 45,24 27,14 27,34" fill="#fff"></path>
</svg>
</div>
</div>';
// Add original iframe HTML and replacement HTML to their respective arrays
array_push( $search, $iframe_html );
array_push( $replace, $replacement );
}
// Replace all YouTube iframes with their corresponding thumbnail placeholders
$content = str_replace( $search, $replace, $content );
return $content;
}
/**
* Add inline css for youtube lazyload
*
* @return string
* @since 3.4
*/
public function add_inline_youtube_css() {
$css = '';
$yt_lazyload = wp_normalize_path( POWERED_CACHE_PATH . 'dist/css/lazyload-youtube.css' );
if ( file_exists( $yt_lazyload ) ) {
$css = (string) file_get_contents( $yt_lazyload ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
}
/**
* Filters the inline css for youtube lazyload
*
* @hook powered_cache_lazy_load_youtube_css
*
* @param string $css
*
* @return string
* @since 3.4
*/
$css = apply_filters( 'powered_cache_lazy_load_youtube_css', $css );
return $css;
}
/**
* Get lazyload exclusion list
*
* @return array
* @since 3.4
*/
public static function get_exclusions() {
if ( ! self::$settings ) {
self::$settings = \PoweredCache\Utils\get_settings();
}
$load_exclusions = preg_split( '#(\r\n|\n|\r)#', self::$settings['lazy_load_exclusions'], - 1, PREG_SPLIT_NO_EMPTY );
$load_exclusions[] = 'selectors.core.image.lightboxObjectFit'; // exclude core lightboxed images
/**
* Filter the lazyload exclusions
*
* @hook powered_cache_lazy_load_exclusions
*
* @param {array} $load_exclusions The current exclusions
*
* @return {array} New value
* @since 3.4
*/
return (array) apply_filters( 'powered_cache_lazy_load_exclusions', $load_exclusions );
}
/**
* Check if excluded or not from defer
*
* @param string $tag Resource
*
* @return bool
* @since 3.4
*/
public static function is_excluded( $tag ) {
$excluded_files = self::get_exclusions();
$excluded_files = implode( '|', $excluded_files );
if ( ! empty( $excluded_files ) && preg_match( '#(' . $excluded_files . ')#', $tag ) ) {
return true;
}
return false;
}
}