PluginProbe
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization / trunk
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization vtrunk
4.2.14 4.2.13 4.2.12 4.2.11 4.2.10 4.2.9 4.2.8 4.2.7 4.2.6 4.2.5 2.5.5 2.5.6 2.5.7 3.0.0 3.0.1 3.1.0 3.1.1 3.1.2 3.1.3 3.10.0 3.11.0 3.11.1 3.11.2 3.11.3 3.12.0 All 135 releases
← All changes | inc/manager.php +433 -43 3.11.2 → trunk View file →
@@ -1,6 +1,10 @@
1 1 <?php
2 2
3 +use OptimoleWP\BgOptimizer\Lazyload;
4 +use OptimoleWP\PageProfiler\Profile;
5 +use OptimoleWP\Preload\Links;
6 +
3 7 /**
4 8 * Class Optml_Manager. Adds hooks for processing tags and urls.
5 9 *
6 10 * @package \Optml\Inc
@@ -44,15 +48,15 @@
44 48 */
45 49 public $lazyload_replacer;
46 50
47 51 /**
48 - * Holds the hero preloader class.
52 + * Holds the page profiler class.
49 53 *
50 54 * @access public
51 - * @since 3.9.0
52 - * @var Optml_Hero_Preloader Preloader instance.
55 + * @since 1.0.0
56 + * @var Profile Page profiler instance.
53 57 */
54 - public $hero_preloader;
58 + public $page_profiler;
55 59
56 60 /**
57 61 * Holds plugin settings.
58 62 *
@@ -64,8 +68,10 @@
64 68 *
65 69 * @var array Integrations classes.
66 70 */
67 71 private $possible_compatibilities = [
72 + 'kadence_blocks',
73 + 'essential_blocks',
68 74 'shortcode_ultimate',
69 75 'foogallery',
70 76 'envira',
71 77 'beaver_builder',
@@ -94,8 +100,24 @@
94 100 'elementor_builder_late',
95 101 'wpml',
96 102 'otter_blocks',
97 103 'spectra',
104 + 'wpsp',
105 + 'jetengine',
106 + 'jetpack',
107 + 'jetpack_photon_compatibility',
108 + 'wp_rocket',
109 + 'wp_super_cache',
110 + 'breeze',
111 + 'litespeed_cache',
112 + 'autoptimize_cache',
113 + 'endurance_cache',
114 + 'rocketnet',
115 + 'speedycache',
116 + 'hummingbird',
117 + 'aruba_hsc',
118 + 'spc',
119 + 'groovy_menu',
98 120 ];
99 121 /**
100 122 * The current state of the buffer.
101 123 *
@@ -101,8 +123,25 @@
101 123 *
102 124 * @var boolean Buffer state.
103 125 */
104 126 private static $ob_started = false;
127 + /**
128 + * The output-buffer nesting level of our capture buffer.
129 + *
130 + * Used to make sure we only ever capture or close our own buffer and not
131 + * one started by a third party.
132 + *
133 + * @var int Buffer nesting level, 0 when no capture buffer is armed.
134 + */
135 + private static $ob_level = 0;
136 + /**
137 + * Whether the captured buffer was already processed at shutdown.
138 + *
139 + * When true, the fallback output handler passes content through untouched.
140 + *
141 + * @var boolean Processed state.
142 + */
143 + private static $ob_processed = false;
105 144
106 145 /**
107 146 * Class instance method.
108 147 *
@@ -117,10 +156,9 @@
117 156 self::$instance = new self();
118 157 self::$instance->url_replacer = Optml_Url_Replacer::instance();
119 158 self::$instance->tag_replacer = Optml_Tag_Replacer::instance();
120 159 self::$instance->lazyload_replacer = Optml_Lazyload_Replacer::instance();
121 - self::$instance->hero_preloader = Optml_Hero_Preloader::instance();
122 -
160 + self::$instance->page_profiler = new Profile();
123 161 add_action( 'after_setup_theme', [ self::$instance, 'init' ] );
124 162 add_action( 'wp_footer', [ self::$instance, 'banner' ] );
125 163 }
126 164
@@ -138,9 +176,9 @@
138 176 add_image_size( $key, $value['width'], $value['height'], true );
139 177 }
140 178 foreach ( $this->possible_compatibilities as $compatibility_class ) {
141 179 $compatibility_class = 'Optml_' . $compatibility_class;
142 - $compatibility = new $compatibility_class;
180 + $compatibility = new $compatibility_class();
143 181
144 182 /**
145 183 * Check if we should load compatibility.
146 184 *
@@ -176,8 +214,11 @@
176 214 if ( ! $has_banner ) {
177 215 return;
178 216 }
179 217
218 + $position = $this->settings->get( 'badge_position' ) ?? 'right';
219 + $show_icon_only = $this->settings->get( 'show_badge_icon' ) === 'enabled';
220 +
180 221 $string = __( 'Optimized by Optimole', 'optimole-wp' );
181 222 $div_style = [
182 223 'display' => 'flex',
183 224 'position' => 'fixed',
@@ -182,9 +223,8 @@
182 223 'display' => 'flex',
183 224 'position' => 'fixed',
184 225 'align-items' => 'center',
185 226 'bottom' => '15px',
186 - 'right' => '15px',
187 227 'background-color' => '#fff',
188 228 'padding' => '8px 6px',
189 229 'font-size' => '12px',
190 230 'font-weight' => '600',
@@ -195,11 +235,13 @@
195 235 'text-decoration' => 'none',
196 236 'font-family' => 'Arial, Helvetica, sans-serif',
197 237 ];
198 238
239 + $div_style[ $position ] = '15px';
240 +
199 241 $logo = OPTML_URL . 'assets/img/logo.svg';
200 242
201 - $link = 'https://optimole.com/wordpress/?from=badgeOn';
243 + $link = tsdk_translate_link( 'https://optimole.com/wordpress/?from=badgeOn' );
202 244
203 245 $css = '';
204 246
205 247 foreach ( $div_style as $key => $value ) {
@@ -205,9 +247,9 @@
205 247 foreach ( $div_style as $key => $value ) {
206 248 $css .= $key . ':' . $value . ';';
207 249 }
208 250
209 - $output = '<a style="' . esc_attr( $css ) . '" href="' . esc_url( $link ) . '" rel="nofollow" target="_blank">';
251 + $output = '<a style="' . esc_attr( $css ) . '" href="' . esc_url( $link ) . '" aria-label="' . esc_html( $string ) . '" rel="nofollow" target="_blank">';
210 252 $output .= '
211 253 <svg style="margin-right:6px" width="20" height="20" viewBox="0 0 251 250" fill="none" xmlns="http://www.w3.org/2000/svg">
212 254 <path d="M217.696 99.0926C240.492 152.857 211.284 215.314 153.249 239.999C95.2141 264.683 30.4436 242.239 7.64869 188.474C-15.1462 134.71 14.8767 75.1972 72.9116 50.4991C130.947 25.8007 194.902 45.3142 217.696 99.0926Z" fill="#EDF0FF"/>
213 255 <path d="M217.696 99.0926C240.492 152.857 211.284 215.314 153.249 239.999C95.2141 264.683 30.4436 242.239 7.64869 188.474C-15.1462 134.71 14.8767 75.1972 72.9116 50.4991C130.947 25.8007 194.902 45.3142 217.696 99.0926Z" fill="#D7EEFC"/>
@@ -238,9 +280,11 @@
238 280 <path d="M100.727 110.97C100.642 111.097 100.487 111.167 100.333 111.238C97.3938 112.506 94.4407 113.802 91.305 114.62C90.7002 114.803 90.0958 114.929 89.4771 115.056C88.4642 115.267 87.4236 115.408 86.3691 115.479C86.4676 113.534 86.4814 111.604 86.4394 109.66C86.524 109.674 86.6224 109.688 86.7064 109.702C88.2535 109.928 89.8005 110.012 91.3471 110.026C94.047 110.069 96.7613 109.913 99.4612 109.956C99.7846 109.956 100.136 109.97 100.417 110.139C100.712 110.308 100.895 110.702 100.727 110.97Z" fill="#1D445C"/>
239 281 <path d="M182.837 193.871C181.5 195.322 180.136 196.731 178.702 198.07C177.071 197.351 175.524 196.519 174.005 195.618C172.74 194.871 171.502 194.082 170.251 193.293C170.124 193.209 169.97 193.11 169.913 192.969C169.787 192.687 170.026 192.349 170.349 192.222C170.659 192.11 170.996 192.138 171.348 192.194C173.12 192.462 174.905 192.786 176.663 193.096C178.702 193.448 180.756 193.772 182.837 193.871Z" fill="#1D445C"/>
240 282 <path d="M175.074 201.282C173.147 202.888 171.151 204.396 169.055 205.818C167.902 205.283 166.777 204.649 165.737 203.945C165.287 203.649 164.837 203.325 164.415 203.015C161.743 201 159.409 198.619 157.117 196.252C157.004 196.139 156.892 196.026 156.85 195.858C156.807 195.561 157.13 195.266 157.454 195.224C157.792 195.195 158.129 195.308 158.439 195.435C161.996 196.816 165.413 198.619 169.112 199.718C169.534 199.845 169.955 199.957 170.406 200.07C171.966 200.465 173.598 200.352 174.92 201.169C174.948 201.197 175.018 201.226 175.074 201.282Z" fill="#1D445C"/>
241 283 </svg>';
242 - $output .= '<span>' . esc_html( $string ) . '</span>';
284 + if ( ! $show_icon_only ) {
285 + $output .= '<span>' . esc_html( $string ) . '</span>';
286 + }
243 287 $output .= '</a>';
244 288
245 289 echo $output;
246 290 }
@@ -265,13 +309,14 @@
265 309
266 310 if ( ( is_admin() && ! self::is_ajax_request() ) || ! $this->settings->is_connected() || ! $this->settings->is_enabled() ) {
267 311 return false; // @codeCoverageIgnore
268 312 }
269 - if ( array_key_exists( 'preview', $_GET ) && ! empty( $_GET['preview'] ) ) {
313 +
314 + if ( array_key_exists( 'preview', $_GET ) && ! empty( $_GET['preview'] ) && ! $this->settings->is_offload_enabled() ) {
270 315 return false; // @codeCoverageIgnore
271 316 }
272 317
273 - if ( array_key_exists( 'optml_off', $_GET ) && 'true' == $_GET['optml_off'] ) { // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison
318 + if ( array_key_exists( 'optml_off', $_GET ) && 'true' === $_GET['optml_off'] ) {
274 319 return false; // @codeCoverageIgnore
275 320 }
276 321 if ( array_key_exists( 'elementor-preview', $_GET ) && ! empty( $_GET['elementor-preview'] ) ) {
277 322 return false; // @codeCoverageIgnore
@@ -281,15 +326,15 @@
281 326 }
282 327 if ( array_key_exists( 'et_fb', $_GET ) && ! empty( $_GET['et_fb'] ) ) {
283 328 return false; // @codeCoverageIgnore
284 329 }
285 - if ( array_key_exists( 'tve', $_GET ) && $_GET['tve'] == 'true' ) { // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison
330 + if ( array_key_exists( 'tve', $_GET ) && $_GET['tve'] === 'true' ) {
286 331 return false; // @codeCoverageIgnore
287 332 }
288 - if ( array_key_exists( 'trp-edit-translation', $_GET ) && ( $_GET['trp-edit-translation'] == 'true' || $_GET['trp-edit-translation'] == 'preview' ) ) { // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison
333 + if ( array_key_exists( 'trp-edit-translation', $_GET ) && ( $_GET['trp-edit-translation'] === 'true' || $_GET['trp-edit-translation'] === 'preview' ) ) {
289 334 return false; // @codeCoverageIgnore
290 335 }
291 - if ( array_key_exists( 'context', $_GET ) && $_GET['context'] == 'edit' ) { // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison
336 + if ( array_key_exists( 'context', $_GET ) && $_GET['context'] === 'edit' ) {
292 337 return false; // @codeCoverageIgnore
293 338 }
294 339 // avada
295 340 if ( array_key_exists( 'fb-edit', $_GET ) && ! empty( $_GET['fb-edit'] ) ) {
@@ -299,9 +344,9 @@
299 344 && array_key_exists( 'builder_id', $_GET ) && ! empty( $_GET['builder_id'] ) ) {
300 345 return false; // @codeCoverageIgnore
301 346 }
302 347 // Motion.page iFrame & builder
303 - if ( ( array_key_exists( 'motionpage_iframe', $_GET ) && $_GET['motionpage_iframe'] == 'true' ) || ( array_key_exists( 'page', $_GET ) && $_GET['page'] == 'motionpage' ) ) { // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison
348 + if ( ( array_key_exists( 'motionpage_iframe', $_GET ) && $_GET['motionpage_iframe'] === 'true' ) || ( array_key_exists( 'page', $_GET ) && $_GET['page'] === 'motionpage' ) ) { // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison
304 349 return false; // @codeCoverageIgnore
305 350 }
306 351 /**
307 352 * Disable replacement on POST request and when user is logged in, but allows for sample image call widget in dashboard
@@ -307,9 +352,9 @@
307 352 * Disable replacement on POST request and when user is logged in, but allows for sample image call widget in dashboard
308 353 */
309 354 if (
310 355 isset( $_SERVER['REQUEST_METHOD'] ) &&
311 - $_SERVER['REQUEST_METHOD'] == 'POST' && // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison
356 + $_SERVER['REQUEST_METHOD'] === 'POST' &&
312 357 is_user_logged_in()
313 358 && ( ! isset( $_GET['quality'] ) || ! current_user_can( 'manage_options' ) )
314 359 ) {
315 360 return false; // @codeCoverageIgnore
@@ -348,9 +393,9 @@
348 393 }
349 394 if ( ! wp_doing_ajax() ) {
350 395 return false;
351 396 }
352 - if ( isset( $_REQUEST['action'] ) && strpos( $_REQUEST['action'], 'wpmdb' ) !== false ) {
397 + if ( isset( $_REQUEST['action'] ) && is_string( $_REQUEST['action'] ) && strpos( $_REQUEST['action'], 'wpmdb' ) !== false ) {
353 398 return false;
354 399 }
355 400
356 401 return true;
@@ -380,9 +425,10 @@
380 425 defined( 'OPTML_SITE_MIRROR' ) ? PHP_INT_MAX : PHP_INT_MIN
381 426 );
382 427 add_action( 'template_redirect', [ $this, 'register_after_setup' ] );
383 428 add_action( 'rest_api_init', [ $this, 'process_template_redirect_content' ], PHP_INT_MIN );
384 -
429 + add_action( 'shutdown', [ $this, 'close_buffer' ], PHP_INT_MIN );
430 + add_action( 'shutdown', [ $this, 'close_final_buffer' ], PHP_INT_MAX );
385 431 foreach ( self::$loaded_compatibilities as $registered_compatibility ) {
386 432 $registered_compatibility->register();
387 433 }
388 434 }
@@ -394,22 +440,108 @@
394 440 do_action( 'optml_after_setup' );
395 441 }
396 442
397 443 /**
444 + * Check if we should load the profiler.
445 + *
446 + * @param bool $default_value Default value.
447 + *
448 + * @return bool Should load the profiler.
449 + */
450 + public static function should_load_profiler( $default_value = false ) {
451 + return ! $default_value && apply_filters( 'optml_page_profiler_disable', false ) === false;
452 + }
453 +
454 + /**
455 + * Decide if the temporary Cache-Control header can be sent while page profiling is pending.
456 + *
457 + * The header must never be sent on non-cacheable pages: it would replace a
458 + * Cache-Control header already set by WordPress or another plugin (PHP's
459 + * header() replaces same-name headers by default), e.g. WooCommerce's
460 + * no-cache header on cart and checkout, letting proxies cache user-specific
461 + * pages. We back off when DONOTCACHEPAGE is set or when any Cache-Control
462 + * header exists already, and let developers override the decision.
463 + *
464 + * @param array<int, string>|null $sent_headers Headers already set for the response; defaults to headers_list().
465 + * @param bool|null $do_not_cache Whether the page is flagged as non-cacheable; defaults to the DONOTCACHEPAGE constant.
466 + *
467 + * @return bool Whether the header can be sent.
468 + */
469 + public function should_send_temporary_cache_header( $sent_headers = null, $do_not_cache = null ) {
470 + if ( null === $do_not_cache ) {
471 + $do_not_cache = defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE;
472 + }
473 + $send = ! $do_not_cache;
474 +
475 + if ( $send ) {
476 + if ( null === $sent_headers ) {
477 + $sent_headers = headers_list();
478 + }
479 + foreach ( $sent_headers as $header ) {
480 + if ( stripos( $header, 'cache-control:' ) === 0 ) {
481 + $send = false;
482 + break;
483 + }
484 + }
485 + }
486 +
487 + /**
488 + * Filters whether the temporary `Cache-Control: max-age=300` header is sent
489 + * while page profiling is pending for the current page.
490 + *
491 + * @param bool $send Computed decision: false when DONOTCACHEPAGE is set or a Cache-Control header exists already.
492 + */
493 + return apply_filters( 'optml_send_temporary_cache_header', $send ) === true;
494 + }
495 + /**
398 496 * Filter raw HTML content for urls.
399 497 *
400 498 * @param string $html HTML to filter.
499 + * @param bool $partial If this is a partial content replacement and not a full page. It matters when we are are doing full page optimization like viewport lazyload.
401 500 *
402 501 * @return mixed Filtered content.
403 502 */
404 - public function replace_content( $html ) {
503 + public function replace_content( $html, $partial = false ) {
405 504
406 505 if ( defined( 'REST_REQUEST' ) && REST_REQUEST && is_user_logged_in() && ( apply_filters( 'optml_force_replacement', false ) !== true ) ) {
407 506 return $html;
408 507 }
508 + if ( self::should_load_profiler( $partial ) ) {
509 + $profile_id = Profile::generate_id( $html );
510 + $should_show_comment = false;
511 + // We disable the optimizer for logged in users.
512 + if ( ! is_user_logged_in() || ! apply_filters( 'optml_force_page_profiler', false ) !== true ) {
513 + $js_optimizer = Optml_Admin::get_optimizer_script( false );
409 514
410 - $html = $this->add_html_class( $html );
515 + if ( ! $this->page_profiler->exists_all( $profile_id ) ) {
516 + $missing = $this->page_profiler->missing_devices( $profile_id );
517 + $time = time();
518 + $url = esc_url( esc_js( $this->get_current_url() ) );
519 + $hmac = wp_hash( $profile_id . $time . $url, 'nonce' );
520 + $js_optimizer = str_replace(
521 + [ Profile::PLACEHOLDER, Profile::PLACEHOLDER_MISSING, Profile::PLACEHOLDER_TIME, Profile::PLACEHOLDER_HMAC, Profile::PLACEHOLDER_URL ],
522 + [ $profile_id, implode( ',', $missing ), strval( $time ), $hmac, $url ],
523 + $js_optimizer
524 + );
525 + $html = str_replace( Optml_Admin::get_optimizer_script( true ), $js_optimizer, $html );
526 + if ( ! headers_sent() && $this->should_send_temporary_cache_header() ) {
527 + header( 'Cache-Control: max-age=300' ); // Attempt to cache the page just for 5 mins until the optimizer is done. Once the optimizer is done, the page will load optimized.
528 + }
529 + } else {
530 + $should_show_comment = isset( $_GET['optml_debug'] ) && $_GET['optml_debug'] === 'true';
531 + }
532 + }
411 533
534 + Profile::set_current_profile_id( $profile_id );
535 + $this->page_profiler->set_current_profile_data();
536 + if ( $should_show_comment ) {
537 + $html = str_replace( '</html>', '</html>' . $this->page_profiler->get_current_profile_html_comment(), $html );
538 + }
539 + }
540 + if ( ! $partial ) {
541 + $html = $this->add_html_class( $html );
542 + }
543 +
412 544 $html = $this->process_images_from_content( $html );
413 545
414 546 if ( $this->settings->get( 'video_lazyload' ) === 'enabled' ) {
415 547 $html = apply_filters( 'optml_video_replace', $html );
@@ -420,18 +552,60 @@
420 552 $html = preg_replace( '/<head>(.*)<\/head>/ism', '<head> $1' . Optml_Lazyload_Replacer::IFRAME_PLACEHOLDER_STYLE . '</head>', $html );
421 553 }
422 554 }
423 555 }
556 +
557 + if ( $this->settings->is_lazyload_type_viewport() && ! $partial ) {
558 + $personalized_bg_css = Lazyload::get_current_personalized_css();
559 + if ( OPTML_DEBUG ) {
560 + do_action( 'optml_log', 'viewport_bgselectorsdata: ' . print_r( $personalized_bg_css, true ) );
561 + }
562 +
563 + if ( ! empty( $personalized_bg_css ) && ( $start_pos = strpos( $html, Lazyload::MARKER ) ) !== false ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.Found
564 + // We replace the general bg css with the personalized one.
565 + if ( ( $end_pos = strpos( $html, Lazyload::MARKER, $start_pos + strlen( Lazyload::MARKER ) ) ) !== false ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.Found
566 + $html = substr_replace(
567 + $html,
568 + $personalized_bg_css,
569 + $start_pos,
570 + $end_pos + strlen( Lazyload::MARKER ) - $start_pos
571 + );
572 + }
573 + }
574 + }
575 + if ( ! $partial ) {
576 + // WE need this last since during bg personalized CSS we collect preload urls
577 + if ( Links::get_links_count() > 0 ) {
578 + if ( OPTML_DEBUG ) {
579 + do_action( 'optml_log', 'preload_links: ' . print_r( Links::get_links(), true ) );
580 + }
581 + $html = str_replace( Optml_Admin::get_preload_links_placeholder(), Links::get_links_html(), $html );
582 + } else {
583 + $html = str_replace( Optml_Admin::get_preload_links_placeholder(), '', $html );
584 + }
585 + }
586 +
424 587 $html = apply_filters( 'optml_url_pre_process', $html );
425 588
426 589 $html = $this->process_urls_from_content( $html );
427 590
428 591 $html = apply_filters( 'optml_url_post_process', $html );
429 -
592 + if ( self::should_load_profiler( $partial ) ) {
593 + Profile::reset_current_profile();
594 + }
430 595 return $html;
431 596 }
432 597
433 598 /**
599 + * Get the current url.
600 + *
601 + * @return string The current url.
602 + */
603 + private function get_current_url() {
604 + global $wp;
605 + return home_url( add_query_arg( [], $wp->request ) );
606 + }
607 + /**
434 608 * Adds a filter that allows adding classes to the HTML tag.
435 609 *
436 610 * @param string $content The HTML content.
437 611 *
@@ -452,9 +626,9 @@
452 626
453 627 $add_classes = implode( ' ', $additional_html_classes );
454 628 foreach ( $matches as $match ) {
455 629 if ( strpos( $match[0], 'class' ) !== false ) {
456 - $new_tag = str_replace( [ 'class="', "class='" ], [ 'class="' . $add_classes, "class='" . $add_classes ], $match[0] );
630 + $new_tag = str_replace( [ 'class="', "class='" ], [ 'class="' . $add_classes, "class='" . $add_classes ], $match[0] );
457 631 } else {
458 632 $new_tag = str_replace( 'html ', 'html class="' . $add_classes . '" ', $match[0] );
459 633 }
460 634
@@ -479,9 +653,8 @@
479 653 $images = self::parse_images_from_html( $content );
480 654 if ( empty( $images ) ) {
481 655 return $content;
482 656 }
483 -
484 657 return apply_filters( 'optml_content_images_tags', $content, $images );
485 658 }
486 659
487 660 /**
@@ -521,20 +694,16 @@
521 694 */
522 695 public static function parse_images_from_html( $content ) {
523 696 $images = [];
524 697
525 - $header_start = null;
526 - $header_end = null;
527 -
528 - if ( preg_match( '/<header.*<\/header>/ismU', $content, $matches, PREG_OFFSET_CAPTURE ) === 1 ) {
529 - $header_start = $matches[0][1];
530 - $header_end = $header_start + strlen( $matches[0][0] );
698 + if ( OPTML_DEBUG ) {
699 + do_action( 'optml_log', 'Content to parse images from: ' . $content );
531 700 }
532 701 $regex = '/(?:<a[^>]+?href=["|\'](?P<link_url>[^\s]+?)["|\'][^>]*?>\s*)?(?P<img_tag>(?:<noscript\s*>\s*)?<img[^>]*?\s?(?:' . implode( '|', array_merge( [ 'src' ], Optml_Tag_Replacer::possible_src_attributes() ) ) . ')=["\'\\\\]*?(?P<img_url>[' . Optml_Config::$chars . ']{10,}).*?>(?:\s*<\/noscript\s*>)?){1}(?:\s*<\/a>)?/ismu';
533 702
534 703 if ( preg_match_all( $regex, $content, $images, PREG_OFFSET_CAPTURE ) ) {
535 704 if ( OPTML_DEBUG ) {
536 - do_action( 'optml_log', $images );
705 + do_action( 'optml_log', 'Image tags parsed: ' . print_r( $images, true ) );
537 706 }
538 707 foreach ( $images as $key => $unused ) {
539 708 // Simplify the output as much as possible, mostly for confirming test results.
540 709 if ( is_numeric( $key ) && $key > 0 ) {
@@ -550,9 +719,9 @@
550 719 }
551 720 $images[ $key ][ $url_key ] = $url_value[0];
552 721
553 722 if ( $key === 0 ) {
554 - $images['in_header'][ $url_key ] = $header_start !== null ? ( $url_value[1] > $header_start && $url_value[1] < $header_end ) : false;
723 + $images['in_no_script'][ $url_key ] = false;
555 724
556 725 // Check if we are in the noscript context.
557 726 if ( $is_no_script === false ) {
558 727 $is_no_script = strpos( $images[0][ $url_key ], '<noscript' ) !== false ? true : false;
@@ -557,9 +726,9 @@
557 726 if ( $is_no_script === false ) {
558 727 $is_no_script = strpos( $images[0][ $url_key ], '<noscript' ) !== false ? true : false;
559 728 }
560 729 if ( $is_no_script ) {
561 - $images['in_header'][ $url_key ] = true;
730 + $images['in_no_script'][ $url_key ] = true;
562 731 $is_no_script = strpos( $images[0][ $url_key ], '</noscript' ) !== false ? false : true;
563 732 }
564 733 }
565 734 }
@@ -580,13 +749,11 @@
580 749 */
581 750 public function process_urls_from_content( $html ) {
582 751 $extracted_urls = $this->extract_urls_from_content( $html );
583 752 if ( OPTML_DEBUG ) {
584 - do_action( 'optml_log', 'matched urls' );
585 - do_action( 'optml_log', $extracted_urls );
753 + do_action( 'optml_log', 'Extracted image urls from content: ' . print_r( $extracted_urls, true ) );
586 754 }
587 755 return $this->do_url_replacement( $html, $extracted_urls );
588 -
589 756 }
590 757
591 758 /**
592 759 * Method to extract assets from content.
@@ -662,10 +829,10 @@
662 829 $is_slashed = strpos( $url, '\/' ) !== false;
663 830 $is_relative = strpos(
664 831 $url,
665 832 $is_slashed ?
666 - addcslashes( $upload_resource['content_path'], '/' ) :
667 - $upload_resource['content_path']
833 + addcslashes( $upload_resource['content_path'], '/' ) :
834 + $upload_resource['content_path']
668 835 ) === 0;
669 836 if ( $is_relative ) {
670 837 $url = $upload_resource['content_host'] . $url;
671 838 }
@@ -674,16 +841,64 @@
674 841 },
675 842 $urls
676 843 );
677 844
845 + /*
846 + * Replace all URLs in a single pass per chunk instead of one full-page
847 + * preg_replace() per URL, which scanned and rebuilt the whole page for
848 + * every replaced URL. Chunks are bounded by pattern size, not only
849 + * count, so the compiled regex stays within PCRE's ~64KB limit even
850 + * for very long URLs (e.g. signed CDN URLs with kilobyte-sized query
851 + * strings). Each chunk is applied as soon as it fills, so only one
852 + * chunk's bookkeeping is in memory at a time.
853 + */
854 + $chunk = [];
855 + $quoted = [];
856 + $quoted_size = 0;
678 857 foreach ( $urls as $origin => $replace ) {
679 - $html = preg_replace( '/(?<![\/|:|\\w])' . preg_quote( $origin, '/' ) . '/m', $replace, $html );
858 + $quoted_origin = preg_quote( $origin, '/' );
859 + if ( ! empty( $chunk ) && ( count( $chunk ) >= 200 || $quoted_size + strlen( $quoted_origin ) > 24000 ) ) {
860 + $html = $this->replace_urls_chunk( $html, $chunk, $quoted );
861 + $chunk = [];
862 + $quoted = [];
863 + $quoted_size = 0;
864 + }
865 + $chunk[ $origin ] = $replace;
866 + $quoted[] = $quoted_origin;
867 + $quoted_size += strlen( $quoted_origin ) + 1;
680 868 }
869 + if ( ! empty( $chunk ) ) {
870 + $html = $this->replace_urls_chunk( $html, $chunk, $quoted );
871 + }
681 872
682 873 return $html;
683 874 }
684 875
685 876 /**
877 + * Replace one chunk of URLs in the content with a single combined pattern.
878 + *
879 + * @param string $html Content to process.
880 + * @param array<string, string> $chunk Map of origin => replacement URLs.
881 + * @param string[] $quoted The preg_quote()d origins, in the same order.
882 + *
883 + * @return string Processed content, unchanged when the pattern fails.
884 + */
885 + private function replace_urls_chunk( $html, $chunk, $quoted ) {
886 + $result = preg_replace_callback(
887 + '/(?<![\/|:|\\w])(?:' . implode( '|', $quoted ) . ')/m',
888 + function ( $matches ) use ( $chunk ) {
889 + return $chunk[ $matches[0] ];
890 + },
891 + $html
892 + );
893 + if ( $result === null ) {
894 + do_action( 'optml_log', 'URL replacement failed for a chunk of ' . count( $chunk ) . ' URLs, PCRE error ' . preg_last_error() );
895 + return $html;
896 + }
897 + return $result;
898 + }
899 +
900 + /**
686 901 * Init html replacer handler.
687 902 */
688 903 public function process_template_redirect_content() {
689 904 // Early exit if function was already called, we don't want duplicate ob_start
@@ -693,13 +908,188 @@
693 908 self::$ob_started = true;
694 909 // We no longer need this if the handler was started.
695 910 remove_filter( 'the_content', [ $this, 'process_images_from_content' ], PHP_INT_MAX );
696 911
697 - ob_start(
698 - [ &$this, 'replace_content' ]
699 - );
912 + $this->start_capture_buffer();
700 913 }
701 914
915 + /**
916 + * Start the output buffer that holds the page HTML.
917 + *
918 + * By default the page is processed by the attached handler when the buffer
919 + * is flushed, the way it worked up to 4.2.11. We never flush other buffers
920 + * and open no buffer at shutdown, so code that opens a buffer early and reads
921 + * it back with ob_get_clean() at shutdown (FacetWP, Groovy Menu) keeps working.
922 + *
923 + * When the optml_capture_at_shutdown filter returns true, close_buffer()
924 + * captures and processes the buffer at shutdown instead, outside of PHP's
925 + * display-handler context. Callbacks hooked into our filters can then use
926 + * output buffering themselves, and fatal errors raised during processing
927 + * keep their real message instead of being masked by "Cannot use output
928 + * buffering in output buffering display handlers".
929 + *
930 + * A named method is used instead of a closure so the buffer can be
931 + * identified as ours via ob_get_status()['name'].
932 + *
933 + * @return void
934 + */
935 + private function start_capture_buffer() {
936 + self::$ob_processed = false;
937 + ob_start( [ $this, 'handle_buffer_fallback' ] );
938 + self::$ob_level = ob_get_level();
939 + }
940 +
941 + /**
942 + * The handler name PHP reports for our capture buffer in ob_get_status().
943 + */
944 + const OB_HANDLER_NAME = 'Optml_Manager::handle_buffer_fallback';
945 +
946 + /**
947 + * Whether the page is captured and processed at shutdown, outside of PHP's display-handler context.
948 + *
949 + * @return bool
950 + */
951 + private function captures_at_shutdown() {
952 + /**
953 + * Filters whether the page is captured and processed at shutdown, outside
954 + * of PHP's display-handler context, instead of inside the output-buffer
955 + * handler.
956 + *
957 + * Off by default: the shutdown capture flushes the buffers
958 + * stacked above ours and opens a new buffer afterwards, which breaks code
959 + * that reads its own buffer back at shutdown. Return true to opt in.
960 + *
961 + * @param bool $capture_at_shutdown Whether to process the buffer at shutdown.
962 + */
963 + return apply_filters( 'optml_capture_at_shutdown', false ) === true;
964 + }
965 +
966 + /**
967 + * Output-buffer handler attached to our buffer.
968 + *
969 + * By default this is where the page is processed. An exception thrown by the
970 + * replacement never breaks the page: the content is returned untouched.
971 + *
972 + * With the optml_capture_at_shutdown opt-in the handler runs only when the
973 + * buffer is flushed outside of close_buffer(), and the content is passed
974 + * through unprocessed, because running the replacement filter graph inside a
975 + * display handler is what that mode exists to avoid.
976 + *
977 + * @param string $content The buffered content.
978 + * @param int $phase PHP's output-handler phase bitmask (unused; keeps replace_content()'s $partial parameter shielded from it).
979 + *
980 + * @return string The content to output.
981 + */
982 + public function handle_buffer_fallback( $content, $phase = 0 ) {
983 + if ( self::$ob_processed || $content === '' ) {
984 + return $content;
985 + }
986 + if ( ! $this->captures_at_shutdown() ) {
987 + try {
988 + return $this->replace_content( $content, self::is_ajax_request() );
989 + } catch ( Throwable $t ) {
990 + // Never break the page from inside a display handler.
991 + do_action( 'optml_log', 'replace_content failed inside the output handler: ' . $t->getMessage() );
992 + return $content;
993 + }
994 + }
995 + do_action( 'optml_log', 'Optimole buffer was flushed outside close_buffer(); content passed through unprocessed.' );
996 +
997 + return $content;
998 + }
999 +
1000 + /**
1001 + * Close the buffer and flush the content.
1002 + */
1003 + public function close_buffer() {
1004 + if ( ! self::$ob_started ) {
1005 + return;
1006 + }
1007 +
1008 + if ( ! $this->captures_at_shutdown() ) {
1009 + if ( ob_get_length() ) {
1010 + ob_end_flush();
1011 + }
1012 + return;
1013 + }
1014 +
1015 + /*
1016 + * Flush the buffers other plugins stacked on top of ours so their
1017 + * handlers still transform the page before we process it, preserving
1018 + * the same order as a full top-down flush at request shutdown.
1019 + */
1020 + while ( ob_get_level() > self::$ob_level ) {
1021 + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a non-flushable buffer must not raise a notice; we stop on failure.
1022 + if ( ! @ob_end_flush() ) {
1023 + break;
1024 + }
1025 + }
1026 +
1027 + if ( ! $this->capture_and_process_buffer() ) {
1028 + do_action( 'optml_log', 'Optimole buffer was closed earlier by third-party code.' );
1029 + return;
1030 + }
1031 +
1032 + /*
1033 + * Re-arm the capture so output echoed by later shutdown callbacks is
1034 + * still processed and unguarded third-party flush calls find a buffer
1035 + * to close instead of raising a notice.
1036 + */
1037 + $this->start_capture_buffer();
1038 + }
1039 +
1040 + /**
1041 + * Close the re-armed buffer at the very end of shutdown.
1042 + *
1043 + * @return void
1044 + */
1045 + public function close_final_buffer() {
1046 + if ( ! self::$ob_started || ! $this->captures_at_shutdown() ) {
1047 + return;
1048 + }
1049 + $this->capture_and_process_buffer( false );
1050 + }
1051 +
1052 + /**
1053 + * Capture our buffer, process it outside the display-handler context and echo the result.
1054 + *
1055 + * Ownership is verified by both nesting level and handler identity, so a
1056 + * buffer another plugin opened at the same level after ours was closed is
1057 + * never captured or closed by us.
1058 + *
1059 + * @param bool $is_page Whether this is the page capture (true) or the late shutdown output (false).
1060 + *
1061 + * @return bool Whether our buffer was found and consumed.
1062 + */
1063 + private function capture_and_process_buffer( $is_page = true ) {
1064 + if ( self::$ob_level === 0 || ob_get_level() !== self::$ob_level ) {
1065 + return false;
1066 + }
1067 + $status = ob_get_status();
1068 + if ( ( $status['name'] ?? '' ) !== self::OB_HANDLER_NAME ) {
1069 + return false;
1070 + }
1071 + $html = ob_get_contents();
1072 + // Set before ob_end_clean() so our handler no-ops during buffer cleanup.
1073 + self::$ob_processed = true;
1074 + ob_end_clean();
1075 + if ( $html !== false && $html !== '' ) {
1076 + if ( $is_page ) {
1077 + /**
1078 + * Filters the captured page HTML before Optimole processes it.
1079 + *
1080 + * Runs once per request, on the buffer captured at shutdown, outside of
1081 + * PHP's display-handler context. Late output echoed by other shutdown
1082 + * callbacks is not passed through this filter.
1083 + *
1084 + * @param string $html The full page HTML.
1085 + */
1086 + $html = apply_filters( 'optml_captured_page_html', $html );
1087 + }
1088 + echo $this->replace_content( $html, self::is_ajax_request() ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- full page HTML, escaping would break the page.
1089 + }
1090 + return true;
1091 + }
702 1092 /**
703 1093 * Throw error on object clone
704 1094 *
705 1095 * The whole idea of the singleton design pattern is that there is a single