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
, 111 closing
(balance: +1) * - At the end:
(2 closing tags) * - Total: 228 opening
, 230 closing
(balance: -2, 2 extra closing tags) * * How browsers handle this: * Browsers are tolerant to invalid HTML. They ignore "extra" closing tags at the end * and build the DOM tree correctly. So #wpfooter stays inside #wpwrap as intended. * * How Masterminds\HTML5 parser handles this: * The HTML5 parser follows strict HTML5 specification and "corrects" the HTML by * removing what it considers "extra" closing tags. Specifically, it removes * one before comment, changing: *
* to: *
* * This "correction" happens during loadHTML() BEFORE any XPath modifications. * After this "fix", the HTML structure becomes truly broken: * - The unclosed div (+1 balance) between wpwrap and wpfooter is no longer closed * - Browser receives the "corrected" HTML and builds DOM with #wpfooter outside #wpwrap * - This breaks the admin page layout (footer appears at the top of content area) * * Attempted solutions: * 1. Using native DOMDocument instead of HTML5 parser - REJECTED (causes other issues) * 2. Post-processing to restore removed tags - PARTIALLY WORKS but fragile * 3. Excluding problematic pages completely - loses functionality * 4. Frontend-only approach (like React pages) - CURRENT SOLUTION * - Skip server-side HTML processing for excluded pages * - Mark all rules as "failed" to trigger InlineCssGenerator CSS output * - FrontendHider JavaScript hides/removes elements client-side * - This works because JavaScript manipulates the already-parsed DOM * * Root cause: * WordPress intentionally outputs "invalid" HTML that browsers handle correctly, * but HTML5 parser's strict correction breaks this intentional structure. * * @since 1.0.0 * @return bool True if current page should be excluded from processing. */ private function is_excluded_page(): bool { $request_uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : ''; /** * List of admin pages excluded from HTML processing. * * These pages have HTML structure issues that cause layout problems * when processed by the HTML5 parser. */ $excluded_pages = [ 'options-general.php', // Add other problematic pages here as discovered ]; foreach ($excluded_pages as $page) { if (str_contains($request_uri, $page)) { $this->logger->debug('Page excluded from processing', [ 'page' => $page, 'request_uri' => $request_uri ]); return true; } } return false; } /** * Check if content is XML (not HTML). * * @param string $content * @return bool */ private function is_xml_content(string $content): bool { $trimmed = trim($content); return str_starts_with($trimmed, '