Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 69
0.00% covered (danger)
0.00%
0 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImageLazyLoader
0.00% covered (danger)
0.00%
0 / 69
0.00% covered (danger)
0.00%
0 / 6
702
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 apply_lazy_loading_to_blocks
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 enqueue_lazy_loader
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 get_inline_script
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 clean_content
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 apply_lazy_loading
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
210
1<?php
2
3namespace NewfoldLabs\WP\Module\Performance\Images;
4
5use DOMDocument;
6use Exception;
7
8/**
9 * Manages the initialization and application of lazy loading for images.
10 */
11class ImageLazyLoader {
12
13    /**
14     * Exclusion rules for lazy loading.
15     * Defines classes and attributes that should prevent lazy loading from being applied to specific images.
16     *
17     * @var array
18     */
19    private static $exclusions = array(
20        'classes'    => array(
21            'nfd-performance-not-lazy',
22            'a3-notlazy',
23            'disable-lazyload',
24            'no-lazy',
25            'no-lazyload',
26            'skip-lazy',
27        ),
28        'attributes' => array(
29            'data-lazy-src',
30            'data-crazy-lazy="exclude"',
31            'data-no-lazy',
32            'data-no-lazy="1"',
33        ),
34    );
35
36    /**
37     * List of content filters where lazy loading will be applied.
38     * These filters modify various types of WordPress content.
39     *
40     * @var array
41     */
42    private static $content_filters = array(
43        'the_content',
44        'post_thumbnail_html',
45        'widget_text',
46        'get_avatar',
47    );
48
49    /**
50     * Constructor to initialize the lazy loading feature.
51     */
52    public function __construct() {
53        // Enqueue the lazy loader script with inline settings.
54        add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_lazy_loader' ) );
55
56        // Add filters to apply lazy loading to various content types.
57        foreach ( self::$content_filters as $filter ) {
58            add_filter( $filter, array( $this, 'apply_lazy_loading' ) );
59        }
60
61        // Hook into Gutenberg block rendering to apply lazy loading.
62        add_filter( 'render_block', array( $this, 'apply_lazy_loading_to_blocks' ), 10, 2 );
63    }
64
65    /**
66     * Applies lazy loading to images within Gutenberg blocks.
67     *
68     * @param string $block_content The HTML content of the block.
69     * @param array  $block The block data array.
70     * @return string Modified block content with lazy loading applied.
71     */
72    public function apply_lazy_loading_to_blocks( $block_content, $block ) {
73        // Only target core/image blocks or other blocks with images.
74        if ( 'core/image' === $block['blockName'] || strpos( $block_content, '<img' ) !== false ) {
75            return $this->apply_lazy_loading( $block_content );
76        }
77
78        return $block_content;
79    }
80
81    /**
82     * Enqueues the lazy loader script file and adds inline exclusion settings.
83     */
84    public function enqueue_lazy_loader() {
85        $script_path = NFD_PERFORMANCE_BUILD_DIR . '/assets/image-lazy-loader.min.js';
86        $script_url  = NFD_PERFORMANCE_BUILD_URL . '/assets/image-lazy-loader.min.js';
87
88        // Register the script with version based on file modification time.
89        wp_register_script(
90            'nfd-performance-lazy-loader',
91            $script_url,
92            array(),
93            file_exists( $script_path ) ? filemtime( $script_path ) : false,
94            true
95        );
96
97        // Inject the exclusion settings into the script.
98        wp_add_inline_script(
99            'nfd-performance-lazy-loader',
100            $this->get_inline_script(),
101            'before'
102        );
103
104        wp_enqueue_script( 'nfd-performance-lazy-loader' );
105    }
106
107    /**
108     * Generates the inline script to define lazy loading exclusions.
109     * This script populates the `window.nfdPerformance` object with exclusion rules.
110     *
111     * @return string JavaScript code to inline.
112     */
113    private function get_inline_script() {
114        return 'window.nfdPerformance = window.nfdPerformance || {};
115        window.nfdPerformance.imageOptimization = window.nfdPerformance.imageOptimization || {};
116        window.nfdPerformance.imageOptimization.lazyLoading = ' . wp_json_encode( self::$exclusions ) . ';';
117    }
118
119    /**
120     * Cleans up content by replacing specific patterns with replacements.
121     * This method is used to sanitize or modify content before lazy loading is applied.
122     *
123     * @param string $pattern Regular expression pattern to match.
124     * @param string $search String to search for in the content.
125     * @param string $replace String to replace the search string with.
126     * @param string $content The content to be cleaned.
127     * @return string The cleaned content.
128     */
129    public function clean_content( $pattern, $search, $replace, $content ) {
130
131        if ( empty( $content ) || empty( $pattern ) || empty( $search ) ) {
132            return $content;
133        }
134
135        $content = preg_replace_callback(
136            $pattern,
137            function ( $matches ) use ( $search, $replace ) {
138                $cleanedIframe = str_replace( $search, $replace, $matches[0] );
139                return $cleanedIframe;
140            },
141            $content
142        );
143        return $content;
144    }
145
146    /**
147     * Applies lazy loading to images in HTML content.
148     * Skips images with specified exclusion classes or attributes.
149     *
150     * @param string $content The HTML content to process.
151     * @return string Modified HTML content with lazy loading applied, or original content on error.
152     */
153    public function apply_lazy_loading( $content ) {
154        // Return unmodified content if it is empty.
155        if ( empty( $content ) ) {
156            return $content;
157        }
158
159        $doc = new DOMDocument();
160        // Suppress warnings from invalid or malformed HTML.
161        libxml_use_internal_errors( true );
162
163        try {
164            if ( function_exists( 'et_setup_theme' ) ) {
165                $content = $this->clean_content(
166                    '/<iframe(.*?)<\/iframe>/s',
167                    '<!-- [et_pb_line_break_holder] -->',
168                    '',
169                    $content
170                );
171            }
172
173            // Attempt to parse the HTML content using htmlentities for encoding.
174            $content = '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>' . $content . '</body></html>';
175            if ( ! $doc->loadHTML( $content, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ) ) {
176                return $content;
177            }
178        } catch ( Exception $e ) {
179            return $content;
180        } finally {
181            // Clear any errors collected during parsing to free memory.
182            libxml_clear_errors();
183        }
184
185        $images = $doc->getElementsByTagName( 'img' );
186
187        foreach ( $images as $image ) {
188            $skip = false;
189
190            // Check if the image has an excluded class.
191            foreach ( self::$exclusions['classes'] as $class ) {
192                if ( $image->hasAttribute( 'class' ) && strpos( $image->getAttribute( 'class' ), $class ) !== false ) {
193                    $skip = true;
194                    break;
195                }
196            }
197
198            // Check if the image has an excluded attribute.
199            foreach ( self::$exclusions['attributes'] as $attr ) {
200                if ( $image->hasAttribute( $attr ) ) {
201                    $skip = true;
202                    break;
203                }
204            }
205
206            if ( $skip ) {
207                continue;
208            }
209
210            // Add the loading="lazy" attribute if not already present.
211            if ( ! $image->hasAttribute( 'loading' ) ) {
212                $image->setAttribute( 'loading', 'lazy' );
213            }
214        }
215
216        // Extract the body content and return it.
217        $body = $doc->getElementsByTagName( 'body' )->item( 0 );
218        return $body ? $doc->saveHTML( $body ) : $content;
219    }
220}