xpath_engine = $xpath_engine; $this->options_repository = $options_repository; $this->logger = $logger; } /** * Initialize output buffering. */ public function init(): void { // Start output buffering for frontend add_action('template_redirect', [$this, 'start_output_buffering']); // Start output buffering for admin pages add_action('admin_init', [$this, 'start_output_buffering']); } /** * Start output buffering. */ public function start_output_buffering(): void { if (!$this->should_process_output()) { return; } // Start output buffering for processing HTML // Note: WordPress and other plugins may already have buffers active, // but we can still add our own buffer to the stack // The callback function will be called automatically when the buffer is flushed ob_start([$this, 'process_output']); // Register shutdown function to ensure buffer is closed even on errors register_shutdown_function(function() { $level = ob_get_level(); if ($level > 0) { // Check if our buffer is still active while ($level > 0 && ob_get_level() > 0) { ob_end_flush(); $level--; } } }); } /** * Process the output buffer content. * * This callback is automatically called when the output buffer is flushed. * It must always return a string to ensure the buffer is properly closed. * * @param string $content * @return string Always returns a string to ensure buffer closure */ public function process_output(string $content): string { // Always return a string, even if empty if (empty($content) || $this->is_xml_content($content)) { return (string) $content; } try { $rules = $this->options_repository->get_active_rules(); // Check if selection mode is active (from sessionStorage via JavaScript) $is_selection_mode = $this->is_selection_mode_active(); // Debug: log HTML size and check for WBCR $has_wbcr = strpos($content, 'id="WBCR"') !== false; $this->logger->debug('Output buffer received HTML', [ 'size' => strlen($content), 'has_wbcr' => $has_wbcr, 'has_wpbody' => strpos($content, 'id="wpbody-content"') !== false ]); $processed = $this->xpath_engine->process_html($content, $rules, $is_selection_mode); // Ensure we always return a string return (string) $processed; } catch (\Exception $e) { // Log error but don't break the page if ($this->logger) { $this->logger->error('Error processing output', ['message' => $e->getMessage()]); } else { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Fallback error logging error_log('AdsDestroyer: Error processing output - ' . $e->getMessage()); } // Always return original content as string to ensure buffer closure return (string) $content; } } /** * Check if output processing should be enabled. * * @return bool */ private function should_process_output(): bool { // Allow processing admin pages for block hiding // This enables hiding blocks on admin pages when rules are created there // Don't process AJAX requests if (wp_doing_ajax()) { return false; } // Don't process REST API requests if (defined('REST_REQUEST') && REST_REQUEST) { return false; } // Don't process if no rules are configured $rules = $this->options_repository->get_active_rules(); if (empty($rules)) { return false; } // Check for excluded pages - use frontend-only approach (like React pages) if ($this->is_excluded_page()) { $this->mark_all_rules_as_failed_for_frontend(); return false; } return true; } /** * Mark all active rules as "failed" for the current page. * * This triggers InlineCssGenerator to generate CSS for all rules, * and FrontendHider JavaScript will hide/remove elements client-side. * Used for pages excluded from server-side HTML processing. * * @since 1.0.0 */ private function mark_all_rules_as_failed_for_frontend(): void { $rules = $this->options_repository->get_active_rules(); if (empty($rules)) { return; } // Collect all rule IDs $all_rule_ids = []; foreach ($rules as $rule) { if (!empty($rule['id']) && !empty($rule['active'])) { $all_rule_ids[] = $rule['id']; } } if (empty($all_rule_ids)) { return; } // Save to transient so InlineCssGenerator will generate CSS for all rules $page_key = $this->get_page_cache_key(); $transient_key = 'adsd_failed_rules_' . $page_key; set_transient($transient_key, $all_rule_ids, 300); // 5 minutes $this->logger->debug('Marked all rules as failed for frontend processing', [ 'page_key' => $page_key, 'rules_count' => count($all_rule_ids), 'reason' => 'excluded_page' ]); } /** * Get page-specific cache key based on current URL. * * @since 1.0.0 * @return string MD5 hash of page URL without dynamic parameters. */ private function get_page_cache_key(): string { $uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : ''; // Parse URL and remove dynamic parameters $parsed = wp_parse_url($uri); $path = $parsed['path'] ?? ''; // Parse query string and keep only stable parameters $query = []; if (!empty($parsed['query'])) { parse_str($parsed['query'], $params); // Keep only stable admin page params (not timestamps, nonces, etc) $stable_params = ['page', 'post_type', 'taxonomy']; foreach ($stable_params as $key) { if (isset($params[$key])) { $query[$key] = $params[$key]; } } } // Build cache key $key_string = $path; if (!empty($query)) { ksort($query); $key_string .= '?' . http_build_query($query); } return md5($key_string); } /** * Check if the current page is excluded from processing. * * @todo KNOWN ISSUE: WordPress admin HTML structure problem * * Problem description: * The original WordPress admin HTML has an INVALID structure with unbalanced div tags: * - Between #wpwrap and #wpfooter: 112 opening