diff --git a/inc/compatibilities/groovy_menu.php b/inc/compatibilities/groovy_menu.php new file mode 100644 index 00000000..2e61cf01 --- /dev/null +++ b/inc/compatibilities/groovy_menu.php @@ -0,0 +1,69 @@ +. Since 4.2.12 we capture and + * process our buffer at `shutdown` (PHP_INT_MIN) and re-arm an empty one, so + * Groovy Menu receives an empty string, finds no and drops the menu. + * + * We apply Groovy Menu's final-output filter to the page we capture, before + * image replacement, and unhook its own shutdown step at that moment. The menu + * is inserted regardless of buffer order and its images are optimized too. When + * our capture does not run (legacy `optml_capture_at_shutdown` mode, or a + * third-party flush of our buffer) Groovy Menu keeps its own shutdown step. + */ +class Optml_groovy_menu extends Optml_compatibility { + /** + * Groovy Menu's shutdown callback that grabs the top output buffer. + */ + const GROOVY_SHUTDOWN_CALLBACK = 'groovy_menu_pre_shutdown'; + + /** + * Groovy Menu's filter that inserts the menu markup into the page HTML. + */ + const GROOVY_OUTPUT_FILTER = 'groovy_menu_final_output'; + + /** + * Should we load the integration logic. + * + * @return bool Should we load. + */ + public function should_load() { + return function_exists( self::GROOVY_SHUTDOWN_CALLBACK ) + && has_action( 'shutdown', self::GROOVY_SHUTDOWN_CALLBACK ) !== false; + } + + /** + * Register integration details. + * + * @return void + */ + public function register() { + add_filter( 'optml_captured_page_html', [ $this, 'insert_menu' ] ); + } + + /** + * Insert the Groovy Menu markup into the captured page and take over its shutdown step. + * + * @param string $html The captured page HTML, before image replacement. + * + * @return string + */ + public function insert_menu( $html ) { + $priority = has_action( 'shutdown', self::GROOVY_SHUTDOWN_CALLBACK ); + if ( $priority === false ) { + return $html; + } + // Our capture ran, so Groovy Menu must not ob_get_clean() the re-armed empty buffer afterwards. + remove_action( 'shutdown', self::GROOVY_SHUTDOWN_CALLBACK, $priority ); + + // Same guard as Groovy Menu's own shutdown callback. + if ( ! defined( 'GROOVY_MENU_SCRIPTS_INIT' ) ) { + return $html; + } + + return apply_filters( self::GROOVY_OUTPUT_FILTER, $html ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Groovy Menu's own filter. + } +} diff --git a/inc/manager.php b/inc/manager.php index cb3cea3f..c0d31d90 100644 --- a/inc/manager.php +++ b/inc/manager.php @@ -116,6 +116,7 @@ final class Optml_Manager { 'hummingbird', 'aruba_hsc', 'spc', + 'groovy_menu', ]; /** * The current state of the buffer. @@ -1029,7 +1030,7 @@ public function close_final_buffer() { if ( ! self::$ob_started ) { return; } - $this->capture_and_process_buffer(); + $this->capture_and_process_buffer( false ); } /** @@ -1039,9 +1040,11 @@ public function close_final_buffer() { * buffer another plugin opened at the same level after ours was closed is * never captured or closed by us. * + * @param bool $is_page Whether this is the page capture (true) or the late shutdown output (false). + * * @return bool Whether our buffer was found and consumed. */ - private function capture_and_process_buffer() { + private function capture_and_process_buffer( $is_page = true ) { if ( self::$ob_level === 0 || ob_get_level() !== self::$ob_level ) { return false; } @@ -1054,6 +1057,18 @@ private function capture_and_process_buffer() { self::$ob_processed = true; ob_end_clean(); if ( $html !== false && $html !== '' ) { + if ( $is_page ) { + /** + * Filters the captured page HTML before Optimole processes it. + * + * Runs once per request, on the buffer captured at shutdown, outside of + * PHP's display-handler context. Late output echoed by other shutdown + * callbacks is not passed through this filter. + * + * @param string $html The full page HTML. + */ + $html = apply_filters( 'optml_captured_page_html', $html ); + } echo $this->replace_content( $html, self::is_ajax_request() ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- full page HTML, escaping would break the page. } return true; diff --git a/tests/test-zz-groovy-menu.php b/tests/test-zz-groovy-menu.php new file mode 100644 index 00000000..a6790cc3 --- /dev/null +++ b/tests/test-zz-groovy-menu.php @@ -0,0 +1,215 @@ +. + * + * @param string $output Page HTML. + * + * @return string + */ + function groovy_menu_add_after_body( $output ) { + return preg_replace( '#(\)#i', '$1' . Test_Groovy_Menu::MENU, $output, 1 ); + } +} + +/** + * Class Test_Groovy_Menu. + */ +class Test_Groovy_Menu extends WP_UnitTestCase { + const MENU = '
'; + const PAGE = ''; + + /** + * The output-buffer nesting level before each test. + * + * @var int + */ + private $base_level = 0; + + /** + * The compatibility under test. + * + * @var Optml_groovy_menu + */ + private $compatibility; + + public function setUp(): void { + parent::setUp(); + if ( ! defined( 'GROOVY_MENU_SCRIPTS_INIT' ) ) { + define( 'GROOVY_MENU_SCRIPTS_INIT', true ); + } + $settings = new Optml_Settings(); + $settings->update( 'service_data', [ + 'cdn_key' => 'test123', + 'cdn_secret' => '12345', + 'whitelist' => [ 'example.com', 'example.org' ], + ] ); + $settings->update( 'lazyload', 'disabled' ); + $settings->update( 'cdn', 'enabled' ); + Optml_Url_Replacer::instance()->init(); + Optml_Tag_Replacer::instance()->init(); + Optml_Manager::instance()->init(); + + $GLOBALS['gm_test_shutdown_calls'] = 0; + add_action( 'shutdown', 'groovy_menu_pre_shutdown', 0 ); + add_filter( 'groovy_menu_final_output', 'groovy_menu_add_after_body' ); + $this->compatibility = new Optml_groovy_menu(); + + $this->reset_buffer_state(); + $this->base_level = ob_get_level(); + } + + public function tearDown(): void { + $this->reset_buffer_state( true ); + while ( ob_get_level() > $this->base_level ) { + // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged + if ( ! @ob_end_clean() ) { + break; + } + } + $this->reset_buffer_state(); + remove_action( 'shutdown', 'groovy_menu_pre_shutdown', 0 ); + remove_filter( 'groovy_menu_final_output', 'groovy_menu_add_after_body' ); + remove_filter( 'optml_captured_page_html', [ $this->compatibility, 'insert_menu' ] ); + remove_filter( 'optml_capture_at_shutdown', '__return_false' ); + parent::tearDown(); + } + + /** + * Reset Optml_Manager buffer statics between tests. + * + * @param bool $processed Value for the processed flag. + */ + private function reset_buffer_state( $processed = false ) { + $reflection = new ReflectionClass( Optml_Manager::class ); + foreach ( [ 'ob_started' => false, 'ob_level' => 0, 'ob_processed' => $processed ] as $property => $value ) { + $prop = $reflection->getProperty( $property ); + $prop->setAccessible( true ); + $prop->setValue( null, $value ); + } + } + + /** + * The compatibility loads only when Groovy Menu's auto-integration hooked its shutdown step. + */ + public function test_loads_only_with_auto_integration() { + $this->assertTrue( $this->compatibility->should_load() ); + remove_action( 'shutdown', 'groovy_menu_pre_shutdown', 0 ); + $this->assertFalse( $this->compatibility->should_load() ); + } + + /** + * Groovy Menu buffer opened on init sits below ours: the menu is inserted and optimized. + */ + public function test_menu_inserted_when_groovy_buffer_is_below_ours() { + $this->compatibility->register(); + $manager = Optml_Manager::instance(); + ob_start(); + ob_start(); // Groovy Menu's own buffer, opened on init. + $manager->process_template_redirect_content(); + echo self::PAGE; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $manager->close_buffer(); + + $this->assertFalse( has_action( 'shutdown', 'groovy_menu_pre_shutdown' ), 'Groovy Menu shutdown step is taken over.' ); + + $manager->close_final_buffer(); + ob_end_flush(); // Core flushes Groovy Menu's buffer at shutdown. + $out = ob_get_clean(); + + $this->assertSame( 1, substr_count( $out, 'gm-navbar' ) ); + $this->assertSame( 2, substr_count( $out, 'i.optimole.com' ) ); + $this->assertStringNotContainsString( '"http://example.org/wp-content/uploads/gm-logo.jpg', $out ); + $this->assertSame( 0, $GLOBALS['gm_test_shutdown_calls'] ); + $this->assertSame( $this->base_level, ob_get_level() ); + } + + /** + * Groovy Menu buffer stacked above ours is flushed through, and the menu is still inserted. + */ + public function test_menu_inserted_when_groovy_buffer_is_above_ours() { + $this->compatibility->register(); + $manager = Optml_Manager::instance(); + ob_start(); + $manager->process_template_redirect_content(); + ob_start(); // Groovy Menu's buffer opened after ours. + echo self::PAGE; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $manager->close_buffer(); + $manager->close_final_buffer(); + $out = ob_get_clean(); + + $this->assertFalse( has_action( 'shutdown', 'groovy_menu_pre_shutdown' ) ); + $this->assertSame( 1, substr_count( $out, 'gm-navbar' ) ); + $this->assertSame( 2, substr_count( $out, 'i.optimole.com' ) ); + $this->assertSame( $this->base_level, ob_get_level() ); + } + + /** + * In legacy in-handler mode Groovy Menu keeps its own shutdown step and still works. + */ + public function test_legacy_mode_keeps_groovy_shutdown_step() { + add_filter( 'optml_capture_at_shutdown', '__return_false' ); + $this->compatibility->register(); + $manager = Optml_Manager::instance(); + ob_start(); + ob_start(); // Groovy Menu's buffer. + $manager->process_template_redirect_content(); + echo self::PAGE; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $manager->close_buffer(); + + $this->assertSame( 0, has_action( 'shutdown', 'groovy_menu_pre_shutdown' ), 'Groovy Menu shutdown step is left in place.' ); + + groovy_menu_pre_shutdown(); // Groovy Menu at shutdown priority 0. + $out = ob_get_clean(); + + $this->assertSame( 1, $GLOBALS['gm_test_shutdown_calls'] ); + $this->assertSame( 1, substr_count( $out, 'gm-navbar' ) ); + $this->assertSame( 1, substr_count( $out, 'i.optimole.com' ) ); + $this->assertSame( $this->base_level, ob_get_level() ); + } + + /** + * When third-party code flushes our buffer before shutdown, Groovy Menu keeps its own shutdown step. + */ + public function test_early_flush_keeps_groovy_shutdown_step() { + $this->compatibility->register(); + $manager = Optml_Manager::instance(); + ob_start(); + ob_start(); // Groovy Menu's buffer. + $manager->process_template_redirect_content(); + echo self::PAGE; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + ob_end_flush(); // Third-party force flush of our buffer. + $manager->close_buffer(); + $manager->close_final_buffer(); + + $this->assertSame( 0, has_action( 'shutdown', 'groovy_menu_pre_shutdown' ) ); + + groovy_menu_pre_shutdown(); + $out = ob_get_clean(); + + $this->assertSame( 1, substr_count( $out, 'gm-navbar' ) ); + $this->assertSame( $this->base_level, ob_get_level() ); + } +}