to hide elements before React renders them, * preventing visual "flashing". Converts XPath to CSS where possible, or uses * saved css_selector from rules. * * @since 1.0.1 */ final class InlineCssGenerator { private const CACHE_KEY = 'adsd_inline_css_cache'; private const CACHE_DURATION = 300; // 5 minutes private const MAX_RULES = 50; private const MAX_CSS_LENGTH = 10240; // 10KB private const MAX_SELECTOR_LENGTH = 512; /** * Characters allowed in a CSS selector. * * Selectors partly come from the browser, so they are validated before being * printed inside a `'; } /** * Generate CSS from all active rules. * FrontendHider will determine which elements actually need hiding. * * @since 1.0.1 * @return string Generated CSS */ private function generate_css(): string { $rules = $this->options_repository->get_active_rules(); if (empty($rules)) { return ''; } // Limit rules $rules = array_slice($rules, 0, self::MAX_RULES, true); // Get rules that failed on backend (element not found in server HTML) $page_key = PageContext::get_page_cache_key(); $failed_rule_ids = get_transient('adsd_failed_rules_' . $page_key); if ($failed_rule_ids === false) { // First load after rule creation - no transient yet. // Let the server-side pass try first; the next load knows what failed. return ''; } if (empty($failed_rule_ids)) { // All rules were successfully applied on backend return ''; } $selectors = []; foreach ($rules as $rule) { // Skip rules that were successfully applied on backend if (!in_array($rule['id'], $failed_rule_ids, true)) { continue; } // Skip rules recorded on another screen if (!PageContext::rule_applies_here($rule)) { continue; } $selectors = array_merge($selectors, $this->get_all_css_selectors_for_rule($rule)); } $selectors = array_unique($selectors); if (empty($selectors)) { return ''; } // Generate CSS with all selectors $css = implode(',', $selectors) . '{display:none!important;visibility:hidden!important;}'; // Check length limit if (strlen($css) > self::MAX_CSS_LENGTH) { $this->logger->warning('Generated CSS exceeds limit', ['length' => strlen($css)]); return ''; } return $css; } /** * Get ALL CSS selectors for a rule (both XPath→CSS and saved css_selector). * * @since 1.0.1 * @param array $rule Rule data * @return array Array of CSS selectors */ private function get_all_css_selectors_for_rule(array $rule): array { $selectors = []; // Strategy 1: Convert XPath to CSS if (!empty($rule['xpath'])) { $css = $this->xpath_to_css((string) $rule['xpath']); if ($css !== null) { $css = $this->validate_selector($css); if ($css !== null) { $selectors[] = $css; } } } // Strategy 2: Use the selector generated by the browser (fallback) if (!empty($rule['css_selector'])) { $selector = $this->validate_selector((string) $rule['css_selector']); if ($selector !== null) { $selectors[] = $selector; } else { $this->logger->warning('Rejected unsafe CSS selector', ['rule_id' => $rule['id'] ?? '']); } } return $selectors; } /** * Validate a CSS selector before it is printed into a style block. * * @since 1.0.29 * @param string $selector Selector to validate. * @return string|null Trimmed selector or null when it is not safe. */ private function validate_selector(string $selector): ?string { $selector = trim($selector); if ($selector === '' || strlen($selector) > self::MAX_SELECTOR_LENGTH) { return null; } if (!preg_match(self::SELECTOR_PATTERN, $selector)) { return null; } if (strpos($selector, '/*') !== false || strpos($selector, '*/') !== false) { return null; } return $selector; } /** * Convert XPath to a CSS selector (the forms the picker generates). * * Supported patterns: * - //*[@id="X"] → #X * - //*[@id="X"]/div[2]/span → #X > div:nth-of-type(2) > span * - //div[contains(concat(" ", normalize-space(@class), " "), " a ")] → div.a * - //*[@id="X"]//div[contains(...)] → #X div.a * - //div[@data-id="v"] → div[data-id="v"] * * Positions in XPath count siblings of the same tag, which is :nth-of-type * in CSS, not :nth-child. * * @since 1.0.1 * @param string $xpath XPath expression * @return string|null CSS selector or null if conversion not possible */ public function xpath_to_css(string $xpath): ?string { $xpath = trim($xpath); if ($xpath === '' || strncmp($xpath, '//', 2) !== 0) { return null; } // Split into steps, keeping the descendant ("//") combinator. $tokens = preg_split('#(//|/)#', $xpath, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); if (!is_array($tokens) || $tokens === []) { return null; } $css = ''; $combinator = ''; foreach ($tokens as $token) { if ($token === '//') { $combinator = $css === '' ? '' : ' '; continue; } if ($token === '/') { $combinator = $css === '' ? '' : ' > '; continue; } $step = $this->step_to_css($token); if ($step === null) { return null; } $css .= $combinator . $step; $combinator = ''; } return $css === '' ? null : $css; } /** * Convert a single XPath step to CSS. * * @since 1.0.31 * @param string $step Step such as `div[2]` or `*[@id="x"]`. * @return string|null CSS fragment or null when the step is not supported. */ private function step_to_css(string $step): ?string { if (!preg_match('/^(\*|[a-z][a-z0-9]*)((?:\[[^\]]*\])*)$/i', $step, $matches)) { return null; } $tag = strtolower($matches[1]); $css = $tag === '*' ? '' : $tag; if ($matches[2] === '') { return $css === '' ? '*' : $css; } if (!preg_match_all('/\[([^\]]*)\]/', $matches[2], $predicates)) { return null; } foreach ($predicates[1] as $predicate) { $predicate = trim($predicate); if (preg_match('/^\d+$/', $predicate)) { $css .= ':nth-of-type(' . (int) $predicate . ')'; continue; } if (preg_match('/^@id=(["\'])([^"\']+)\1$/', $predicate, $id_match)) { $css .= '#' . $id_match[2]; continue; } if (preg_match('/normalize-space\(@class\).*?,\s*(["\'])\s([^"\']+)\s\1\s*\)$/', $predicate, $class_match)) { $css .= '.' . $class_match[2]; continue; } if (preg_match('/^@(data-[a-z0-9_-]+)=(["\'])([^"\']*)\2$/i', $predicate, $attr_match)) { $css .= '[' . strtolower($attr_match[1]) . '="' . $attr_match[3] . '"]'; continue; } return null; } return $css; } /** * Get cached CSS. * * @since 1.0.1 * @return string|null Cached CSS or null */ private function get_cached_css(): ?string { $cached = get_transient(self::CACHE_KEY . '_' . PageContext::get_page_cache_key()); return is_string($cached) ? $cached : null; } /** * Cache CSS. * * @since 1.0.1 * @param string $css CSS to cache */ private function cache_css(string $css): void { PageContext::set_tracked_transient( self::CACHE_KEY . '_' . PageContext::get_page_cache_key(), $css, self::CACHE_DURATION ); } /** * Invalidate CSS cache. * * @since 1.0.1 */ public function invalidate_cache(): void { PageContext::flush_tracked_transients(); } }