PluginProbe
Powered Cache – Caching and Optimization for WordPress – Easily Improve PageSpeed & Web Vitals Score / 3.7
Powered Cache – Caching and Optimization for WordPress – Easily Improve PageSpeed & Web Vitals Score v3.7
trunk 1.0 1.0.1 1.1 1.1.1 1.1.2 1.2 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 2.0 2.0.1 2.0.2 2.0.3 2.0.4 2.1 2.1.1 2.1.2 2.2 2.2.1 All 69 releases
powered-cache / includes / classes / FileOptimizer.php

FileOptimizer.php in Powered Cache – Caching and Optimization for WordPress – Easily Improve PageSpeed & Web Vitals Score 3.7, at includes/classes/FileOptimizer.php

934 lines 25.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * File optimizer (minify, contact) related functionalities
4 *
5 * @package PoweredCache
6 */
7
8 namespace PoweredCache;
9
10 use PoweredCache\Dependencies\voku\helper\HtmlMin;
11 use const PoweredCache\Constants\POST_META_DISABLE_CSS_OPTIMIZATION;
12 use const PoweredCache\Constants\POST_META_DISABLE_JS_DEFER;
13 use const PoweredCache\Constants\POST_META_DISABLE_JS_DELAY;
14 use const PoweredCache\Constants\POST_META_DISABLE_JS_OPTIMIZATION;
15 use PoweredCache\Optimizer\CSS;
16 use PoweredCache\Optimizer\Helper;
17 use PoweredCache\Optimizer\JS;
18 use function PoweredCache\Utils\get_cache_dir;
19 use function PoweredCache\Utils\remove_dir;
20
21
22 /**
23 * Class FileOptimizer
24 */
25 class FileOptimizer {
26 /**
27 * Plugin settings
28 *
29 * @var array
30 */
31 public $settings = [];
32
33 /**
34 * Do optimizations for wp-admin?
35 *
36 * @var bool
37 */
38 public $optimize_dashboard;
39
40
41 /**
42 * Return an instance of the current class
43 *
44 * @return FileOptimizer
45 * @since 1.0
46 */
47 public static function factory() {
48 static $instance = false;
49
50 if ( ! $instance ) {
51 $instance = new self();
52 $instance->setup();
53 }
54
55 return $instance;
56 }
57
58 /**
59 * Setup routine
60 */
61 public function setup() {
62 $this->settings = \PoweredCache\Utils\get_settings();
63
64 // Check request method
65 if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || ! in_array( $_SERVER['REQUEST_METHOD'], [ 'GET', 'HEAD' ], true ) ) {
66 return true;
67 }
68
69 add_action( 'plugins_loaded', [ $this, 'setup_file_optimizer' ] );
70
71 }
72
73 /**
74 * Setup file optimizer module
75 */
76 public function setup_file_optimizer() {
77 /**
78 * Filters whether apply or not apply file optimizations on wp-admin.
79 *
80 * @hook powered_cache_fo_dashboard
81 *
82 * @param {boolean} true to enable optimizations for dashboard.
83 *
84 * @return {boolean} New value.
85 * @since 2.0
86 */
87 $this->optimize_dashboard = apply_filters( 'powered_cache_fo_dashboard', false );
88
89 add_action( 'powered_cache_purge_all_cache', [ $this, 'maybe_purge_fo_cache' ] );
90
91 /**
92 * Don't optimize wp-admin by default
93 * This might worth to add as option?
94 */
95
96 if ( is_admin() && ! $this->optimize_dashboard ) {
97 return;
98 }
99
100 /**
101 * Don't optimize in customizer preview
102 */
103 if ( ! empty( $_GET['customize_changeset_uuid'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
104 \PoweredCache\Utils\log( 'Do not run file optimizer in customizer preview' );
105
106 return;
107 }
108
109 /**
110 * Filters FileOptimizer integration
111 *
112 * @hook powered_cache_fo_disable
113 *
114 * @param {boolean} False by default.
115 *
116 * @return {boolean} New value.
117 * @since 2.2
118 */
119 $disable_file_optimizer = apply_filters( 'powered_cache_fo_disable', false );
120
121 if ( $disable_file_optimizer ) {
122 return;
123 }
124
125 add_action( 'init', [ $this, 'setup_css_combine' ] );
126 add_action( 'init', [ $this, 'setup_js_combine' ] );
127 add_filter( 'script_loader_tag', [ $this, 'js_minify' ], 10, 3 );
128 add_filter( 'style_loader_tag', [ $this, 'css_minify' ], 10, 4 );
129 add_filter( 'powered_cache_fo_script_loader_tag', [ $this, 'change_js_execute_method' ] );
130 add_filter( 'script_loader_tag', [ $this, 'change_js_execute_method' ], 99 );
131 add_action( 'after_setup_theme', [ $this, 'maybe_start_buffer' ], 999 );
132 add_action( 'template_redirect', [ $this, 'maybe_suppress_optimizations' ] );
133
134 if ( ! $this->settings['combine_js'] ) {
135 add_filter( 'powered_cache_fo_js_do_concat', '__return_false' );
136 }
137
138 if ( ! $this->settings['combine_css'] ) {
139 add_filter( 'powered_cache_fo_css_do_concat', '__return_false' );
140 }
141
142 if ( $this->settings['combine_google_fonts'] ) {
143 add_action( 'wp_enqueue_scripts', [ $this, 'combine_google_fonts' ], 99 );
144 }
145 }
146
147 /**
148 * Process output buffer
149 *
150 * @return void
151 */
152 public function maybe_start_buffer() {
153 if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
154 return;
155 }
156 $request_uri = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) );
157
158 if ( strpos( $request_uri, 'robots.txt' ) !== false || strpos( $request_uri, '.htaccess' ) !== false ) {
159 return;
160 }
161
162 if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'GET' !== $_SERVER['REQUEST_METHOD'] ) {
163 return;
164 }
165
166 $file_extension = preg_replace( '#^(.*?)\?.*$#', '$1', $request_uri );
167 $file_extension = trim( preg_replace( '#^.*\.(.*)$#', '$1', $file_extension ) );
168
169 if ( ! preg_match( '#index\.php$#i', $request_uri ) && in_array( $file_extension, array( 'php', 'xml', 'xsl' ), true ) ) {
170 return;
171 }
172
173 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
174 return;
175 }
176
177 ob_start( [ $this, 'process_buffer' ] );
178 }
179
180 /**
181 * Process output buffer
182 *
183 * @param string $html Output buffer
184 *
185 * @return string|string[]|null
186 */
187 public function process_buffer( $html ) {
188 $html = $this->maybe_defer_inline_scripts( $html );
189 $html = $this->maybe_delay_scripts( $html );
190 $html = $this->maybe_replace_google_fonts_with_bunny_fonts( $html );
191 $html = $this->maybe_minify_html( $html );
192
193 return $html;
194 }
195
196 /**
197 * Replace Google Fonts with Bunny drop-in replacement's
198 *
199 * @param string $html Output buffer
200 *
201 * @return array|mixed|string|string[]
202 * @since 3.0
203 */
204 public function maybe_replace_google_fonts_with_bunny_fonts( $html ) {
205 if ( ! $this->settings['use_bunny_fonts'] ) {
206 return $html;
207 }
208
209 $html = str_replace(
210 [
211 'https://fonts.googleapis.com',
212 'http://fonts.googleapis.com',
213 '//fonts.googleapis.com',
214 ],
215 'https://fonts.bunny.net',
216 $html
217 );
218
219 return $html;
220 }
221
222 /**
223 * Maybe suppress optimizations based on the post meta options
224 *
225 * @since 2.1
226 */
227 public function maybe_suppress_optimizations() {
228 if ( is_singular() ) {
229 $disable_css_optimization = (bool) get_post_meta( get_the_ID(), POST_META_DISABLE_CSS_OPTIMIZATION, true );
230 $disable_js_optimization = (bool) get_post_meta( get_the_ID(), POST_META_DISABLE_JS_OPTIMIZATION, true );
231 $disable_js_defer = (bool) get_post_meta( get_the_ID(), POST_META_DISABLE_JS_DEFER, true );
232 $disable_js_delay = (bool) get_post_meta( get_the_ID(), POST_META_DISABLE_JS_DELAY, true );
233
234 if ( $disable_css_optimization ) {
235 add_filter( 'powered_cache_fo_css_do_concat', '__return_false' );
236 add_filter( 'powered_cache_fo_disable_css_minify', '__return_true' );
237 }
238
239 if ( $disable_js_optimization ) {
240 add_filter( 'powered_cache_fo_js_do_concat', '__return_false' );
241 add_filter( 'powered_cache_fo_disable_js_minify', '__return_true' );
242 }
243
244 if ( $disable_js_defer ) {
245 add_filter( 'powered_cache_disable_js_defer', '__return_true' );
246 add_filter( 'powered_cache_disable_js_defer_inline', '__return_true' );
247 }
248
249 if ( $disable_js_delay ) {
250 add_filter( 'powered_cache_delayed_js_skip', '__return_true' );
251 }
252 }
253 }
254
255 /**
256 * Purge FO cache
257 *
258 * @since 2.0
259 */
260 public function maybe_purge_fo_cache() {
261 if ( file_exists( POWERED_CACHE_FO_CACHE_DIR ) ) {
262 remove_dir( POWERED_CACHE_FO_CACHE_DIR );
263 }
264 }
265
266 /**
267 * Minify given HTML output
268 *
269 * @param string $buffer HTML
270 *
271 * @return string|string[]|null
272 */
273 public function maybe_minify_html( $buffer ) {
274 if ( ! $this->settings['minify_html'] ) {
275 return $buffer;
276 }
277
278 if ( false === stripos( $buffer, '<html' ) && false === stripos( $buffer, '<!DOCTYPE html' ) ) {
279 return $buffer;
280 }
281
282 /**
283 * Filters whether disable or not disable HTML minification.
284 *
285 * @hook powered_cache_fo_disable_html_minify
286 *
287 * @param {boolean} true to disable html minify
288 *
289 * @return {boolean} New value.
290 * @since 2.0
291 */
292 if ( apply_filters( 'powered_cache_fo_disable_html_minify', false ) ) {
293 return $buffer;
294 }
295
296 $html_min = new HtmlMin();
297 $html_min->doOptimizeViaHtmlDomParser( $this->settings['minify_html_dom_optimization'] );
298 $html_min->doRemoveOmittedQuotes( false );
299 $html_min->doRemoveOmittedHtmlTags( false );
300 $html_min->overwriteSpecialScriptTags( [ 'x-tmpl-mustache', 'text/template' ] );
301 $buffer = $html_min->minify( $buffer );
302
303 if ( ! $this->settings['minify_html_dom_optimization'] ) {
304 // Remove line breaks and spaces between tags
305 $buffer = preg_replace( '/>\s+</', '><', $buffer );
306 // Remove comments
307 $buffer = preg_replace( '/<!--(.|\s)*?-->/', '', $buffer );
308 }
309
310 return $buffer;
311 }
312
313
314 /**
315 * Add JS execution method to script tag
316 *
317 * @param string $tag <script... tag
318 *
319 * @return mixed
320 */
321 public function change_js_execute_method( $tag ) {
322 $is_defer = $this->settings['js_defer'];
323
324 if ( ! $is_defer ) {
325 return $tag;
326 }
327
328 if ( preg_match( '/<script\s+[^>]*\b(?:async|defer)\b[^>]*>/i', $tag ) ) {
329 return $tag;
330 }
331
332 if ( Helper::is_defer_excluded( $tag ) ) {
333 return $tag;
334 }
335
336 /**
337 * Filters whether disable or not disable Defer
338 *
339 * @hook powered_cache_disable_js_defer
340 *
341 * @param {boolean} true to disable defer
342 *
343 * @return {boolean} New value.
344 * @since 3.2
345 */
346 if ( apply_filters( 'powered_cache_disable_js_defer', false, $tag ) ) {
347 return $tag;
348 }
349
350 $search = '<script ';
351 $replace = '<script defer="defer" ';
352 $tag = str_replace( $search, $replace, $tag );
353
354 return $tag;
355 }
356
357 /**
358 * Minify CSS item
359 *
360 * @param string $tag style tag <link...
361 * @param string $handle style handle name
362 * @param string $href Resource URL
363 * @param string $media CSS media
364 *
365 * @return mixed
366 */
367 public function css_minify( $tag, $handle, $href, $media ) {
368 if ( ! $this->settings['minify_css'] ) {
369 return $tag;
370 }
371
372 /**
373 * Filters whether disable or not disable CSS minification.
374 *
375 * @hook powered_cache_fo_disable_css_minify
376 *
377 * @param {boolean} true to disable CSS minify
378 *
379 * @return {boolean} New value.
380 * @since 2.0
381 */
382 if ( apply_filters( 'powered_cache_fo_disable_css_minify', false ) ) {
383 return $tag;
384 }
385
386 // only minify static .css
387 if ( false === strpos( $href, '.css' ) ) {
388 return $tag;
389 }
390
391 // already minified
392 if ( false !== strpos( $href, '.min' ) ) {
393 return $tag;
394 }
395
396 // only minify local hosted
397 if ( ! Helper::is_internal_url( $href, home_url() ) ) {
398 return $tag;
399 }
400
401 // excluded explicitly
402 if ( Helper::is_excluded_css( $href ) ) {
403 return $tag;
404 }
405
406 $realpath = Helper::realpath( $href, home_url() );
407 $path = substr( $realpath, strlen( ABSPATH ) - 1 );
408 $optimized_url = Helper::get_optimized_url( $path, true );
409 $new_tag = str_replace( $href, $optimized_url, $tag );
410
411 return $new_tag;
412 }
413
414 /**
415 * Minify JS file
416 *
417 * @param string $tag script tag <script...
418 * @param string $handle JS handle
419 * @param string $src Resource URL
420 *
421 * @return mixed
422 */
423 public function js_minify( $tag, $handle, $src ) {
424 if ( ! $this->settings['minify_js'] ) {
425 return $tag;
426 }
427
428 /**
429 * Filters whether disable or not disable JS minification.
430 *
431 * @hook powered_cache_fo_disable_js_minify
432 *
433 * @param {boolean} true to disable JS minify
434 * @param {string} $tag script tag <script...
435 * @param {string} $handle JS handle
436 * @param {string} $src Resource URL
437 *
438 * @return {boolean} New value.
439 * @since 2.0
440 */
441 if ( apply_filters( 'powered_cache_fo_disable_js_minify', false, $tag, $handle, $src ) ) {
442 return $tag;
443 }
444
445 // only minify static .css
446 if ( false === strpos( $src, '.js' ) ) {
447 return $tag;
448 }
449
450 // already minified
451 if ( false !== strpos( $src, '.min' ) ) {
452 return $tag;
453 }
454
455 // only minify local hosted
456 if ( ! Helper::is_internal_url( $src, home_url() ) ) {
457 return $tag;
458 }
459
460 // excluded explicitly
461 if ( Helper::is_excluded_js( $src ) ) {
462 return $tag;
463 }
464
465 $realpath = Helper::realpath( $src, home_url() );
466 $path = substr( $realpath, strlen( ABSPATH ) - 1 );
467 $optimized_url = Helper::get_optimized_url( $path, true );
468 $new_tag = str_replace( $src, $optimized_url, $tag );
469
470 return $new_tag;
471 }
472
473
474 /**
475 * Setup JS concat
476 */
477 public function setup_js_combine() {
478 if ( ! $this->settings['combine_js'] ) {
479 return;
480 }
481
482 /**
483 * Filters whether disable or not disable JS combine
484 *
485 * @hook powered_cache_fo_disable_js_combine
486 *
487 * @param {boolean} true to disable JS combine
488 *
489 * @return {boolean} New value.
490 * @since 2.0
491 */
492 if ( apply_filters( 'powered_cache_fo_disable_js_combine', false ) ) {
493 return;
494 }
495
496 global $wp_scripts;
497
498 $wp_scripts = new JS( $wp_scripts );
499 /**
500 * Filters whether allow or not allow gzip compression for combined file names.
501 *
502 * @hook powered_cache_fo_allow_gzip_compression
503 *
504 * @param {boolean} true to enable gzip compression on filenames
505 *
506 * @return {boolean} New value.
507 * @since 2.0
508 */
509 $wp_scripts->allow_gzip_compression = apply_filters( 'powered_cache_fo_allow_gzip_compression', true );
510 $wp_scripts->do_minify = $this->settings['minify_js'];
511
512 }
513
514 /**
515 * Setup CSS concat
516 */
517 public function setup_css_combine() {
518 if ( ! $this->settings['combine_css'] ) {
519 return;
520 }
521
522 /**
523 * Filters whether disable or not disable CSS combine
524 *
525 * @hook powered_cache_fo_disable_css_combine
526 *
527 * @param {boolean} true to disable CSS combine
528 *
529 * @return {boolean} New value.
530 * @since 2.0
531 */
532 if ( apply_filters( 'powered_cache_fo_disable_css_combine', false ) ) {
533 return;
534 }
535
536 global $wp_styles;
537
538 $wp_styles = new CSS( $wp_styles );
539 $wp_styles->allow_gzip_compression = apply_filters( 'powered_cache_fo_allow_gzip_compression', true );
540 $wp_styles->do_minify = $this->settings['minify_css'];
541 $wp_styles->enable_cdn = $this->settings['enable_cdn'];
542
543 }
544
545 /**
546 * Combine google fonts
547 * Derived from https://gist.github.com/eugenealegiojo/dbdd620a998458aa2eb1f124b2f0b18e
548 */
549 public function combine_google_fonts() {
550 global $wp_styles;
551
552 // Check for any enqueued `fonts.googleapis.com` from themes or plugins
553 if ( isset( $wp_styles->queue ) ) {
554 $google_fonts_domain = '//fonts.googleapis.com/css';
555 $enqueued_google_fonts = array();
556 $families = array();
557 $subsets = array();
558 $font_args = array();
559 $font_display = '';
560
561 // Collect all enqueued google fonts
562 foreach ( $wp_styles->queue as $key => $handle ) {
563 if ( ! isset( $wp_styles->registered[ $handle ] ) ) {
564 continue;
565 }
566
567 $style_src = $wp_styles->registered[ $handle ]->src;
568
569 if ( false !== strpos( $style_src, 'fonts.googleapis.com/css' ) || false !== strpos( $style_src, 'fonts.bunny.net/css' ) ) {
570 $url = wp_parse_url( $style_src );
571
572 if ( is_string( $url['query'] ) ) {
573 parse_str( $url['query'], $parsed_url );
574
575 if ( isset( $parsed_url['family'] ) ) {
576 // Collect all subsets
577 if ( isset( $parsed_url['subset'] ) ) {
578 $subsets[] = rawurlencode( trim( $parsed_url['subset'] ) );
579 }
580
581 $font_families = explode( '|', $parsed_url['family'] );
582 foreach ( $font_families as $parsed_font ) {
583 $get_font = explode( ':', $parsed_font );
584
585 // Extract the font data
586 if ( isset( $get_font[0] ) && ! empty( $get_font[0] ) ) {
587 $family = $get_font[0];
588 $weights = isset( $get_font[1] ) && ! empty( $get_font[1] ) ? explode( ',', $get_font[1] ) : array();
589
590 // Combine weights if family has been enqueued
591 if ( isset( $enqueued_google_fonts[ $family ] ) && $weights !== $enqueued_google_fonts[ $family ]['weights'] ) {
592 $combined_weights = array_merge( $weights, $enqueued_google_fonts[ $family ]['weights'] );
593 $enqueued_google_fonts[ $family ]['weights'] = array_unique( $combined_weights );
594 } else {
595 $enqueued_google_fonts[ $family ] = array(
596 'handle' => $handle,
597 'family' => $family,
598 'weights' => $weights,
599 );
600
601 if ( isset( $parsed_url['display'] ) ) {
602 $font_display = $parsed_url['display'];
603 }
604
605 // Remove enqueued google font style, so we would only have one HTTP request.
606 wp_dequeue_style( $handle );
607 }
608 }
609 }
610 }
611 }
612 }
613 }
614
615 // Combine all queued fonts
616 if ( count( $enqueued_google_fonts ) > 0 ) {
617 foreach ( $enqueued_google_fonts as $family => $data ) {
618 // Collect all family and weights
619 if ( ! empty( $data['weights'] ) ) {
620 $families[] = $family . ':' . implode( ',', $data['weights'] );
621 } else {
622 $families[] = $family;
623 }
624 }
625
626 if ( ! empty( $families ) ) {
627 $font_args['family'] = implode( '|', $families );
628
629 if ( ! empty( $subsets ) ) {
630 $font_args['subset'] = implode( ',', $subsets );
631 }
632
633 /**
634 * Force font display: swap
635 *
636 * @since 2.2
637 */
638 if ( $this->settings['swap_google_fonts_display'] ) {
639 $font_display = 'swap';
640 }
641
642 /**
643 * Filters font display attribute of the google fonts.
644 *
645 * @hook powered_cache_fo_google_font_display
646 *
647 * @param {string} $font_display font display attribute.
648 *
649 * @return {string} New value.
650 * @since 2.0
651 */
652 $font_display = apply_filters( 'powered_cache_fo_google_font_display', $font_display );
653
654 if ( ! empty( $font_display ) ) {
655 $font_args['display'] = $font_display;
656 }
657
658 /**
659 * Filters google font's domain
660 *
661 * @hook powered_cache_fo_google_fonts_domain
662 *
663 * @param {string} $font_display font display attribute.
664 *
665 * @return {string} New value.
666 * @since 3.0
667 */
668 $fonts_domain = apply_filters( 'powered_cache_fo_google_fonts_domain', $google_fonts_domain );
669
670 $src = esc_url_raw( add_query_arg( $font_args, $fonts_domain ) );
671
672 // Enqueue google fonts into one URL request
673 wp_enqueue_style( // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion
674 'pc-google-fonts-' . md5( $src ),
675 $src,
676 array()
677 );
678
679 unset( $enqueued_google_fonts );
680 }
681 }
682 }
683 }
684
685 /**
686 * DelayedJS execution
687 * Similar logic with image lazy-loading but this time for scripts.
688 *
689 * @param string $html HTML Buffer
690 *
691 * @return string
692 * @since 3.0
693 */
694 public function maybe_delay_scripts( $html ) {
695 if ( ! $this->settings['js_delay'] ) {
696 return $html;
697 }
698
699 $pattern = '/<script([^>]*)>(.*?)<\/script>/si';
700
701 preg_match_all( $pattern, $html, $matches, PREG_SET_ORDER );
702
703 if ( empty( $matches ) ) {
704 return $html;
705 }
706
707 $delayed_script_count = 0;
708
709 foreach ( $matches as $match ) {
710 $script = $match[0];
711 $attributes = $match[1];
712 $content = $match[2];
713
714 $is_delay_skipped = function_exists( 'is_user_logged_in' ) && is_user_logged_in();
715
716 /**
717 * Whether skip or not skip js for delay
718 *
719 * @hook powered_cache_delayed_js_skip
720 *
721 * @param {bool} Depends on logged-in status by default.
722 *
723 * @return {bool} New value.
724 * @since 3.0
725 */
726 if ( apply_filters( 'powered_cache_delayed_js_skip', $is_delay_skipped, $script, $attributes, $content ) ) {
727 continue;
728 }
729
730 if ( Helper::is_delay_excluded( $script ) ) {
731 continue;
732 }
733
734 if ( ! preg_match( '/type=/', $script ) || preg_match( '/type=[\'"]text\/javascript[\'"]/', $script ) ) {
735 $new_script = $script;
736
737 // Check if script has a src attribute
738 if ( strpos( $attributes, 'src=' ) !== false ) {
739 $new_script = preg_replace( '/<script([^>]*)>/', '<script type="pc-delayed-js"$1>', $new_script );
740 } else {
741 $new_script = preg_replace( '/<script([^>]*)>/', '<script type="pc-delayed-js"$1>', $new_script );
742 }
743
744 $html = str_replace( $script, $new_script, $html );
745 $delayed_script_count ++;
746 }
747 }
748
749 if ( 0 === $delayed_script_count ) {
750 return $html;
751 }
752
753 /**
754 * Filter delay time for JS execution fallback
755 *
756 * @hook powered_cache_delayed_js_timeout
757 *
758 * @param {int} $delay_in_ms Delay time in milliseconds
759 *
760 * @return {int} New value.
761 * @since 3.0
762 */
763 $delay_timeout = apply_filters( 'powered_cache_delayed_js_timeout', $this->settings['js_delay_timeout'] );
764 $script_path = POWERED_CACHE_PATH . 'dist/js/script-loader.js';
765 $script_content = file_get_contents( $script_path ); // phpcs:ignore
766
767 if ( ! $script_content ) {
768 return $html;
769 }
770
771 $head_pos = strpos( $html, '</head>' );
772
773 if ( false === $head_pos ) { // bail if no head tag
774 return $html;
775 }
776
777 $delay_js_script_content = '<script id="powered-cache-delayed-js">' . $script_content . '</script>' . PHP_EOL;
778
779 /**
780 * Delayed JS script content
781 *
782 * @hook powered_cache_delayed_js_script_content
783 *
784 * @param {string} $delay_js_script_content Delayed JS script content
785 * @param {string} $script_path Script path
786 * @param {string} $html HTML buffer
787 * @param {int} $delay_timeout Delay time in milliseconds
788 *
789 * @return {string} New value.
790 * @since 3.4
791 */
792 $delay_js_script_content = apply_filters( 'powered_cache_delayed_js_script_content', $delay_js_script_content, $script_path, $html, $delay_timeout );
793
794 $html = substr_replace( $html, $delay_js_script_content, $head_pos, 0 );
795
796 $script_loader = PHP_EOL . '<script id="powered-cache-delayed-script-loader">' . PHP_EOL;
797 $script_loader .= 'console.log("[Powered Cache] - Script(s) will be loaded with delay or interaction");' . PHP_EOL;
798 $script_loader .= 'window.PCScriptLoaderTimeout=' . absint( $delay_timeout ) . ';' . PHP_EOL;
799
800 $script_loader .= 'Defer.all(\'script[type="pc-delayed-js"]\', window.PCScriptLoaderTimeout, true);' . PHP_EOL;
801
802 // Dispatch DOMContentLoaded event after delayed scripts are loaded
803 // This ensures scripts that listen for DOMContentLoaded still execute
804 $script_loader .= '(function(){' . PHP_EOL;
805 $script_loader .= ' var pcDelayedScripts=document.querySelectorAll(\'script[type="pc-delayed-js"]\');' . PHP_EOL;
806 $script_loader .= ' if(pcDelayedScripts.length===0)return;' . PHP_EOL;
807 $script_loader .= ' var pcScriptCount=pcDelayedScripts.length;' . PHP_EOL;
808 $script_loader .= ' var pcCheckInterval=setInterval(function(){' . PHP_EOL;
809 $script_loader .= ' var remaining=document.querySelectorAll(\'script[type="pc-delayed-js"]\').length;' . PHP_EOL;
810 $script_loader .= ' if(remaining===0){' . PHP_EOL;
811 $script_loader .= ' clearInterval(pcCheckInterval);' . PHP_EOL;
812 $script_loader .= ' setTimeout(function(){' . PHP_EOL;
813 $script_loader .= ' if(document.readyState==="complete"||document.readyState==="interactive"){' . PHP_EOL;
814 $script_loader .= ' var event=document.createEvent?document.createEvent("Event"):new Event("DOMContentLoaded");' . PHP_EOL;
815 $script_loader .= ' if(document.createEvent){event.initEvent("DOMContentLoaded",true,true);}' . PHP_EOL;
816 $script_loader .= ' document.dispatchEvent(event);' . PHP_EOL;
817 $script_loader .= ' console.log("[Powered Cache] - DOMContentLoaded event dispatched for "+pcScriptCount+" delayed script(s)");' . PHP_EOL;
818 $script_loader .= ' }' . PHP_EOL;
819 $script_loader .= ' },10);' . PHP_EOL;
820 $script_loader .= ' }' . PHP_EOL;
821 $script_loader .= ' },50);' . PHP_EOL;
822 $script_loader .= '})();' . PHP_EOL;
823
824 $script_loader .= '</script>' . PHP_EOL;
825
826 /**
827 * Delayed JS script loader content
828 *
829 * @hook powered_cache_delayed_js_script_loader
830 *
831 * @param {string} $script_loader Delayed JS script loader content
832 * @param {string} $html HTML buffer
833 * @param {int} $delay_timeout Delay time in milliseconds
834 *
835 * @return {string} New value.
836 * @since 3.4
837 */
838 $script_loader = apply_filters( 'powered_cache_delayed_js_script_loader', $script_loader, $html, $delay_timeout );
839
840 $body_pos = strpos( $html, '</body>' );
841
842 if ( false !== $body_pos ) {
843 $html = substr_replace( $html, $script_loader, $body_pos, 0 );
844 } else {
845 $html_pos = strpos( $html, '</html>' );
846 if ( false !== $html_pos ) {
847 $html = substr_replace( $html, $script_loader, $html_pos, 0 );
848 }
849 }
850
851 return $html;
852 }
853
854 /**
855 * Defer jQuery depended inline scripts
856 *
857 * @param string $html Output buffer
858 *
859 * @return array|mixed|string|string[]|null
860 * @since 3.2
861 */
862 public function maybe_defer_inline_scripts( $html ) {
863 if ( ! $this->settings['js_defer'] ) {
864 return $html;
865 }
866
867 $html = preg_replace_callback(
868 '/(<script[^>]*>)(.*?)<\/script>/s',
869 function ( $matches ) {
870 $script_open_tag = $matches[1];
871 $script_content = $matches[2];
872
873 if ( ! $this->should_defer_script( $script_content, $script_open_tag ) ) {
874 return $matches[0]; // Return the script unchanged
875 }
876
877 /**
878 * Filters whether disable or not disable inline defer
879 *
880 * @hook powered_cache_disable_js_defer_inline
881 *
882 * @param {boolean} true to disable defer
883 *
884 * @return {boolean} New value.
885 * @since 3.2
886 */
887 if ( apply_filters( 'powered_cache_disable_js_defer_inline', false, $script_content, $script_open_tag ) ) {
888 return $matches[0]; // Return the script unchanged
889 }
890
891 return $script_open_tag . 'window.addEventListener("DOMContentLoaded", function() {(function($) {' . $script_content . '})(jQuery);});</script>';
892 },
893 $html
894 );
895
896 return $html;
897 }
898
899 /**
900 * Determine whether defer or not defer inline script
901 *
902 * @param string $script_content Script content
903 * @param string $script_attributes Script attributes
904 *
905 * @return bool
906 * @since 3.2
907 */
908 private function should_defer_script( $script_content, $script_attributes ) {
909 // Ignore scripts with a 'src' attribute (external scripts)
910 if ( false !== strpos( $script_attributes, 'src=' ) ) {
911 return false;
912 }
913
914 // ignore ld+json
915 if ( false !== strpos( $script_attributes, 'type="application/ld+json"' ) ) {
916 return false;
917 }
918
919 // Check if the script contains 'DOMContentLoaded' or 'document.write'
920 if ( false !== strpos( $script_content, 'DOMContentLoaded' ) || false !== strpos( $script_content, 'document.write' ) ) {
921 return false;
922 }
923
924 // Check if the script does NOT contain jQuery-related functions
925 if ( false === strpos( $script_content, 'jQuery(' ) && false === strpos( $script_content, '$.(' ) && false === strpos( $script_content, '$(' ) ) {
926 return false;
927 }
928
929 return true;
930 }
931
932
933 }
934