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( '#(\
';
+
+ /**
+ * 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() );
+ }
+}