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 +442 -44 3.10.0 → 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 }
@@ -258,16 +302,21 @@
258 302 if ( apply_filters( 'optml_force_replacement', false ) === true ) {
259 303 return true;
260 304 }
261 305
262 - if ( ( is_admin() && ! self::is_ajax_request() ) || ! $this->settings->is_connected() || ! $this->settings->is_enabled() || is_customize_preview() ) {
306 + if ( is_customize_preview() && $this->settings->get( 'offload_media' ) !== 'enabled' ) {
307 + return false;
308 + }
309 +
310 + if ( ( is_admin() && ! self::is_ajax_request() ) || ! $this->settings->is_connected() || ! $this->settings->is_enabled() ) {
263 311 return false; // @codeCoverageIgnore
264 312 }
265 - 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() ) {
266 315 return false; // @codeCoverageIgnore
267 316 }
268 317
269 - 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'] ) {
270 319 return false; // @codeCoverageIgnore
271 320 }
272 321 if ( array_key_exists( 'elementor-preview', $_GET ) && ! empty( $_GET['elementor-preview'] ) ) {
273 322 return false; // @codeCoverageIgnore
@@ -277,15 +326,15 @@
277 326 }
278 327 if ( array_key_exists( 'et_fb', $_GET ) && ! empty( $_GET['et_fb'] ) ) {
279 328 return false; // @codeCoverageIgnore
280 329 }
281 - 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' ) {
282 331 return false; // @codeCoverageIgnore
283 332 }
284 - 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' ) ) {
285 334 return false; // @codeCoverageIgnore
286 335 }
287 - 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' ) {
288 337 return false; // @codeCoverageIgnore
289 338 }
290 339 // avada
291 340 if ( array_key_exists( 'fb-edit', $_GET ) && ! empty( $_GET['fb-edit'] ) ) {
@@ -294,14 +343,18 @@
294 343 if ( array_key_exists( 'builder', $_GET ) && ! empty( $_GET['builder'] )
295 344 && array_key_exists( 'builder_id', $_GET ) && ! empty( $_GET['builder_id'] ) ) {
296 345 return false; // @codeCoverageIgnore
297 346 }
347 + // Motion.page iFrame & builder
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
349 + return false; // @codeCoverageIgnore
350 + }
298 351 /**
299 352 * Disable replacement on POST request and when user is logged in, but allows for sample image call widget in dashboard
300 353 */
301 354 if (
302 355 isset( $_SERVER['REQUEST_METHOD'] ) &&
303 - $_SERVER['REQUEST_METHOD'] == 'POST' && // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison
356 + $_SERVER['REQUEST_METHOD'] === 'POST' &&
304 357 is_user_logged_in()
305 358 && ( ! isset( $_GET['quality'] ) || ! current_user_can( 'manage_options' ) )
306 359 ) {
307 360 return false; // @codeCoverageIgnore
@@ -340,9 +393,9 @@
340 393 }
341 394 if ( ! wp_doing_ajax() ) {
342 395 return false;
343 396 }
344 - if ( isset( $_REQUEST['action'] ) && strpos( $_REQUEST['action'], 'wpmdb' ) !== false ) {
397 + if ( isset( $_REQUEST['action'] ) && is_string( $_REQUEST['action'] ) && strpos( $_REQUEST['action'], 'wpmdb' ) !== false ) {
345 398 return false;
346 399 }
347 400
348 401 return true;
@@ -372,9 +425,10 @@
372 425 defined( 'OPTML_SITE_MIRROR' ) ? PHP_INT_MAX : PHP_INT_MIN
373 426 );
374 427 add_action( 'template_redirect', [ $this, 'register_after_setup' ] );
375 428 add_action( 'rest_api_init', [ $this, 'process_template_redirect_content' ], PHP_INT_MIN );
376 -
429 + add_action( 'shutdown', [ $this, 'close_buffer' ], PHP_INT_MIN );
430 + add_action( 'shutdown', [ $this, 'close_final_buffer' ], PHP_INT_MAX );
377 431 foreach ( self::$loaded_compatibilities as $registered_compatibility ) {
378 432 $registered_compatibility->register();
379 433 }
380 434 }
@@ -386,22 +440,108 @@
386 440 do_action( 'optml_after_setup' );
387 441 }
388 442
389 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 + /**
390 496 * Filter raw HTML content for urls.
391 497 *
392 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.
393 500 *
394 501 * @return mixed Filtered content.
395 502 */
396 - public function replace_content( $html ) {
503 + public function replace_content( $html, $partial = false ) {
397 504
398 505 if ( defined( 'REST_REQUEST' ) && REST_REQUEST && is_user_logged_in() && ( apply_filters( 'optml_force_replacement', false ) !== true ) ) {
399 506 return $html;
400 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 );
401 514
402 - $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 + }
403 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 +
404 544 $html = $this->process_images_from_content( $html );
405 545
406 546 if ( $this->settings->get( 'video_lazyload' ) === 'enabled' ) {
407 547 $html = apply_filters( 'optml_video_replace', $html );
@@ -412,18 +552,60 @@
412 552 $html = preg_replace( '/<head>(.*)<\/head>/ism', '<head> $1' . Optml_Lazyload_Replacer::IFRAME_PLACEHOLDER_STYLE . '</head>', $html );
413 553 }
414 554 }
415 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 +
416 587 $html = apply_filters( 'optml_url_pre_process', $html );
417 588
418 589 $html = $this->process_urls_from_content( $html );
419 590
420 591 $html = apply_filters( 'optml_url_post_process', $html );
421 -
592 + if ( self::should_load_profiler( $partial ) ) {
593 + Profile::reset_current_profile();
594 + }
422 595 return $html;
423 596 }
424 597
425 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 + /**
426 608 * Adds a filter that allows adding classes to the HTML tag.
427 609 *
428 610 * @param string $content The HTML content.
429 611 *
@@ -444,9 +626,9 @@
444 626
445 627 $add_classes = implode( ' ', $additional_html_classes );
446 628 foreach ( $matches as $match ) {
447 629 if ( strpos( $match[0], 'class' ) !== false ) {
448 - $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] );
449 631 } else {
450 632 $new_tag = str_replace( 'html ', 'html class="' . $add_classes . '" ', $match[0] );
451 633 }
452 634
@@ -471,9 +653,8 @@
471 653 $images = self::parse_images_from_html( $content );
472 654 if ( empty( $images ) ) {
473 655 return $content;
474 656 }
475 -
476 657 return apply_filters( 'optml_content_images_tags', $content, $images );
477 658 }
478 659
479 660 /**
@@ -513,20 +694,16 @@
513 694 */
514 695 public static function parse_images_from_html( $content ) {
515 696 $images = [];
516 697
517 - $header_start = null;
518 - $header_end = null;
519 -
520 - if ( preg_match( '/<header.*<\/header>/ismU', $content, $matches, PREG_OFFSET_CAPTURE ) === 1 ) {
521 - $header_start = $matches[0][1];
522 - $header_end = $header_start + strlen( $matches[0][0] );
698 + if ( OPTML_DEBUG ) {
699 + do_action( 'optml_log', 'Content to parse images from: ' . $content );
523 700 }
524 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';
525 702
526 703 if ( preg_match_all( $regex, $content, $images, PREG_OFFSET_CAPTURE ) ) {
527 704 if ( OPTML_DEBUG ) {
528 - do_action( 'optml_log', $images );
705 + do_action( 'optml_log', 'Image tags parsed: ' . print_r( $images, true ) );
529 706 }
530 707 foreach ( $images as $key => $unused ) {
531 708 // Simplify the output as much as possible, mostly for confirming test results.
532 709 if ( is_numeric( $key ) && $key > 0 ) {
@@ -542,9 +719,9 @@
542 719 }
543 720 $images[ $key ][ $url_key ] = $url_value[0];
544 721
545 722 if ( $key === 0 ) {
546 - $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;
547 724
548 725 // Check if we are in the noscript context.
549 726 if ( $is_no_script === false ) {
550 727 $is_no_script = strpos( $images[0][ $url_key ], '<noscript' ) !== false ? true : false;
@@ -549,9 +726,9 @@
549 726 if ( $is_no_script === false ) {
550 727 $is_no_script = strpos( $images[0][ $url_key ], '<noscript' ) !== false ? true : false;
551 728 }
552 729 if ( $is_no_script ) {
553 - $images['in_header'][ $url_key ] = true;
730 + $images['in_no_script'][ $url_key ] = true;
554 731 $is_no_script = strpos( $images[0][ $url_key ], '</noscript' ) !== false ? false : true;
555 732 }
556 733 }
557 734 }
@@ -572,13 +749,11 @@
572 749 */
573 750 public function process_urls_from_content( $html ) {
574 751 $extracted_urls = $this->extract_urls_from_content( $html );
575 752 if ( OPTML_DEBUG ) {
576 - do_action( 'optml_log', 'matched urls' );
577 - do_action( 'optml_log', $extracted_urls );
753 + do_action( 'optml_log', 'Extracted image urls from content: ' . print_r( $extracted_urls, true ) );
578 754 }
579 755 return $this->do_url_replacement( $html, $extracted_urls );
580 -
581 756 }
582 757
583 758 /**
584 759 * Method to extract assets from content.
@@ -631,9 +806,9 @@
631 806 * @param array $extracted_urls Urls to check.
632 807 *
633 808 * @return string Processed html.
634 809 */
635 - private function do_url_replacement( $html, $extracted_urls ) {
810 + public function do_url_replacement( $html, $extracted_urls ) {
636 811 $extracted_urls = apply_filters( 'optml_extracted_urls', $extracted_urls );
637 812
638 813 if ( empty( $extracted_urls ) ) {
639 814 return $html;
@@ -654,10 +829,10 @@
654 829 $is_slashed = strpos( $url, '\/' ) !== false;
655 830 $is_relative = strpos(
656 831 $url,
657 832 $is_slashed ?
658 - addcslashes( $upload_resource['content_path'], '/' ) :
659 - $upload_resource['content_path']
833 + addcslashes( $upload_resource['content_path'], '/' ) :
834 + $upload_resource['content_path']
660 835 ) === 0;
661 836 if ( $is_relative ) {
662 837 $url = $upload_resource['content_host'] . $url;
663 838 }
@@ -666,16 +841,64 @@
666 841 },
667 842 $urls
668 843 );
669 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;
670 857 foreach ( $urls as $origin => $replace ) {
671 - $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;
672 868 }
869 + if ( ! empty( $chunk ) ) {
870 + $html = $this->replace_urls_chunk( $html, $chunk, $quoted );
871 + }
673 872
674 873 return $html;
675 874 }
676 875
677 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 + /**
678 901 * Init html replacer handler.
679 902 */
680 903 public function process_template_redirect_content() {
681 904 // Early exit if function was already called, we don't want duplicate ob_start
@@ -685,13 +908,188 @@
685 908 self::$ob_started = true;
686 909 // We no longer need this if the handler was started.
687 910 remove_filter( 'the_content', [ $this, 'process_images_from_content' ], PHP_INT_MAX );
688 911
689 - ob_start(
690 - [ &$this, 'replace_content' ]
691 - );
912 + $this->start_capture_buffer();
692 913 }
693 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 + }
694 1092 /**
695 1093 * Throw error on object clone
696 1094 *
697 1095 * The whole idea of the singleton design pattern is that there is a single