*/ private array $executed_rules = []; /** * Constructor. * * @since 1.0.0 * @param Logger $logger Logger instance. */ public function __construct(Logger $logger) { $this->logger = $logger; } /** * Get the HTML5 parser, created on first use. * * The HTML namespace is disabled so plain XPath expressions match. * * @since 1.0.29 * @return HTML5 Parser instance. */ private function get_parser(): HTML5 { if ($this->html5_parser === null) { $this->html5_parser = new HTML5(['disable_html_ns' => true]); } return $this->html5_parser; } /** * Process HTML content and hide elements matching XPath rules. * * @since 1.0.0 * @param string $html Input HTML content. * @param array> $rules Array of XPath rules. * @param bool $is_selection_mode Whether selection mode is active. * @return string Processed HTML content. */ public function process_html(string $html, array $rules, bool $is_selection_mode = false): string { if (empty($rules) || trim($html) === '') { return $html; } $rules = array_filter($rules, static function (array $rule): bool { return PageContext::rule_applies_here($rule); }); $xpaths = []; foreach ($rules as $rule) { if (empty($rule['xpath']) || empty($rule['active'])) { continue; } if (isset($this->executed_rules[$rule['xpath']])) { continue; } $xpaths[] = $rule['xpath']; } if (empty($xpaths)) { return $html; } // In selection mode elements stay in place and are only hidden visually, // so the user can still see and pick them. $processed_html = $this->apply_rules($html, $xpaths, !$is_selection_mode); // Rules that did not match server-side HTML are handed over to the // client-side runtime through a per-page transient. $failed_rule_ids = []; foreach ($rules as $rule) { $xpath = $rule['xpath'] ?? ''; if (!empty($xpath) && !empty($rule['active']) && !isset($this->executed_rules[$xpath])) { $failed_rule_ids[] = $rule['id']; } } PageContext::set_tracked_transient( 'adsd_failed_rules_' . PageContext::get_page_cache_key(), $failed_rule_ids, self::FAILED_RULES_TTL ); return $processed_html; } /** * Apply XPath rules to provided HTML. * * @since 1.0.0 * @param string $html Input HTML content. * @param array $xpaths Array of XPath expressions. * @param bool $delete_nodes True to delete nodes, false to hide via CSS class. * @return string Processed HTML content. */ public function apply_rules(string $html, array $xpaths, bool $delete_nodes = false): string { if (empty($xpaths) || trim($html) === '') { return $html; } // Ensure HTML has a root element for DOMDocument if (strpos($html, ''; } try { $dom = $this->get_parser()->loadHTML($html); if (!$dom) { return $html; } $xpath = new \DOMXPath($dom); $modified = false; // Deeper positional indices first, so removing div[2] cannot shift div[1]. usort($xpaths, static function (string $a, string $b): int { preg_match_all('/\[(\d+)\]/', $a, $matches_a); preg_match_all('/\[(\d+)\]/', $b, $matches_b); $indices_a = array_map('intval', $matches_a[1]); $indices_b = array_map('intval', $matches_b[1]); $max_depth = max(count($indices_a), count($indices_b)); for ($i = $max_depth - 1; $i >= 0; $i--) { $val_a = $indices_a[$i] ?? 0; $val_b = $indices_b[$i] ?? 0; if ($val_a !== $val_b) { return $val_b <=> $val_a; } } return 0; }); foreach ($xpaths as $expr) { $expr = $this->sanitize_xpath($expr); if ($expr === '') { continue; } if ($this->apply_xpath_expression($xpath, $expr, $delete_nodes)) { $modified = true; $this->executed_rules[$expr] = true; } } if ($modified) { return $this->get_parser()->saveHTML($dom); } } catch (\Throwable $e) { // Never break the page because of a broken rule or malformed HTML. $this->logger->error('XPath processing error', ['message' => $e->getMessage()]); } return $html; } /** * Apply a single XPath expression to hide matching elements. * * @since 1.0.0 * @param \DOMXPath $xpath XPath object. * @param string $xpath_expression XPath expression. * @param bool $delete_nodes True to delete nodes, false to hide via CSS. * @return bool True if elements were hidden, false otherwise. */ private function apply_xpath_expression(\DOMXPath $xpath, string $xpath_expression, bool $delete_nodes = false): bool { try { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A malformed expression raises a warning; the false return value is handled below $nodes = @$xpath->evaluate($xpath_expression); if (!$nodes instanceof \DOMNodeList || $nodes->length === 0) { $this->logger->debug('XPath element not found, rule skipped', ['expression' => $xpath_expression]); return false; } // Deepest nodes first, so replacing a parent cannot invalidate children. $collected = []; foreach ($nodes as $node) { if (!$node instanceof \DOMElement && !$node instanceof \DOMText) { continue; } $depth = 0; $parent = $node; while ($parent = $parent->parentNode) { $depth++; } $collected[] = ['node' => $node, 'depth' => $depth]; } usort($collected, static function (array $a, array $b): int { return $b['depth'] <=> $a['depth']; }); $count = 0; foreach ($collected as $item) { $node = $item['node']; if ($delete_nodes) { if ($node->parentNode) { // A hidden placeholder preserves the surrounding flow. $placeholder = $xpath->document->createElement('span'); $placeholder->setAttribute('data-adsd-removed', '1'); $placeholder->setAttribute('aria-hidden', 'true'); $node->parentNode->replaceChild($placeholder, $node); } } else { $this->hide_node_visually($node); } $count++; if ($count >= self::MAX_NODES_PER_RULE) { break; } } return $count > 0; } catch (\Throwable $e) { $this->logger->error('XPath rule error', [ 'expression' => $xpath_expression, 'message' => $e->getMessage(), ]); return false; } } /** * Mark a node as visually hidden instead of removing it. * * @since 1.0.29 * @param \DOMNode $node Node to hide. */ private function hide_node_visually(\DOMNode $node): void { $target = $node instanceof \DOMElement ? $node : $node->parentNode; if (!$target instanceof \DOMElement) { return; } $target->setAttribute('class', trim($target->getAttribute('class') . ' adsd-visually-hidden')); } /** * Reject XPath expressions containing markup or script payloads. * * @since 1.0.0 * @param string $xpath XPath expression. * @return string Sanitized XPath expression, or an empty string when rejected. */ private function sanitize_xpath(string $xpath): string { $xpath = trim($xpath); $dangerous_patterns = [ '/javascript:/i', '/